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")))); }, "Đó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 [activeRollTool, setActiveRollTool] = React.useState('select');
const [snapVal, setSnapVal] = React.useState('1/16'); const [snapVal, setSnapVal] = React.useState('1/16');
const [ccMode, setCcMode] = React.useState('velocity'); 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 [loopStartBeat, setLoopStartBeat] = React.useState(null);
const [loopEndBeat, setLoopEndBeat] = React.useState(null); const [loopEndBeat, setLoopEndBeat] = React.useState(null);
const [isLooping, setIsLooping] = React.useState(false); const [isLooping, setIsLooping] = React.useState(false);
const [showGhostNotes, setShowGhostNotes] = React.useState(true);
const [sessionSyncMode, setSessionSyncMode] = React.useState(true);
const rulerDragRef = React.useRef(null); const rulerDragRef = React.useRef(null);
React.useEffect(() => { React.useEffect(() => {
const handler = (e) => { const handler = (e) => {
@@ -4831,7 +4833,29 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
ctx.stroke(); 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) => { notes.forEach((note) => {
const x = note.start_beat * pixelsPerBeat; const x = note.start_beat * pixelsPerBeat;
const y = (127 - note.pitch) * NoteHeight; const y = (127 - note.pitch) * NoteHeight;
@@ -4903,7 +4927,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
ctx.stroke(); 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(() => { React.useLayoutEffect(() => {
const canvas = ccCanvasRef.current; const canvas = ccCanvasRef.current;
@@ -4962,6 +4986,14 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
return () => clearTimeout(timer); 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 handleGridMouseDown = (e) => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
@@ -5646,6 +5678,63 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const [showCC, setShowCC] = React.useState(true); const [showCC, setShowCC] = React.useState(true);
const [ccHeight, setCcHeight] = React.useState(80); 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) => { const snapPitchToScale = (pitch, scale) => {
if (!scale) return pitch; if (!scale) return pitch;
const octave = Math.floor(pitch / 12); const octave = Math.floor(pitch / 12);
@@ -5711,8 +5800,10 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
const renderBarLabels = () => { const renderBarLabels = () => {
const labels = []; const labels = [];
const barsCount = Math.ceil(viewBeats / 4); const barsCount = Math.ceil(viewBeats / 4);
const barOffset = Math.floor(sessionStartBar);
for (let bar = 0; bar < barsCount; bar++) { for (let bar = 0; bar < barsCount; bar++) {
const x = bar * 4 * pixelsPerBeat; const x = bar * 4 * pixelsPerBeat;
const displayBar = bar + barOffset;
labels.push( labels.push(
/*#__PURE__*/React.createElement("div", { /*#__PURE__*/React.createElement("div", {
key: bar, 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", className: "pl-1 border-l border-zinc-700 h-full select-none cursor-pointer hover:bg-zinc-800/30",
onClick: (e) => { onClick: (e) => {
e.stopPropagation(); 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)); setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: barTime } : s));
} }
}, `Bar ${bar}`) }, `Bar ${displayBar}`)
); );
} }
return labels; 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" 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", { }, React.createElement("div", {
className: "flex items-center gap-4" className: "flex items-center gap-4"
}, React.createElement("span", { }, React.createElement("select", {
className: "text-xs font-bold text-yellow-500 uppercase flex items-center gap-1.5" value: st.target_id || '',
}, React.createElement("i", { onChange: function(e) { handleSwitchMidiItem(e.target.value); },
"data-lucide": "music", 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"
className: "w-3.5 h-3.5" }, allMidiItems.map(function(m) {
}), st.label), React.createElement("div", { 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" className: "flex items-center gap-1 text-xs"
}, React.createElement("span", { }, React.createElement("span", {
className: "text-zinc-500 font-semibold" className: "text-zinc-500 font-semibold"
@@ -5798,7 +5892,23 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
}, mode)))), React.createElement("button", { }, mode)))), React.createElement("button", {
onClick: () => setShowCC(!showCC), 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'}` 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" className: "flex items-center gap-1"
}, React.createElement("button", { }, React.createElement("button", {
onClick: () => onSaveNotes(st.id, st.trackId, st.target_id, notes), onClick: () => onSaveNotes(st.id, st.trackId, st.target_id, notes),
@@ -16115,6 +16225,7 @@ const App = () => {
zoom: zoom, zoom: zoom,
bpm: bpm, bpm: bpm,
viewportWidth: viewportWidth, viewportWidth: viewportWidth,
activeTracks: activeTracks,
onClose: () => closeSubTab(st.id), onClose: () => closeSubTab(st.id),
onUpdateNotes: handleUpdateMidiNotes, onUpdateNotes: handleUpdateMidiNotes,
onSaveNotes: handleSaveMidiNotes, 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 };
})();
+1
View File
@@ -18,6 +18,7 @@
<script src="/static/js/services/soundfontPlayer.js?v=202607271245"></script> <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/aiGateway.js?v=202607271016"></script>
<script src="/static/js/services/dawCommandDispatcher.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> <script src="/static/js/app.precompiled.js?v=202607271245" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016"> <link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style> <style>
+6
View File
@@ -461,3 +461,9 @@
- **Tóm tắt thay đổi:** CC 120 vẫn không đủ vì SpessaSynth 4.3.1 AudioWorklet có bug: looped voices trong MIDI message pipeline xử lý CC 120 sai (`processMessage`). Fix: thêm `noteOn(ch, pitch, 0)` (MIDI noteOff alternate path) + `_synthInstance.post({channelNumber:ch, type:"stopAll", data:1})` gửi lệnh trực tiếp đến worklet qua `handleMessage` — bypass hoàn toàn MIDI pipeline. - **Tóm tắt thay đổi:** CC 120 vẫn không đủ vì SpessaSynth 4.3.1 AudioWorklet có bug: looped voices trong MIDI message pipeline xử lý CC 120 sai (`processMessage`). Fix: thêm `noteOn(ch, pitch, 0)` (MIDI noteOff alternate path) + `_synthInstance.post({channelNumber:ch, type:"stopAll", data:1})` gửi lệnh trực tiếp đến worklet qua `handleMessage` — bypass hoàn toàn MIDI pipeline.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html` - **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Cần clear cache browser (index.html cache-bust param updated). - **Ghi chú/Test (nếu có):** Cần clear cache browser (index.html cache-bust param updated).
---
### [2026-07-27 17:07] Task: MIDI Ghost Notes + Dropdown Item Switcher + Session Sync Mode
- **Tóm tắt thay đổi:** Thêm ghost notes cho Piano Roll tab: tất cả MIDI items không được chọn thành ghost notes (25% opacity, dashed border). Dropdown thay thế tab title để chuyển nhanh MIDI item đang edit. Hai chế độ xem: Session Sync (ghost visible, bar labels aligned với session) và Isolated (bar 0, no ghost).
- **Các file ảnh hưởng:** `app/static/js/services/ghostNoteExtractor.js`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Kiểm tra dropdown list đúng tất cả MIDI items. Switch item → ghost notes của item cũ hiện ra. Nút 🌐 Session/📋 Isolated chuyển chế độ. 👻 Ghost toggle chỉ hoạt động ở Session mode.