feat: hiển thị ghost note trong piano roll tab
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user