10 KiB
Plan: MIDI Ghost Notes + Dropdown Item Switcher + Session Sync Mode
Overview
Three features built on each other:
- Dropdown at tab title position listing ALL MIDI items across all tracks
- Item switching — selected item becomes editable, all others become ghost notes
- Session sync mode toggle — viewport aligns with session bars (ghost visible) or resets to bar 0 (isolated, no ghost)
Key Design Decisions
A. Ghost note scope = all items except the selected one
Not just "other tracks" — ALL MIDI items in activeTracks except the one matching targetItemId contribute ghost notes if overlapping.
B. Two viewport modes, togglable
| Mode | Viewport origin | Ghost notes | Bar labels |
|---|---|---|---|
| Session Sync (default) | item.startTime / secondsPerBar |
Visible | Bar N (session-absolute) |
| Isolated | bar 0 | Hidden | Bar N (0-based) |
C. Ghost notes computed (not persisted)
No schema changes. Extraction runs in useMemo inside PianoRollTabEditor.
D. Hit-testing exclusion is automatic
Ghost notes are in separate ghostLayers state; mouse handlers only iterate notes.
Files to Modify
| File | Change |
|---|---|
NEW app/static/js/services/ghostNoteExtractor.js |
Extraction logic |
app/static/js/app.jsx (~16113) |
Pass activeTracks prop to PianoRollTabEditor |
app/static/js/app.jsx (~4563-6070) |
All PianoRollTabEditor changes below |
Step-by-Step Implementation
Step 1: Create ghostNoteExtractor.js
app/static/js/services/ghostNoteExtractor.js
export function extractGhostLayers(activeTracks, targetTrackId, targetItemId, bpm)
Algorithm:
secondsPerBeat = 60 / bpm- Find
targetItemacross all tracks →windowStartBeat = targetItem.startTime / secondsPerBeat,windowEndBeat = (targetItem.startTime + targetItem.duration) / secondsPerBeat - Iterate ALL tracks, ALL MIDI items:
- Skip non-MIDI tracks (
!t.midiItems || !t.midiItems.length) - Skip muted tracks (
t.muted) - Skip item matching
targetItemId(the active item)
- Skip non-MIDI tracks (
- For each candidate item:
itemStartBeat = item.startTime / secondsPerBeatitemEndBeat = (item.startTime + item.duration) / secondsPerBeat- Overlap test:
itemStartBeat < windowEndBeat && itemEndBeat > windowStartBeat - For each overlapping note:
noteAbsStart = itemStartBeat + note.start_beatnoteAbsEnd = noteAbsStart + note.duration_beats- Clip: keep if
noteAbsStart < windowEndBeat && noteAbsEnd > windowStartBeat clampedDur = Math.min(noteAbsEnd, windowEndBeat) - Math.max(noteAbsStart, windowStartBeat)- Push:
{ id: ghost_${note.id}, pitch, relative_start_beat: noteAbsStart - windowStartBeat, duration_beats: clampedDur, velocity, original_track_name: t.name, original_track_color: t.color || '#888' }
- Group by track →
ghostLayers: [{ track_id, track_name, track_color, notes }] - Return
ghostLayers
Step 2: Pass activeTracks to PianoRollTabEditor
At render site (~line 16113), add:
activeTracks: activeTracks,
Add activeTracks to destructured props in PianoRollTabEditor function signature (~line 4563).
Step 3: New state & derived data
Inside PianoRollTabEditor (~line 4570), after existing React.useState declarations:
const [showGhostNotes, setShowGhostNotes] = React.useState(true);
const [sessionSyncMode, setSessionSyncMode] = React.useState(true);
// Compute all MIDI items for dropdown
const allMidiItems = React.useMemo(() => {
const result = [];
(activeTracks || []).forEach(t => {
if (!t.midiItems || !t.midiItems.length) return;
t.midiItems.forEach(m => {
result.push({ ...m, _trackId: t.id, _trackName: t.name });
});
});
return result;
}, [activeTracks]);
// Compute ghost layers
const ghostLayers = React.useMemo(() => {
if (!activeTracks || !st || !st.target_id) return [];
return extractGhostLayers(activeTracks, st.trackId, st.target_id, parseInt(bpm) || 120);
}, [activeTracks, st.trackId, st.target_id, bpm]);
// Compute session offset for bar labels
const secondsPerBar = (60.0 / (parseInt(bpm) || 120)) * 4;
const targetTrack = React.useMemo(
() => (activeTracks || []).find(t => t.id === st.trackId),
[activeTracks, st.trackId]
);
const activeTargetItem = React.useMemo(
() => targetTrack ? (targetTrack.midiItems || []).find(m => m.id === st.target_id) : null,
[targetTrack, st.target_id]
);
const sessionStartBar = sessionSyncMode && activeTargetItem
? (activeTargetItem.startTime / secondsPerBar) : 0;
Step 4: Dropdown at tab title position
Replace the static title (line 5748-5752) with a dropdown:
/* 1a. TAB TITLE DROPDOWN */
React.createElement("div", { className: "relative inline-block text-xs" },
React.createElement("select", {
value: st.target_id,
onChange: e => handleSwitchMidiItem(e.target.value),
className: "bg-zinc-800 border border-zinc-700 text-yellow-500 font-bold rounded px-1.5 py-0.5 outline-none focus:border-yellow-500 max-w-[200px]"
}, allMidiItems.map(m =>
React.createElement("option", {
key: m.id,
value: m.id
}, `${m._trackName} - ${m.name || 'MIDI'}`)
))
)
Step 5: Switch handler function
Add before the return statement:
const handleSwitchMidiItem = (itemId) => {
if (itemId === st.target_id) return;
// Save current notes first
onSaveNotes(st.id, st.trackId, st.target_id, notes);
// Find selected item
const match = allMidiItems.find(m => m.id === itemId);
if (!match) return;
// Update subTab state (triggers ghost re-compute via useMemo)
setSubTabs(prev => prev.map(s => s.id === st.id ? {
...s,
trackId: match._trackId,
target_id: match.id,
label: `Piano Roll: ${match.name || 'MIDI'}`,
notes: match.notes || [],
duration: match.duration || 4,
instrumentProgram: activeTracks.find(t => t.id === match._trackId)?.instrumentProgram,
instrumentName: activeTracks.find(t => t.id === match._trackId)?.instrumentName,
note_selection: [],
currentTime: 0,
} : s));
// Reset local state
setSelectedNoteIds([]);
setLoopStartBeat(null);
setLoopEndBeat(null);
};
Step 6: Session sync toggle button
In toolbar (~line 5798, near CC toggle), add:
/* Session sync mode toggle */
React.createElement("button", {
onClick: () => setSessionSyncMode(!sessionSyncMode),
className: `px-2 py-1 rounded text-xs ${sessionSyncMode ? 'bg-cyan-900/60 text-cyan-300 border border-cyan-700' : 'text-zinc-500 hover:text-zinc-300'}`,
title: sessionSyncMode ? "Session-synced mode (ghost visible)" : "Isolated mode (bar 0, no ghost)"
}, sessionSyncMode ? "🌐 Session" : "📋 Isolated")
And the Ghost toggle:
React.createElement("button", {
onClick: () => setShowGhostNotes(!showGhostNotes),
disabled: !sessionSyncMode,
className: `px-2 py-1 rounded text-xs ${!sessionSyncMode ? 'opacity-30 cursor-not-allowed' : showGhostNotes ? 'bg-purple-900/60 text-purple-300 border border-purple-700' : 'text-zinc-500 hover:text-zinc-300'}`,
title: "Toggle ghost notes visibility"
}, "👻 Ghost")
Ghost toggle disabled in isolated mode (no ghost notes to show).
Step 7: Bar labels with session offset
Modify renderBarLabels() (~line 5711-5734):
Replace Bar ${bar} with:
const displayBar = bar + Math.floor(sessionStartBar);
`Bar ${displayBar}`
And the seek click handler:
const barTime = (bar + Math.floor(sessionStartBar)) * 4 * beatSec;
Step 8: Auto-scroll to session position
Add useEffect:
React.useEffect(() => {
if (sessionSyncMode && gridScrollRef.current && activeTargetItem) {
const scrollTargetBeats = sessionStartBar * 4;
gridScrollRef.current.scrollLeft = scrollTargetBeats * pixelsPerBeat;
}
}, [sessionSyncMode, sessionStartBar, st.target_id, pixelsPerBeat]);
Step 9: Ghost note canvas layer
In the note-drawing useLayoutEffect (~line 4834), insert before active note rendering:
/* Layer 2: Ghost Notes */
if (showGhostNotes && sessionSyncMode && ghostLayers.length > 0) {
ghostLayers.forEach(layer => {
ctx.save();
ctx.globalAlpha = 0.25;
ctx.fillStyle = layer.track_color || '#888';
ctx.strokeStyle = layer.track_color || '#888';
layer.notes.forEach(note => {
const x = note.relative_start_beat * pixelsPerBeat;
const y = (127 - note.pitch) * NoteHeight;
const w = note.duration_beats * pixelsPerBeat;
const 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();
});
}
Add showGhostNotes, sessionSyncMode, ghostLayers to dependency array.
Step 10: Ghost notes in CC Lane
Skip ghost notes in CC lane (only active notes). CC lane already only iterates notes, not ghostLayers. No changes needed.
Edge Cases
- Dropdown with single MIDI item: Only one option, no ghost notes (nothing to ghost).
- Item deleted while piano roll is open:
handleSwitchMidiItemfails gracefully (item not found → no-op).ghostLayersuseMemoreturns[]. - BPM change mid-edit: All beat computations update via
useMemo/React reactivity. - Session sync → Isolated switch: Scroll resets to 0, bar labels change to 0-based, ghost notes disappear.
- Isolated → Session sync switch: Scroll jumps to session position, ghost notes reappear.
- Color fallback: Use track's
colorprop; if#888as default.
Validation
- Open MIDI item → dropdown shows all MIDI items across all tracks
- Select different item from dropdown → active notes switch, ghost notes re-compute
- Ghost notes from ALL non-selected items appear (same track + other tracks)
- Ghost toggle hides/shows ghost notes (disabled in isolated mode)
- Session sync mode shows correct bar labels (e.g.
Bar 2if item starts at bar 2) - Isolated mode shows
Bar 0, 1, 2...regardless of item's session position - Switching items in isolated mode: notes change, viewport stays at bar 0
- Ghost notes cannot be clicked/dragged (excluded from hit-testing)
- Muted tracks' MIDI items are excluded from ghost notes
- Items outside target window are excluded from ghost notes