Files
SonicForgeStudio/.kilo/plans/1785145662134-midi-ghost-note-plan.md
T

10 KiB

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

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:

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: 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