Files
SonicForgeStudio/md/40_MIDI_GHOSTNOTE.md

9.4 KiB

TECHNICAL SPECIFICATION: GHOST NOTES FEATURE IN PIANO ROLL TAB

This document details the workflow and processing algorithms for opening any MIDIItem (for example, MIDI Item 1 spanning Bar 0 to Bar 4) on the main Timeline in a Piano Roll Tab, while extracting and displaying all notes from other tracks occupying the same time interval (Bar 0 - Bar 4) as Ghost Notes (faded reference notes that cannot be interactively edited).


1. DATA FLOW & MODULE ARCHITECTURE DIAGRAM

[ USER DBL-CLICK ITEM 1 ] 
(Track A, Bar 0 - 4)
           |
           v
[ 1. Context Extractor Engine ]
   ├── Target Item: Active Editing Item
   ├── Window Bounds: [Bar 0.0 -> Bar 4.0]
   └── Scan All Other Tracks (B, C, D...)
           |
           v
[ 2. Overlap Filtering Algorithm ]
   ├── Track B (Bass): Item B1 (Bar 0 - 8)  -> Overlap! Slice [Bar 0 -> 4]
   ├── Track C (Pads): Item C1 (Bar 2 - 6)  -> Overlap! Slice [Bar 2 -> 4]
   └── Track D (Lead): Item D1 (Bar 5 - 8)  -> Out of bounds! Ignore
           |
           v
[ 3. Piano Roll State Store ]
   ├── activeItem: MIDI Item 1 (Full Edit Access)
   └── ghostLayers: [ Track B Notes, Track C Notes ] (Read-Only)
           |
           v
[ 4. Multi-Layer Canvas Renderer ]
   ├── Layer 1: Background Grid & Pitch Keys
   ├── Layer 2: Ghost Notes (Opacity 25%, Muted Color, Pointer Events OFF)
   └── Layer 3: Active Notes (Full Opacity, Drag/Drop/Resize Allowed)


2. TIME-WINDOW OVERLAP FILTERING ALGORITHM

To determine whether a MIDIItem on another track overlaps with the interval [start\_bar, end\_bar] of MIDI Item 1, a 1D geometric condition is applied:

Overlap Condition

Two time intervals [A_{start}, A_{end}] and [B_{start}, B_{end}] intersect if and only if:

B_{start} < A_{end} \quad \text{AND} \quad B_{end} > A_{start}

Where:

  • A_{start} = \text{Item1.start\_bar} = 0.0
  • A_{end} = \text{Item1.start\_bar} + \text{Item1.duration\_bars} = 4.0
// app/static/js/services/ghostNoteExtractor.js

/**
 * Extracts a list of Ghost Notes from other tracks within the specified Bar window
 * @param {Object} sessionState - Full Main Session state object
 * @param {string} targetTrackId - ID of the currently active editing Track (Track A)
 * @param {number} windowStartBar - Start bar position of the active Item (e.g., 0.0)
 * @param {number} windowDurationBars - Bar duration of the active Item (e.g., 4.0)
 * @returns {Array} List of normalized Ghost Layers
 */
export function extractGhostLayers(sessionState, targetTrackId, windowStartBar, windowDurationBars) {
  const windowEndBar = windowStartBar + windowDurationBars;
  const timeSigNumerator = sessionState.metadata.time_signature_numerator || 4;
  
  const ghostLayers = [];

  // Iterate over all tracks in Main Session
  sessionState.main_session.tracks.forEach((track) => {
    // Ignore active editing track and non-MIDI or muted tracks
    if (track.id === targetTrackId || track.type !== "MIDI" || track.mute) {
      return;
    }

    const trackGhostNotes = [];

    // Iterate over all items in target candidate tracks
    track.items.forEach((item) => {
      if (item.type !== "MIDI_ITEM") return;

      const itemStartBar = item.start_bar;
      const itemEndBar = item.start_bar + item.duration_bars;

      // Evaluate Overlap Condition
      if (itemStartBar < windowEndBar && itemEndBar > windowStartBar) {
        const itemOffsetBar = item.clip_start_offset_bars || 0.0;

        item.source_data.notes.forEach((note) => {
          // Convert internal note start_beat to absolute timeline beats
          const noteAbsoluteBeat = (itemStartBar * timeSigNumerator) + note.start_beat - (itemOffsetBar * timeSigNumerator);
          const noteEndAbsoluteBeat = noteAbsoluteBeat + note.duration_beats;

          const windowStartBeat = windowStartBar * timeSigNumerator;
          const windowEndBeat = windowEndBar * timeSigNumerator;

          // Retain notes truly within the visible bounds [windowStartBeat -> windowEndBeat]
          if (noteAbsoluteBeat < windowEndBeat && noteEndAbsoluteBeat > windowStartBeat) {
            trackGhostNotes.push({
              id: `ghost_${note.id}`,
              pitch: note.pitch,
              // Convert beat position to relative coordinates of the Piano Roll Window (0 -> duration_beats)
              relative_start_beat: noteAbsoluteBeat - windowStartBeat,
              duration_beats: note.duration_beats,
              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;
}


3. STATE STRUCTURE FOR PIANO ROLL TAB

When the user opens MIDI Item 1, the Tab Controller generates an isolated context payload for the Piano Roll:

// Data Payload passed into the PianoRollTab Component
const pianoRollTabContext = {
  tab_id: "tab_pianoroll_item_1",
  title: "Piano Roll - MIDI Item 1",
  type: "PIANO_ROLL_TAB",
  parent_tab_id: "tab_main_session",
  
  // 1. Target Item open for direct interactive editing
  active_context: {
    track_id: "track_A",
    item_id: "item_1",
    item_name: "MIDI Item 1",
    start_bar: 0.0,
    duration_bars: 4.0,
    notes: [/* Original source MIDI notes of Item 1 */]
  },

  // 2. Read-only Ghost Layers displayed as background reference indicators
  ghost_layers: [
    {
      track_id: "track_B_bass",
      track_name: "Track B (Bass)",
      track_color: "#3b82f6", // Green / Blue
      notes: [
        { pitch: 36, relative_start_beat: 0.0, duration_beats: 4.0, velocity: 0.9 },
        { relative_start_beat: 4.0, pitch: 38, duration_beats: 4.0, velocity: 0.8 }
      ]
    },
    {
      track_id: "track_C_pads",
      track_name: "Track C (Pads)",
      track_color: "#ec4899", // Pink
      notes: [
        { pitch: 60, relative_start_beat: 8.0, duration_beats: 8.0, velocity: 0.6 }
      ]
    }
  ]
};


4. MULTI-LAYER CANVAS RENDERER WORKFLOW

Inside the Piano Roll canvas rendering module, elements are drawn sequentially by z-index order to position Ghost Notes behind Active Notes:

// app/static/js/views/pianoRollRenderer.js

export function renderPianoRollCanvas(ctx, canvasWidth, canvasHeight, viewState, activeItem, ghostLayers) {
  const { zoomX, zoomY, scrollX, scrollY, noteHeight } = viewState;

  // -----------------------------------------------------------------
  // LAYER 1: BACKGROUND & GRID LINES
  // -----------------------------------------------------------------
  drawPianoGrid(ctx, canvasWidth, canvasHeight, viewState);

  // -----------------------------------------------------------------
  // LAYER 2: GHOST NOTES (BACKGROUND REFERENCE FROM OTHER TRACKS)
  // -----------------------------------------------------------------
  if (ghostLayers && ghostLayers.length > 0) {
    ghostLayers.forEach((layer) => {
      ctx.save();
      // Set faded opacity (20% - 30% opacity)
      ctx.globalAlpha = 0.25;
      ctx.fillStyle = layer.track_color;
      ctx.strokeStyle = layer.track_color;

      layer.notes.forEach((note) => {
        const x = (note.relative_start_beat - scrollX) * zoomX;
        const y = (127 - note.pitch - scrollY) * noteHeight;
        const w = note.duration_beats * zoomX;
        const h = noteHeight - 1; // 1px border gap

        // Draw Ghost note body (Dashed border or light stroke)
        ctx.fillRect(x, y, w, h);
        ctx.setLineDash([2, 2]); // Dashed lines indicating non-interactive status
        ctx.lineWidth = 1;
        ctx.strokeRect(x, y, w, h);
      });

      ctx.restore();
    });
  }

  // -----------------------------------------------------------------
  // LAYER 3: ACTIVE NOTES (EDITABLE NOTES FOR ITEM 1)
  // -----------------------------------------------------------------
  activeItem.notes.forEach((note) => {
    ctx.save();
    ctx.globalAlpha = 1.0; // 100% full opacity
    
    const x = (note.start_beat - scrollX) * zoomX;
    const y = (127 - note.pitch - scrollY) * noteHeight;
    const w = note.duration_beats * zoomX;
    const h = noteHeight - 1;

    // Bright fill colors for active notes
    ctx.fillStyle = note.selected ? "#f59e0b" : "#10b981"; // Orange if selected, green if default
    ctx.fillRect(x, y, w, h);

    ctx.strokeStyle = "#ffffff";
    ctx.lineWidth = 1;
    ctx.strokeRect(x, y, w, h);

    ctx.restore();
  });
}


5. POINTER INTERACTION RULES

To prevent accidental modification or drag interactions with Ghost Notes:

  • Hit-Testing Isolation: On pointer events (mousedown, mousemove, or marquee selection), the Hit-Test algorithm queries activeItem.notes exclusively. ghostLayers items are omitted from event evaluation.
  • Toggle Ghost Notes Visibility: The Piano Roll toolbar includes an Eye / Ghost icon button ([👻 Ghost Notes]) allowing operators to toggle the visibility of the reference background layer.
  • Live Synchronous Updates: Modifying a note on Track B within the Main Session or an adjacent tab dispatches an EVENT_SESSION_UPDATED Event Bus signal. The active Piano Roll Tab receives this event and invokes extractGhostLayers() to dynamically redraw updated Ghost Note positions.