# Plan: MIDI Ghost Notes + Dropdown Item Switcher + Session Sync Mode ## Overview Three features built on each other: 1. **Dropdown** at tab title position listing ALL MIDI items across all tracks 2. **Item switching** — selected item becomes editable, all others become ghost notes 3. **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` ```js export function extractGhostLayers(activeTracks, targetTrackId, targetItemId, bpm) ``` Algorithm: 1. `secondsPerBeat = 60 / bpm` 2. Find `targetItem` across all tracks → `windowStartBeat = targetItem.startTime / secondsPerBeat`, `windowEndBeat = (targetItem.startTime + targetItem.duration) / secondsPerBeat` 3. 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) 4. For each candidate item: - `itemStartBeat = item.startTime / secondsPerBeat` - `itemEndBeat = (item.startTime + item.duration) / secondsPerBeat` - Overlap test: `itemStartBeat < windowEndBeat && itemEndBeat > windowStartBeat` - For each overlapping note: - `noteAbsStart = itemStartBeat + note.start_beat` - `noteAbsEnd = 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' }` 5. Group by track → `ghostLayers: [{ track_id, track_name, track_color, notes }]` 6. Return `ghostLayers` ### Step 2: Pass `activeTracks` to PianoRollTabEditor At render site (~line 16113), add: ```js 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: ```js 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: ```js /* 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: ```js 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: ```js /* 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: ```js 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: ```js const displayBar = bar + Math.floor(sessionStartBar); `Bar ${displayBar}` ``` And the seek click handler: ```js const barTime = (bar + Math.floor(sessionStartBar)) * 4 * beatSec; ``` ### Step 8: Auto-scroll to session position Add `useEffect`: ```js 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: ```js /* 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**: `handleSwitchMidiItem` fails gracefully (item not found → no-op). `ghostLayers` `useMemo` returns `[]`. - **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 `color` prop; if `#888` as default. --- ## Validation 1. Open MIDI item → dropdown shows all MIDI items across all tracks 2. Select different item from dropdown → active notes switch, ghost notes re-compute 3. Ghost notes from ALL non-selected items appear (same track + other tracks) 4. Ghost toggle hides/shows ghost notes (disabled in isolated mode) 5. Session sync mode shows correct bar labels (e.g. `Bar 2` if item starts at bar 2) 6. Isolated mode shows `Bar 0, 1, 2...` regardless of item's session position 7. Switching items in isolated mode: notes change, viewport stays at bar 0 8. Ghost notes cannot be clicked/dragged (excluded from hit-testing) 9. Muted tracks' MIDI items are excluded from ghost notes 10. Items outside target window are excluded from ghost notes