updateActiveTracksRef checks sessionTabs by currentActiveTab.
When activeTab is a PIANO_ROLL sub-tab (midi_xxx), none matches
-> falls to setTracks (main). Instrument changes are lost.
Fix: also check subTabs for PIANO_ROLL type, resolve parent
session tab via parent_tab_id, route to setSessionTabs.
Ctrl+Click handler toggled selection + set pending drag.
Once selected, drag system treated it as 'move selection'
instead of 'copy'. Removed selection toggle - Ctrl+Click
now only sets pending drag for copy operation.
When PIANO_ROLL sub-tab is opened from SECTION-TAB,
activeTab switches to midi_xxx, so activeTracks fell
back to main project tracks. Section trackIds don't
exist in main tracks -> dropdown empty, ghost layers
empty, instrument data wrong.
Fix: check subTabs for PIANO_ROLL type, resolve parent
section tab from parent_tab_id, return its tracks.
handleEditSectionInTab overwrote midiItems: [] when cloning
section.tracks, stripping all MIDI data from section context.
PianoRollTabEditor uses activeTracks which resolves section
tracks -- midiItems must be preserved for dropdown switcher,
ghost note extraction, and instrument settings to work.
Scanner: new instance with existing sf_scan_state.json
did not populate in-memory _catalog. Now re-inspects
unchanged files to fill catalog on first scan.
Frontend: left column reads instrumentSelectorData.
soundfonts instead of sfPresets (null when no presets).
Click SF in left column triggers on-demand fetch.
Modal max-w-md -> max-w-4xl. Font 14px (text-sm).
Left column: soundfont list. Right column: instruments
of selected SF. All Instruments view when none selected.
Search filters both columns.
Ctrl+click toggle selection THEN drag started -> selIds.has(itemId)
returned false because the item was just removed from snapshot.
Fix: snapshot selectedItemIds BEFORE toggle, pass to pendingDrag.
Drag always copies the original group; toggle is only visual.
Same bug as copy: return statement only updated
targetTrackId/drag.trackId, discarding items from other tracks.
Fix: always return { sections: midSections, midiItems: midMidis }
for ALL tracks in the map (arrays are sliced per-track).
The updateActiveTracks return statement was:
return t.id === trackId ? updated : t;
This discarded duplicate items that belong to other tracks.
Fix: always return updated{ sections, midiItems, clips }
for EVERY track in the map (already sliced from originals,
so unchanged tracks are no-ops).
React batches setSelectedItemIds, so when handleSetPendingDrag
snapshots selectedItemIds, the just-toggled item isn't included
yet. Fix: pass wasAlreadySelected boolean from TimelineTrack
mousedown; handleSetPendingDrag manually applies the toggle
(add/delete) to the snapshot so drag uses correct selection.
The useEffect with [] deps captured handleSectionItemDragStart
from initial render, which read an empty selectedItemIds.
Added handleSectionItemDragStartRef that stays current across
renders; the effect calls ref.current instead of the closure.
- Ctrl+click on selected item: deselect that item only
- Ctrl+click on empty space (via sweep start): deselect all items
- Ctrl+drag on empty space BETWEEN or BELOW tracks: creates
sweep overlay scanning ALL tracks for intersecting items
- onDeselectItem handler + prop wired through WaveformLane
- Global onMouseDown on tracks container div for empty-area sweep
- Removed sweepTrackIdRef filter in mouseup handler:
all tracks are scanned for intersecting items.
- Removed track ID check from sweep overlay rendering:
overlay appears on every track (not just drag-start track).
- Fixed stale closure by adding sweepSelectRef.
handleMouseUp captured stale sweepSelect state from render
closure. Added sweepSelectRef updated on every mousemove
so mouseup reads the latest sweep range correctly.
- Ctrl+drag on empty space: sweep range selection overlay
- Mouseup selects all items (MIDI, sections, clips) in sweep
- Selected items rendered with amber border highlight
- Ctrl+drag on section/MIDI item: duplicates the item
- Drag selected items: moves entire selected group together
- Ctrl+drag selected items: duplicates the entire group
useEffect watching [activeTool, activeTab] runs createIcons()
but contextMenu is NOT in deps. On first right-click, if
activeTool/activeTab also change (e.g. timeline click), the
effect fires and icons appear. Subsequent right-clicks don't
change activeTool/activeTab, so createIcons() never runs for
the new context menu DOM.
Fix: add contextMenu to useEffect dependency array.
- Use filter(t => t.isArmed) instead of find() to route
MIDI input to ALL armed tracks simultaneously
- Use getTrackMidiChannel per track instead of raw
MIDI hardware channel (msg.data[0] & 0x0F)
- NoteOff, CC, PitchBend also routed to each armed
track's dedicated channel
- Previous code sent to channel 0 + first armed track only
Line 5273 called playNote with undefined program + no channel,
defaulting to channel 0 with last-set instrument (may be Track 2's).
Fix: resolve parent track from st.trackId, pass correct channel
and synth_engine/instrumentProgram.
6 call sites were missing channel parameter, causing FluidSynth
to use ch=0 for all tracks -> program_change on channel 0
overwrote instrument across tracks during playback.
Fixes: startTrackPlayback, section sub-track MIDI, playMidiPreviewNote,
piano roll Alt+scroll preview, note click preview, keybed preview.
When midiChannel is undefined (no instrument set), compute channel
from track index to avoid defaulting to 0 and clashing with
configured tracks that use channel 0.
- setTrackInstrumentWithProgram stores midiChannel on the track
- schedulePianoRollMidi reads track.midiChannel instead of
recomputing from index (can be inconsistent across calls)
- selectInstrument also stores midiChannel if not already set
- isLooping defaults to true on subTab creation (auto-loop when
range selection is set)
- Per-track instrument confirmed: selectInstrument respects passed
channel, _playNoteFluid bypasses _engineChMap when channel is
explicit, no-instrument fallback sets program 0 (default piano)
instead of inheriting another track's program
- When activePlayTrackIds is null (no button selected): no ghost play
- When activePlayTrackIds is set (one button red): only that track's
ghost notes play, with the track's own instrument on its own channel
- Removed st.currentTime >= 0 check from playhead rendering
- handleEditMidiInTab: currentTime = -beatOff * beatSec so phBeat=0
- handleSwitchMidiItem: same logic for dropdown switches
- Playhead draws at x=0 (bar 0) when item opens, moves right during
playback from the initial negative position
When selectedNoteIds.length > 0, shift+scroll on any note changes
velocity for all selected notes. Also fixed hit-test to subtract
renderBeatOffset for correct positioning in session mode.
handleEditMidiInTab now sets currentTime relative to item start:
currentTime = Math.max(0, mainTimelineCurrentTime - midiItem.startTime)
so playhead reflects the main timeline position instead of bar 0.
handleSwitchMidiItem now computes new currentTime from the delta
between old and new renderBeatOffset, keeping the playhead at
the same session-absolute time instead of resetting to bar 0.
When activePlayTrackIds was null (default: all play), clicking a track
created an empty Set and deleted nothing. Now initializes with all
track IDs, so click toggles the clicked track off/on correctly.
- Remove dashed border from ghost notes (fill-only at 25% opacity)
- Track column: replaced div overlay with natural flex child; use
<button> elements with border-l-2 indicator; removed pointer-events
- Ghost playback: skip layers with no instrument assigned (silent)
instead of playing with default piano
- 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
renderBeatOffset was in bars but used as beat offset in rendering.
Multiplying by timeSigNum=4 converts to beats so items appear at
correct session-absolute positions.
- handleSwitchMidiItem no longer saves/sets previous item as ghost
- Removed ghostTrackFilter/filteredGhostLayers
- Track column shows clickable track names that toggle play state
- activePlayTrackIds Set controls which tracks' MIDI are playable
- Context menu flips upward near viewport bottom edge
- Track column (120px) left of keybed: checkboxes per track to
filter ghost notes; active track highlighted
- Session mode: canvas extends to full session duration
- Filtered ghost layers via ghostTrackFilter state (Set of track IDs)
- Fixed bar label seek in session mode (was using wrong offset)
- 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
- Track sustain state per channel via _sustainStates[]
- sustainActive(channel) returns boolean
- MIDI NoteOn: only send CC123 if !sustainActive(ch)
- Preserves sustained notes when playing new notes