Files
SonicForgeStudio/md/40_MIDI_GHOSTNOTE.md
T

259 lines
9.4 KiB
Markdown

# 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
```text
[ 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$
```javascript
// 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:
```javascript
// 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:
```javascript
// 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.