feat(piano-roll): ghost notes + dropdown item switcher + session sync

- MIDI ghost notes: all non-selected items rendered at 25% opacity
- Dropdown at tab title: switch active edit target across all tracks
- Session Sync mode (default): viewport aligned to session bars
- Isolated mode toggle: bar 0, no ghost notes
- Ghost toggle: show/hide ghost layer (only in session mode)
- Auto-scroll to session position in session sync mode
- Bar labels show absolute session bar numbers
This commit is contained in:
2026-07-27 17:08:59 +07:00
parent c4a320a1c1
commit 035d75e504
5 changed files with 213 additions and 18 deletions
+123 -12
View File
@@ -4560,7 +4560,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, onInstrumentSelect, onRescheduleMidi, onSeekPlayhead }) => {
const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClose, onUpdateNotes, onSaveNotes, setSubTabs, onPlayPause, onStop, isPlaying, playPreviewNote, showToast, midiDevices, recordingState, recTempMidiNotes, onRecord, selectedMidiInputId, onMidiInputSelect, activeMidiPitches, onInstrumentSelect, onRescheduleMidi, onSeekPlayhead }) => {
const [activeRollTool, setActiveRollTool] = React.useState('select');
const [snapVal, setSnapVal] = React.useState('1/16');
const [ccMode, setCcMode] = React.useState('velocity');
@@ -4610,6 +4610,8 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const [loopStartBeat, setLoopStartBeat] = React.useState(null);
const [loopEndBeat, setLoopEndBeat] = React.useState(null);
const [isLooping, setIsLooping] = React.useState(false);
const [showGhostNotes, setShowGhostNotes] = React.useState(true);
const [sessionSyncMode, setSessionSyncMode] = React.useState(true);
const rulerDragRef = React.useRef(null);
React.useEffect(() => {
const handler = (e) => {
@@ -4831,7 +4833,29 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
ctx.stroke();
}
// Draw notes with velocity layer representation
// Layer 2: Ghost Notes (background reference from other tracks)
if (showGhostNotes && sessionSyncMode && ghostLayers.length > 0) {
ghostLayers.forEach(function(layer) {
ctx.save();
ctx.globalAlpha = 0.25;
ctx.fillStyle = layer.track_color || '#888';
ctx.strokeStyle = layer.track_color || '#888';
layer.notes.forEach(function(note) {
var x = note.relative_start_beat * pixelsPerBeat;
var y = (127 - note.pitch) * NoteHeight;
var w = note.duration_beats * pixelsPerBeat;
var h = NoteHeight - 1;
ctx.fillRect(x, y, w, h);
ctx.setLineDash([2, 2]);
ctx.lineWidth = 1;
ctx.strokeRect(x, y, w, h);
ctx.setLineDash([]);
});
ctx.restore();
});
}
// Layer 3: Active notes with velocity layer representation
notes.forEach((note) => {
const x = note.start_beat * pixelsPerBeat;
const y = (127 - note.pitch) * NoteHeight;
@@ -4903,7 +4927,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
ctx.stroke();
}
}
}, [notes, snapVal, rollZoom, selectedNoteIds, selectionMarquee, st.currentTime, bpm, viewWidth, viewBeats, recordingState, recTempMidiNotes]);
}, [notes, snapVal, rollZoom, selectedNoteIds, selectionMarquee, st.currentTime, bpm, viewWidth, viewBeats, recordingState, recTempMidiNotes, showGhostNotes, sessionSyncMode, ghostLayers]);
React.useLayoutEffect(() => {
const canvas = ccCanvasRef.current;
@@ -4962,6 +4986,14 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
return () => clearTimeout(timer);
}, []);
// Auto-scroll to session position when in session sync mode
React.useEffect(function() {
if (sessionSyncMode && rulerScrollRef.current && activeTargetItem) {
var scrollTargetBeats = sessionStartBar * 4;
rulerScrollRef.current.scrollLeft = scrollTargetBeats * pixelsPerBeat;
}
}, [sessionSyncMode, sessionStartBar, st.target_id, pixelsPerBeat]);
const handleGridMouseDown = (e) => {
const canvas = canvasRef.current;
if (!canvas) return;
@@ -5646,6 +5678,63 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const [showCC, setShowCC] = React.useState(true);
const [ccHeight, setCcHeight] = React.useState(80);
const allMidiItems = React.useMemo(() => {
const result = [];
(activeTracks || []).forEach(t => {
if (!t.midiItems || !t.midiItems.length) return;
t.midiItems.forEach(m => {
var extended = Object.assign({}, m, { _trackId: t.id, _trackName: t.name });
result.push(extended);
});
});
return result;
}, [activeTracks]);
const ghostLayers = React.useMemo(function() {
if (!activeTracks || !st || !st.target_id) return [];
var fn = window.SonicGhost && window.SonicGhost.extractGhostLayers;
return fn ? fn(activeTracks, st.trackId, st.target_id, parseInt(bpm) || 120) : [];
}, [activeTracks, st.trackId, st.target_id, bpm]);
const secondsPerBar = (60.0 / (parseInt(bpm) || 120)) * 4;
const activeTargetItem = React.useMemo(function() {
if (!activeTracks || !st) return null;
var trk = activeTracks.find(function(t) { return t.id === st.trackId; });
return trk ? (trk.midiItems || []).find(function(m) { return m.id === st.target_id; }) : null;
}, [activeTracks, st.trackId, st.target_id]);
const sessionStartBar = sessionSyncMode && activeTargetItem
? (activeTargetItem.startTime / secondsPerBar) : 0;
const handleSwitchMidiItem = React.useCallback(function(itemId) {
if (itemId === st.target_id) return;
if (st.isDirty) {
onSaveNotes(st.id, st.trackId, st.target_id, notes);
}
var match = allMidiItems.find(function(m) { return m.id === itemId; });
if (!match) return;
var trk = (activeTracks || []).find(function(t) { return t.id === match._trackId; });
setSubTabs(function(prev) {
return prev.map(function(s) {
if (s.id !== st.id) return s;
return Object.assign({}, s, {
trackId: match._trackId,
target_id: match.id,
label: 'Piano Roll: ' + (match.name || 'MIDI'),
notes: match.notes || [],
duration: match.duration || 4,
instrumentProgram: trk ? trk.instrumentProgram : undefined,
instrumentName: trk ? trk.instrumentName : undefined,
note_selection: [],
currentTime: 0,
isDirty: false
});
});
});
setSelectedNoteIds([]);
setLoopStartBeat(null);
setLoopEndBeat(null);
}, [st.id, st.target_id, st.isDirty, allMidiItems, activeTracks, onSaveNotes, notes]);
const snapPitchToScale = (pitch, scale) => {
if (!scale) return pitch;
const octave = Math.floor(pitch / 12);
@@ -5711,8 +5800,10 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const renderBarLabels = () => {
const labels = [];
const barsCount = Math.ceil(viewBeats / 4);
const barOffset = Math.floor(sessionStartBar);
for (let bar = 0; bar < barsCount; bar++) {
const x = bar * 4 * pixelsPerBeat;
const displayBar = bar + barOffset;
labels.push(
/*#__PURE__*/React.createElement("div", {
key: bar,
@@ -5724,10 +5815,10 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
className: "pl-1 border-l border-zinc-700 h-full select-none cursor-pointer hover:bg-zinc-800/30",
onClick: (e) => {
e.stopPropagation();
const barTime = bar * 4 * beatSec;
const barTime = displayBar * 4 * beatSec;
setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: barTime } : s));
}
}, `Bar ${bar}`)
}, `Bar ${displayBar}`)
);
}
return labels;
@@ -5744,12 +5835,15 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
className: "h-10 bg-[#282828] border-b border-zinc-900 flex items-center justify-between px-4 shrink-0 text-slate-200"
}, React.createElement("div", {
className: "flex items-center gap-4"
}, React.createElement("span", {
className: "text-xs font-bold text-yellow-500 uppercase flex items-center gap-1.5"
}, React.createElement("i", {
"data-lucide": "music",
className: "w-3.5 h-3.5"
}), st.label), React.createElement("div", {
}, React.createElement("select", {
value: st.target_id || '',
onChange: function(e) { handleSwitchMidiItem(e.target.value); },
className: "bg-zinc-800 border border-zinc-700 text-yellow-500 font-bold text-xs rounded px-1.5 py-0.5 outline-none focus:border-yellow-500 max-w-[180px] uppercase"
}, allMidiItems.map(function(m) {
return React.createElement("option", { key: m.id, value: m.id },
(m._trackName || '') + ' - ' + (m.name || 'MIDI')
);
})), React.createElement("div", {
className: "flex items-center gap-1 text-xs"
}, React.createElement("span", {
className: "text-zinc-500 font-semibold"
@@ -5798,7 +5892,23 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
}, mode)))), 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'), React.createElement("div", {
}, ccMode === 'pan' ? 'Pan' : 'Vel'), React.createElement("button", {
onClick: function() { setSessionSyncMode(function(p) { return !p; }); },
className: function() {
var base = 'px-2 py-1 rounded text-xs ';
return sessionSyncMode ? base + 'bg-cyan-900/60 text-cyan-300 border border-cyan-700' : base + 'text-zinc-500 hover:text-zinc-300';
}(),
title: sessionSyncMode ? "Session-synced mode (ghost visible)" : "Isolated mode (bar 0, no ghost)"
}, sessionSyncMode ? "\uD83C\uDF10 Session" : "\uD83D\uDCCB Isolated"), React.createElement("button", {
onClick: function() { setShowGhostNotes(function(p) { return !p; }); },
disabled: !sessionSyncMode,
className: function() {
if (!sessionSyncMode) return 'px-2 py-1 rounded text-xs opacity-30 cursor-not-allowed';
var base = 'px-2 py-1 rounded text-xs ';
return showGhostNotes ? base + 'bg-purple-900/60 text-purple-300 border border-purple-700' : base + 'text-zinc-500 hover:text-zinc-300';
}(),
title: "Toggle ghost notes visibility"
}, "\uD83D\uDC7B Ghost"), React.createElement("div", {
className: "flex items-center gap-1"
}, React.createElement("button", {
onClick: () => onSaveNotes(st.id, st.trackId, st.target_id, notes),
@@ -16115,6 +16225,7 @@ const App = () => {
zoom: zoom,
bpm: bpm,
viewportWidth: viewportWidth,
activeTracks: activeTracks,
onClose: () => closeSubTab(st.id),
onUpdateNotes: handleUpdateMidiNotes,
onSaveNotes: handleSaveMidiNotes,