feat: track column buttons, ghost playback, per-track channels

- Track column: centered 12px buttons, purple active / yellow highlight
- Ghost playback: ghostLayers synced to st.ghostPlayLayers via useEffect;
  schedulePianoRollMidi plays ghost notes from active tracks
- Per-track MIDI channels: each track gets a unique channel based on
  its index, enabling separate instruments per track
This commit is contained in:
2026-07-27 18:21:46 +07:00
parent a98dd58744
commit 0500d16bc7
2 changed files with 64 additions and 9 deletions
+58 -5
View File
@@ -5052,6 +5052,27 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
return () => clearTimeout(timer);
}, []);
// Sync ghost play data to subTab state for playback integration
React.useEffect(function() {
if (!sessionSyncMode || !showGhostNotes || !ghostLayers.length) {
setSubTabs(function(prev) { return prev.map(function(s) { if (s.id !== st.id) return s; return Object.assign({}, s, { ghostPlayLayers: [] }); }); });
return;
}
var layers = [];
ghostLayers.forEach(function(layer) {
if (activePlayTrackIds !== null && !activePlayTrackIds.has(layer.track_id)) return;
var trk = (activeTracks || []).find(function(t) { return t.id === layer.track_id; });
layers.push({
trackId: layer.track_id,
notes: layer.notes.map(function(n) { return { pitch: n.pitch, start_beat: n.relative_start_beat, duration_beats: n.duration_beats, velocity: n.velocity || 0.8 }; }),
instrumentProgram: trk ? trk.instrumentProgram : undefined,
instrumentName: trk ? trk.instrumentName : undefined,
synthEngine: trk ? trk.synth_engine : undefined
});
});
setSubTabs(function(prev) { return prev.map(function(s) { if (s.id !== st.id) return s; return Object.assign({}, s, { ghostPlayLayers: layers }); }); });
}, [ghostLayers, activePlayTrackIds, sessionSyncMode, showGhostNotes, st.id, activeTracks]);
const handleGridMouseDown = (e) => {
const canvas = canvasRef.current;
if (!canvas) return;
@@ -6110,7 +6131,7 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
className: "absolute left-0 top-0 w-[120px] bg-[#16161a] border-r border-zinc-800 z-10 flex flex-col overflow-hidden pointer-events-none",
style: { height: KeybedPixelHeight + 'px' }
}, React.createElement("div", {
className: "pointer-events-auto"
className: "pointer-events-auto flex flex-col"
}, (allMidiItems.length > 0 ? function() {
var seenTracks = {};
var els = [];
@@ -6131,9 +6152,9 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
return s.size === 0 ? null : s;
});
},
className: "flex items-center px-1.5 h-[18px] border-b border-zinc-900 cursor-pointer " + (isActive ? 'bg-yellow-900/20' : (isPlayOn ? 'bg-zinc-800/40' : 'bg-zinc-900/60'))
className: "flex items-center justify-center h-[18px] border-b border-zinc-900 cursor-pointer select-none " + (isActive ? 'bg-yellow-600 text-black font-bold' : (isPlayOn ? 'bg-purple-700 text-white font-semibold' : 'bg-zinc-800 text-zinc-500 hover:bg-zinc-700'))
}, React.createElement("span", {
className: "text-[9px] font-mono truncate " + (isActive ? 'text-yellow-400 font-bold' : (isPlayOn ? 'text-zinc-300' : 'text-zinc-600')),
className: "text-[12px] font-sans truncate px-1",
title: track ? track.name : m._trackName
}, track ? track.name : m._trackName)));
});
@@ -6765,7 +6786,11 @@ const App = () => {
// Trigger SpessaSynth load + program change when soundfont instrument selected
if (window.SonicSF && window.SonicSF.selectInstrument && instrumentId && isSfInstrument) {
const sfId = instrumentId.replace('sf_', '');
const ch = sfBank === 128 ? 9 : 0;
var allTracks = activeTracksRef.current || tracks;
var tidx = 0;
for (var i = 0; i < allTracks.length; i++) { if (allTracks[i].id === trackId) { tidx = i; break; } }
var ch = sfBank === 128 ? 9 : (tidx >= 9 ? tidx + 1 : tidx);
if (ch >= 16) ch = 0;
window.SonicSF.selectInstrument(ch, sfBank || 0, sfProg || 0, sfId);
}
setSubTabs(prev => prev.map(s => {
@@ -10372,6 +10397,9 @@ const App = () => {
const destNode = getOrCreateTrackNode(track, context);
const instrumentProgram = track ? track.instrumentProgram : undefined;
const synthEngine = track ? track.synth_engine : undefined;
var allTracks = activeTracksRef.current || [];
var mainCh = 0;
for (var i = 0; i < allTracks.length; i++) { if (allTracks[i].id === st.trackId) { mainCh = i >= 9 ? i + 1 : i; if (mainCh >= 16) mainCh = 0; break; } }
midiNotes.forEach(note => {
const noteOnBeat = note.start_beat || 0;
const noteDurBeat = note.duration_beats || 1;
@@ -10383,10 +10411,35 @@ const App = () => {
const scheduledTime = startWallTime + effectiveStart;
const durMs = effectiveDur * 1000;
if (window.SonicSF) {
window.SonicSF.playNote(note.pitch || 60, note.velocity || 0.8, durMs, scheduledTime, instrumentProgram, destNode, undefined, synthEngine);
window.SonicSF.playNote(note.pitch || 60, note.velocity || 0.8, durMs, scheduledTime, instrumentProgram, destNode, mainCh, synthEngine);
}
}
});
// Play ghost notes from active tracks
var ghostLayers = st.ghostPlayLayers || [];
ghostLayers.forEach(function(layer) {
var ghostTrack = allTracks.find(function(t) { return t.id === layer.trackId; });
var ghostProg = layer.instrumentProgram !== undefined ? layer.instrumentProgram : (ghostTrack ? ghostTrack.instrumentProgram : undefined);
var ghostSynth = layer.synthEngine || (ghostTrack ? ghostTrack.synth_engine : undefined);
var ghostDest = getOrCreateTrackNode(ghostTrack, context);
var ghostCh = 0;
for (var i = 0; i < allTracks.length; i++) { if (allTracks[i].id === layer.trackId) { ghostCh = i >= 9 ? i + 1 : i; if (ghostCh >= 16) ghostCh = 0; break; } }
layer.notes.forEach(function(note) {
var beat = note.start_beat || 0;
var dur = note.duration_beats || 1;
var startSec = beat * secondsPerBeat;
var durSec = dur * secondsPerBeat;
if (startSec + durSec > offsetSeconds) {
var effStart = Math.max(0, startSec - offsetSeconds);
var effDur = durSec - Math.max(0, offsetSeconds - startSec);
var schedTime = startWallTime + effStart;
var durMs = effDur * 1000;
if (window.SonicSF) {
window.SonicSF.playNote(note.pitch || 60, note.velocity || 0.8, durMs, schedTime, ghostProg, ghostDest, ghostCh, ghostSynth);
}
}
});
});
};
const handlePlayPause = () => {
if (activeTab !== 'main' && !activeTab.startsWith('session_')) {
File diff suppressed because one or more lines are too long