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:
+123
-12
@@ -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,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,75 @@
|
||||
// SonicForge Studio Ghost Note Extractor Service
|
||||
(function() {
|
||||
function extractGhostLayers(activeTracks, targetTrackId, targetItemId, bpm) {
|
||||
if (!activeTracks || !targetItemId) return [];
|
||||
|
||||
const secondsPerBeat = 60.0 / (parseInt(bpm) || 120);
|
||||
|
||||
let targetItem = null;
|
||||
for (var i = 0; i < activeTracks.length; i++) {
|
||||
var t = activeTracks[i];
|
||||
var found = (t.midiItems || []).find(function(m) { return m.id === targetItemId; });
|
||||
if (found) { targetItem = found; break; }
|
||||
}
|
||||
if (!targetItem) return [];
|
||||
|
||||
const windowStartBeat = targetItem.startTime / secondsPerBeat;
|
||||
const windowEndBeat = (targetItem.startTime + targetItem.duration) / secondsPerBeat;
|
||||
|
||||
const ghostLayers = [];
|
||||
|
||||
for (var i = 0; i < activeTracks.length; i++) {
|
||||
var track = activeTracks[i];
|
||||
if (!track.midiItems || !track.midiItems.length) continue;
|
||||
if (track.muted) continue;
|
||||
|
||||
var trackGhostNotes = [];
|
||||
|
||||
for (var j = 0; j < track.midiItems.length; j++) {
|
||||
var item = track.midiItems[j];
|
||||
if (item.id === targetItemId) continue;
|
||||
|
||||
var itemStartBeat = item.startTime / secondsPerBeat;
|
||||
var itemEndBeat = (item.startTime + item.duration) / secondsPerBeat;
|
||||
|
||||
if (itemStartBeat >= windowEndBeat || itemEndBeat <= windowStartBeat) continue;
|
||||
|
||||
var notes = item.notes || [];
|
||||
for (var k = 0; k < notes.length; k++) {
|
||||
var note = notes[k];
|
||||
var noteAbsStart = itemStartBeat + (note.start_beat || 0);
|
||||
var noteAbsEnd = noteAbsStart + (note.duration_beats || 1);
|
||||
|
||||
if (noteAbsStart >= windowEndBeat || noteAbsEnd <= windowStartBeat) continue;
|
||||
|
||||
var clampedStart = Math.max(noteAbsStart, windowStartBeat);
|
||||
var clampedEnd = Math.min(noteAbsEnd, windowEndBeat);
|
||||
var clampedDur = clampedEnd - clampedStart;
|
||||
|
||||
trackGhostNotes.push({
|
||||
id: 'ghost_' + (note.id || Math.random().toString(36).substr(2, 9)),
|
||||
pitch: note.pitch,
|
||||
relative_start_beat: clampedStart - windowStartBeat,
|
||||
duration_beats: clampedDur,
|
||||
velocity: note.velocity,
|
||||
original_track_name: track.name,
|
||||
original_track_color: track.color || '#888888'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (trackGhostNotes.length > 0) {
|
||||
ghostLayers.push({
|
||||
track_id: track.id,
|
||||
track_name: track.name,
|
||||
track_color: track.color || '#6b7280',
|
||||
notes: trackGhostNotes
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ghostLayers;
|
||||
}
|
||||
|
||||
window.SonicGhost = { extractGhostLayers: extractGhostLayers };
|
||||
})();
|
||||
@@ -18,6 +18,7 @@
|
||||
<script src="/static/js/services/soundfontPlayer.js?v=202607271245"></script>
|
||||
<script src="/static/js/services/aiGateway.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/ghostNoteExtractor.js?v=202607271727"></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202607271245" defer></script>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||
<style>
|
||||
|
||||
Reference in New Issue
Block a user