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"))));
|
}, "Đó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 };
|
||||||
|
})();
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user