feat(piano-roll): ghost notes + dropdown item switcher + session sync

- MIDI ghost notes: all non-selected items rendered at 25% opacity
- Dropdown at tab title: switch active edit target across all tracks
- Session Sync mode (default): viewport aligned to session bars
- Isolated mode toggle: bar 0, no ghost notes
- Ghost toggle: show/hide ghost layer (only in session mode)
- Auto-scroll to session position in session sync mode
- Bar labels show absolute session bar numbers
This commit is contained in:
2026-07-27 17:08:59 +07:00
parent c4a320a1c1
commit 035d75e504
5 changed files with 213 additions and 18 deletions
@@ -0,0 +1,75 @@
// SonicForge Studio Ghost Note Extractor Service
(function() {
function extractGhostLayers(activeTracks, targetTrackId, targetItemId, bpm) {
if (!activeTracks || !targetItemId) return [];
const secondsPerBeat = 60.0 / (parseInt(bpm) || 120);
let targetItem = null;
for (var i = 0; i < activeTracks.length; i++) {
var t = activeTracks[i];
var found = (t.midiItems || []).find(function(m) { return m.id === targetItemId; });
if (found) { targetItem = found; break; }
}
if (!targetItem) return [];
const windowStartBeat = targetItem.startTime / secondsPerBeat;
const windowEndBeat = (targetItem.startTime + targetItem.duration) / secondsPerBeat;
const ghostLayers = [];
for (var i = 0; i < activeTracks.length; i++) {
var track = activeTracks[i];
if (!track.midiItems || !track.midiItems.length) continue;
if (track.muted) continue;
var trackGhostNotes = [];
for (var j = 0; j < track.midiItems.length; j++) {
var item = track.midiItems[j];
if (item.id === targetItemId) continue;
var itemStartBeat = item.startTime / secondsPerBeat;
var itemEndBeat = (item.startTime + item.duration) / secondsPerBeat;
if (itemStartBeat >= windowEndBeat || itemEndBeat <= windowStartBeat) continue;
var notes = item.notes || [];
for (var k = 0; k < notes.length; k++) {
var note = notes[k];
var noteAbsStart = itemStartBeat + (note.start_beat || 0);
var noteAbsEnd = noteAbsStart + (note.duration_beats || 1);
if (noteAbsStart >= windowEndBeat || noteAbsEnd <= windowStartBeat) continue;
var clampedStart = Math.max(noteAbsStart, windowStartBeat);
var clampedEnd = Math.min(noteAbsEnd, windowEndBeat);
var clampedDur = clampedEnd - clampedStart;
trackGhostNotes.push({
id: 'ghost_' + (note.id || Math.random().toString(36).substr(2, 9)),
pitch: note.pitch,
relative_start_beat: clampedStart - windowStartBeat,
duration_beats: clampedDur,
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;
}
window.SonicGhost = { extractGhostLayers: extractGhostLayers };
})();