91 Commits

Author SHA1 Message Date
3dtours ae71acef0f fix: sửa lỗi Ctrl+S trong SECTION-TAB để lưu vào MAIN SESSION 2026-07-28 10:13:58 +07:00
3dtours f0b62d5a1c fix: Ctrl+S in SECTION-TAB stale closure, saveSectionTab uses initial sessionTabs
Keyboard handler (useEffect [] deps) captures handleSaveSectionTab
from first render, which has sessionTabs = []. When section tab
is opened later, saved handleSaveSectionTab finds no tab and
returns silently. Added handleSaveSectionTabRef, updated on
every render, used in keyboard handler via ref.current.
2026-07-28 10:13:28 +07:00
3dtours f84431c9a6 fix: sửa lỗi Ctrl+S trong SECTION-TAB 2026-07-28 10:11:25 +07:00
3dtours b5f3f987b7 fix: Ctrl+click empty space starts sweep select with yellow cursor
Line 1163 called onSweepSelectStart which set up sweep
state (yellow selection rectangle). Mousemove then
selected all items in the sweep range. Changed to
onClearSelection = () => setSelectedItemIds(new Set())
which only clears selection without starting sweep.
2026-07-28 10:10:05 +07:00
3dtours 5ec329933a fix: Ctrl+S in SECTION-TAB exports SF instead of saving section
First Ctrl+S handler (line 8066, handleExportSFS) catches
ALL Ctrl+S before second handler (line 8157, save section)
ever runs. Moved section-tab check into first handler:
activeTab.startsWith('session_') -> handleSaveSectionTab.
Main session Ctrl+S still exports SF (same as before).
2026-07-28 10:07:02 +07:00
3dtours aafc750274 fix: sub-tab keyboard block captures Ctrl+A/S in SECTION-TAB
Line 7942 checked activeTabRef.current !== 'main', which
is true for SECTION-TAB (session_xxx). Sub-tab block
only handles audio/image keys then unconditionally
returns at line 8005 -> Ctrl+A/S never reached.
Changed to exclude section tabs via
!activeTabRef.current.startsWith('session_').
2026-07-28 10:03:46 +07:00
3dtours 62a41d4502 fix: Ctrl+S saves section in SECTION-TAB, Ctrl+A selects all items
Ctrl+S in SECTION-TAB (activeTab starts with session_):
calls handleSaveSectionTab(curTab).
Ctrl+A in MAIN/SECTION-TAB: collects sections, midiItems,
clips from activeTracksRef.current into selectedItemIds.
Ignores sub-tab contexts (PIANO_ROLL).
2026-07-28 09:59:13 +07:00
3dtours fa7ffe88bf fix: sửa lỗi insert section trong SECTION-TAB 2026-07-28 09:57:56 +07:00
3dtours 385d62cfaf fix: Insert Section visible in SECTION-TAB context menu
JSX rendering menu at line 17500 lacked the
!sessionTabs.some(s => s.id === activeTab) guard.
Added it - section tab context menu no longer offers
Insert Section (sections cannot contain section items).
2026-07-28 09:55:37 +07:00
3dtours f4a364df51 fix: sửa lỗi play section item và midi item riêng biệt 2026-07-28 09:52:41 +07:00
3dtours 32b61553f9 fix: program_change at call time overrides subsequent notes
_playNoteFluid called program_change synchronously at
call time, not at scheduled note time. startTrackPlayback
processes all MIDI items first, then all sections -> each
note's program_change overwritten by the last type processed.
All notes played with section instrument regardless of
actual time position. Moved program_change into doNote
(setTimeout callback) so it fires at correct time.
2026-07-28 09:48:54 +07:00
3dtours 67551075be fix: sửa lỗi loaded instrument ở SECTION-TAB nhưng không play được ở MAIN SESSION 2026-07-28 09:39:18 +07:00
3dtours fd31e8051d fix: sửa lỗi loaded instrument ở MAIN SESSION thì bị áp dụng cho SECTION-TAB 2026-07-28 09:33:35 +07:00
3dtours 43691a127e fix: section track plays GM Piano via program default 0
section cloned tracks have instrumentProgram:undefined.
Line 10283 defaulted to 0 -> FluidSynth program_change(ch,0)
-> plays whatever SoundFont is loaded (MAIN SF).
Added _isSectionClone flag; line 10283 skips program default
for section clones -> undefined -> _playNoteFluid silent return.
2026-07-28 09:30:27 +07:00
3dtours 6fa6578dd1 feat: silent fallback when no instrument — no oscillator default
_playNoteFluid no-instrument branch previously called
_playNoteFallback (sine wave oscillator). Changed to
silent return. User explicitly requested no default sound.
2026-07-28 09:28:21 +07:00
3dtours 2dec94aa7e fix: restore program default 0 for MAIN track MIDI playback
Line 10283 defaulted to undefined when instrumentProgram
undefined, breaking Play for MAIN tracks with MIDI items
but no instrument. Restored: undefined ? instrumentProgram : 0.
Section sub-track (line 10393) keeps undefined -> oscillator
fallback to avoid loading MAIN SoundFont.
2026-07-28 09:25:50 +07:00
3dtours ea40f0c540 revert: playhead fixes cause cannot play on MAIN/SECTION-TAB
Revert two changes from 5d31563:
1. Explicit requestAnimationFrame in handlePlayPause (redundant,
   useEffect already handles RAF start after setIsPlaying)
2. setCurrentTime(0) in handleEditSectionInTab (resets global
   currentTime, interferes with MAIN session position)
2026-07-28 09:22:44 +07:00
3dtours 5d31563df6 fix: playhead not moving in SECTION-TAB with empty tracks
Two fixes:
1. handlePlayPause: add explicit requestAnimationFrame(updatePlayhead)
   after setIsPlaying(true) to ensure RAF starts immediately.
2. handleEditSectionInTab: add setCurrentTime(0) to reset playhead
   to section start. Without this, currentTime may be outside
   the section's timeline range (e.g. from main session playback).
2026-07-28 09:20:31 +07:00
3dtours 2f2f84066c fix: main playback default program 0 loads MAIN SoundFont
Line 10283 same issue as 10393: when instrumentProgram is
undefined (section cloned tracks), program defaults to 0.
_playNoteFluid calls program_change(ch,0) on FluidSynth,
which loads program 0 from whatever SoundFont is loaded
(MAIN session's SF). Fix: pass undefined -> oscillator
fallback for all tracks without assigned instrument.
2026-07-28 09:15:51 +07:00
3dtours 52ec9379d6 fix: section sub-track MIDI default program 0 loads MAIN SoundFont
Line 10393 defaulted program to 0 when subTrack.instrumentProgram
was undefined (section clone with no instrument). _playNoteFluid
receives program=0, calls program_change(ch,0) on FluidSynth,
which picks program 0 from whatever SoundFont is loaded (MAIN
session's SF). Fix: pass undefined -> _playNoteFluid takes
oscillator fallback, no SoundFont loading.
2026-07-28 09:10:33 +07:00
3dtours 9c2fa52e1b revert: Synth button conditional on section items - track can have both
815a6fa hid Synth when track has sections. But a track
can contain both section items AND midi items. Synth
applies to MIDI items regardless of sections. Section
instrument isolation is handled by section clone
nullifying all soundfont fields (9befc20).
2026-07-28 08:50:13 +07:00
3dtours 815a6faaca fix: Synth button shown on section/audio tracks
Synth button was unconditional in TCP. Now gated:
track.midiItems?.length > 0 && !track.sections?.length
Section tracks: Synth hidden (sections are containers).
MIDI tracks: Synth shown.
Audio tracks: Synth hidden.
FX button stays unconditional for all track types.
2026-07-28 08:37:31 +07:00
3dtours 9befc20851 fix: section track inherits soundfont_id/bank/program via ...t spread
...t in fallback branch copied soundfont_id, soundfont_bank,
soundfont_program, instrument_source from parent MAIN track
even though instrumentProgram and synth_engine were nulled.
FluidSynth picks up these fields and auto-loads the SoundFont.
Fix: override all soundfont/synth fields to null/undefined.
2026-07-28 08:32:21 +07:00
3dtours 18e177fa2d revert: section tracks inherit parent instrument, auto-loads SF prog0
Previous fix f2ac70e inherited instrumentId/program/name
from parent MAIN track. This causes SECTION-TAB to auto-load
program 0 of whatever SoundFont is loaded on MAIN, even
though user didn't select any instrument for the section.
Section tracks now keep null instrument + synth_engine
undefined. User can still load instrument manually via
Synth button (routed by updateActiveTracks fix).
2026-07-28 08:27:27 +07:00
3dtours f2ac70eee0 fix: section sub-tracks nullify instrument instead of inheriting
handleEditSectionInTab fallback branch explicitly set
instrumentId: null, instrumentProgram: undefined,
instrumentName: null, overriding parent track's values
spread by ...t. Changed to inherit from parent track:
instrumentId: t.instrumentId || null,
instrumentProgram: t.instrumentProgram,
instrumentName: t.instrumentName || null
2026-07-28 08:18:43 +07:00
3dtours ed51cab765 fix: instrument load from PIANO_ROLL in SECTION-TAB writes to main
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.
2026-07-28 08:11:36 +07:00
3dtours 447504ea67 fix: Ctrl+Click on section/MIDI items toggled selection, locked item
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.
2026-07-28 08:07:49 +07:00
3dtours e4d6405b37 fix: PIANO_ROLL activeTracks resolves main tracks, not section
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.
2026-07-28 08:01:22 +07:00
3dtours 2baf034852 fix: section tab clears midiItems, breaks ghost+dropdown
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.
2026-07-28 07:51:08 +07:00
3dtours d40ac47d65 fix: scanner catalog empty + left column blank
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.
2026-07-28 07:32:40 +07:00
3dtours 14e3a561a3 feat: instrument selector modal 2-column layout
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.
2026-07-28 07:20:01 +07:00
3dtours d0def4bf1e feat: auto-scan soundfont for Synth instrument list
Background daemon (30s interval) tracks scanned files via
sf_scan_state.json (size+mtime). Only inspects new/changed SFs.
Catalog served from scanner cache, no full re-scan per request.
Upload/delete trigger immediate re-scan.
2026-07-28 07:06:37 +07:00
3dtours e68fe27d7f fix: sửa lỗi ctrl+click+drag để copy các items trong MAIN SESSION và SECTION-TAB 2026-07-27 22:33:36 +07:00
3dtours 1dbece1fc7 fix: multi-item drag mousemove didn't handle clips
The multi-item drag mousemove handler only processed
info.type === 'section' and 'midiItem'. Added 'clip' support
(midClips slice + findIndex + update).
2026-07-27 22:32:11 +07:00
3dtours 1ca2ec6d01 fix: Ctrl+click+drag copies pre-toggle selection, not post-toggle
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.
2026-07-27 22:31:29 +07:00
3dtours 292c787a31 fix: multi-item MOVE also discarded cross-track updates
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).
2026-07-27 22:27:24 +07:00
3dtours b84507bd64 fix: multi-ids duplicate only returned changes for drag-start 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).
2026-07-27 22:26:55 +07:00
3dtours 0992879b49 fix: selectedItemIds snapshot includes toggled item for drag
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.
2026-07-27 22:25:11 +07:00
3dtours 74fdc2b303 fix: pending drag effect calls stale handleSectionItemDragStart
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.
2026-07-27 22:21:17 +07:00
3dtours c99527e687 refactor: Ctrl+click toggles selection, Ctrl+click+drag copies group
Changed behavior:
- Ctrl+Click on item (no drag): toggle selection (add/remove)
- Ctrl+Click+Drag on item (movement > 5px): copy selected group
- Alt+Click: move item immediately
- No modifier + click selected item: move group

Added pendingDragRef + useEffect to detect mousemove threshold
before starting copy-drag. onAddToSelection + onSetPendingDrag
props wired through WaveformLane.
2026-07-27 22:14:01 +07:00
3dtours e055e8b5c8 feat: Ctrl+click deselect + global empty-area sweep
- 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
2026-07-27 21:59:33 +07:00
3dtours 85ad22ee4f fix: sweep select now covers all tracks
- 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.
2026-07-27 21:54:12 +07:00
3dtours b8dfc9b211 fix: sweep select stale closure + add sweepSelectRef
handleMouseUp captured stale sweepSelect state from render
closure. Added sweepSelectRef updated on every mousemove
so mouseup reads the latest sweep range correctly.
2026-07-27 21:51:53 +07:00
3dtours fd24c2dcd8 feat: Ctrl+drag sweep select + multi-item copy/drag
- 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
2026-07-27 21:46:20 +07:00
3dtours 80a5352645 feat: Ctrl+drag on ruler creates global selection
handleRulerMouseDown's Ctrl+click branch previously only
cleared selection. Now it sets selectionMode='global',
records drag start, and enables isDraggingRulerRef so the
existing document-level mousemove handler updates the
selection range in real-time.
2026-07-27 21:38:23 +07:00
3dtours 4b732844c9 fix: sửa lỗi hiển thị icon trước menu item ở menu context trong MAIN SESSION 2026-07-27 21:38:10 +07:00
3dtours 430e87445e fix: context menu icons only appear on first open
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.
2026-07-27 21:35:53 +07:00
3dtours 74992f7b55 fix: sửa lỗi mỗi track đều chỉ chơi instrument của track mình 2026-07-27 21:35:42 +07:00
3dtours 8beef7cb4d fix: multi-track ARM routes MIDI to all armed tracks
- 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
2026-07-27 21:31:10 +07:00
3dtours 08cd4d72cf fix: sửa lỗi khi set instrument cho track 2 thì khi vẽ midi note ở track 1 cũng phát âm thanh từ track 2 2026-07-27 21:28:43 +07:00
3dtours 081a17d537 docs: add _playNoteFluid else branch fix to wiki 2026-07-27 21:27:04 +07:00
3dtours 6dec171af7 fix: _playNoteFluid else branch leaked other track's SF program 0
When a track has no instrument configured, _playNoteFluid called
program_change(ch, 0). FluidSynth searches ALL loaded SoundFonts
to resolve program 0 — if only another track's SoundFont is loaded,
that SF's program 0 gets applied to the wrong track.

Fix: use oscillator fallback directly instead of program_change.
2026-07-27 21:26:52 +07:00
3dtours e38391ac7d fix: brush draw preview note uses wrong instrument
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.
2026-07-27 20:52:49 +07:00
3dtours b42190e6bc feat: implement md/41_INSTRUMENT.md spec
- PianoRollTabService: getParentTrackByItemId, buildActiveScope,
  getTrackMidiChannel helpers
- parent_track_id on all MIDI items for reverse track lookup
- handleSwitchMidiItem uses active_scope pattern
- Toolbar shows parent track context labels
- Channel assignment simplified to trackIndex % 16
- All playNote calls use unified getTrackMidiChannel
2026-07-27 20:48:14 +07:00
3dtours 40502eda86 fix: pass channel+synth_engine to playNote in all MIDI playback paths
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.
2026-07-27 20:42:45 +07:00
3dtours f429f6b1d3 docs: verify instrument assignment rules for MAIN SESSION
All 4 rules confirmed working:
1. MIDI item inherits track instrument
2. Section item does NOT inherit
3. Track isolation preserved
4. Piano roll instrument change propagates back to track
2026-07-27 20:32:30 +07:00
3dtours 912fb2d354 fix: fallback channel for unconfigured tracks uses index
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.
2026-07-27 20:19:50 +07:00
3dtours 59cf1d67db fix: store midiChannel on track object for consistent per-track playback
- 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
2026-07-27 20:19:20 +07:00
3dtours 0420eccb82 feat: auto-loop on range selection + per-track instrument isolation
- 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
2026-07-27 20:16:55 +07:00
3dtours b5344e41f7 fix: default instrument for tracks without instrument set
_playNoteFluid: when no synthEngine and no program, fallback to
program 0 on channel (default piano) so unconfigured tracks still
produce sound via FluidSynth
2026-07-27 20:09:49 +07:00
3dtours 8c5d71c2f0 fix: sửa lỗi hiển thị của midi ghost note 2026-07-27 20:09:10 +07:00
3dtours 4a9cbcdef1 fix: per-track instrument + ghost note visibility
soundfontPlayer.js:
- selectInstrument: respect passed channel (don't allocate new one)
- _playNoteFluid: only use _engineChMap when channel is undefined

ghostNoteExtractor.js:
- Include ALL notes from overlapping items (not clipped)
- relative_start_beat can be negative (notes before window)
- Keep original duration instead of clamping
2026-07-27 20:04:56 +07:00
3dtours e14554a75a fix: session-absolute timing for playhead + MIDI scheduling
- st.currentTime is now session-absolute (0 = session bar 0)
- schedulePianoRollMidi adds sessionBeatOffset to note timing so
  notes at renderBeatOffset beats are scheduled with correct delay
- Playhead rendering: removed renderBeatOffset (uses st.currentTime
  directly as session-absolute beats)
- handleEditMidiInTab/handleSwitchMidiItem: currentTime = 0
- Ruler click: clickTime = clickBeat * beatSec (session-absolute)
2026-07-27 19:55:58 +07:00
3dtours a60607c673 fix: ghost playback only when track button is red
- 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
2026-07-27 19:49:10 +07:00
3dtours c9176a27fa fix: single-track active mode — only one button red at a time
activePlayTrackIds changed from Set to single trackId string.
All buttons default gray. Click toggles red on one track only.
2026-07-27 19:44:03 +07:00
3dtours db4bdd2471 fix: track buttons fit column width, gray default, red on click
- Removed w-full so button widths are auto (fit content)
- Default: bg-zinc-700 text-zinc-300 hover:bg-zinc-600
- Play-on click: bg-red-700 text-white
2026-07-27 19:42:28 +07:00
3dtours 60141cef3f style: track buttons with border, rounded, 20px height, 14px font, red active
- Each button: border border-zinc-600 rounded-md, h-[20px], text-[14px]
- Active/play-on track: bg-red-700 (red), selected track: bg-yellow-600
- Off track: bg-zinc-800 text-zinc-500
2026-07-27 19:41:06 +07:00
3dtours 9c0909c3fa fix: sửa lỗi hiển thị của midi ghost note 2026-07-27 19:39:01 +07:00
3dtours 6ff2f72c58 fix: playhead at bar 0 when opening MIDI item in session mode
- 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
2026-07-27 19:37:59 +07:00
3dtours ab4b91a158 fix: shift+scroll velocity changes all selected notes
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.
2026-07-27 19:36:54 +07:00
3dtours 1628a34ea0 fix: ghost overlap use inclusive boundary for adjacent items
Changed itemEndBeat <= windowStartBeat → < and
noteAbsEnd <= windowStartBeat → < so abutting items
(Item 1 ends at bar 1, Item 2 starts at bar 1) show
ghost notes from the adjacent item.
2026-07-27 19:31:36 +07:00
3dtours db45efe53c fix: sync piano roll playhead with main timeline position
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.
2026-07-27 19:29:51 +07:00
3dtours fd23b8c4b0 fix: align CC lane velocity label with keybed column
Added 120px spacer before the CC label div so it aligns with
the piano key column (60px) after the track column (120px).
2026-07-27 19:28:51 +07:00
3dtours 1d3c7a7955 fix: preserve playhead session position across dropdown switch
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.
2026-07-27 19:28:01 +07:00
3dtours 3ed62b3343 fix: subtract renderBeatOffset from mouse beat for correct positioning
- handleGridMouseDown/move: beat = x/pixelsPerBeat - renderBeatOffset
  so hit-testing and note creation use item-relative coordinates
- CC mouse handlers: same offset subtraction
- Ruler click: clickTime subtracts renderBeatOffset so st.currentTime
  remains item-relative
2026-07-27 19:25:29 +07:00
3dtours 3a775fdda7 fix: track button toggle bug — init Set with all track IDs
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.
2026-07-27 18:37:25 +07:00
3dtours 509e171c20 fix: ghost border, track buttons clickable, per-track instrument silent
- 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
2026-07-27 18:37:25 +07:00
3dtours 0500d16bc7 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
2026-07-27 18:37:25 +07:00
3dtours a98dd58744 fix: renderBeatOffset in beats (multiply by timeSigNum)
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.
2026-07-27 18:03:44 +07:00
3dtours 1ba9e8379f feat(piano-roll): session bar0, fixed track column, renderBeatOffset
- sessionStartBar = 0 always; notes rendered at session-absolute positions
- renderBeatOffset aligns active/ghost notes to session timeline
- Track column uses absolute overlay with pointer-events for always-visible
- Removed auto-scroll (viewport starts at bar 0 in session mode)
- Grid spacer (120px) aligns grid with ruler track header
2026-07-27 17:57:47 +07:00
3dtours b9113f8a06 fix: dropdown switch keeps ghost notes from other tracks only; track column toggles MIDI play
- 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
2026-07-27 17:40:31 +07:00
3dtours 91ddc3cb6f fix: move ghost decls before totalBeats to fix TDZ 2026-07-27 17:34:14 +07:00
3dtours fb0cd248ad feat(piano-roll): context menu flip, track column, session duration
- 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)
2026-07-27 17:32:06 +07:00
3dtours d21091b69b feat: hiển thị ghost note trong piano roll tab 2026-07-27 17:26:13 +07:00
3dtours 53a27b5af4 fix: move ghostLayers decl before useLayoutEffect to fix TDZ 2026-07-27 17:15:04 +07:00
3dtours 035d75e504 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
2026-07-27 17:08:59 +07:00
3dtours c4a320a1c1 fix: AI panel hiển thị nội dung nhiều hơn 2026-07-27 16:43:17 +07:00
3dtours 095a0680e8 fix(build): remove extra brace at L6795, Babel build now succeeds; feat(ai-copilot): rows=4, resize-y, cursor-aware ArrowUp/Down 2026-07-27 16:36:26 +07:00
3dtours 4ed0b1e5be fix(ai-copilot): patch trực tiếp app.precompiled.js rows=4 + cursor check cho ArrowUp/Down 2026-07-27 16:21:24 +07:00
3dtours 29206d62a3 fix(ai-copilot): tăng chiều cao prompt input rows=4 + resize-y, sửa ArrowUp/ArrowDown chỉ gọi history khi cursor ở đầu/cuối text 2026-07-27 16:16:26 +07:00
3dtours 57235e68d2 docs: wiki entry program_select fix 2026-07-27 16:16:00 +07:00
17 changed files with 2274 additions and 232 deletions
+2
View File
@@ -17,6 +17,8 @@ app/storage/uploads/*
app/storage/processed/*
!app/storage/uploads/.gitkeep
!app/storage/processed/.gitkeep
app/storage/*.db
app/storage/sf_scan_state.json
.DS_Store
# VST3 and sample library directories (proprietary binaries)
@@ -0,0 +1,287 @@
# Plan: MIDI Ghost Notes + Dropdown Item Switcher + Session Sync Mode
## Overview
Three features built on each other:
1. **Dropdown** at tab title position listing ALL MIDI items across all tracks
2. **Item switching** — selected item becomes editable, all others become ghost notes
3. **Session sync mode toggle** — viewport aligns with session bars (ghost visible) or resets to bar 0 (isolated, no ghost)
---
## Key Design Decisions
### A. Ghost note scope = all items except the selected one
Not just "other tracks" — ALL MIDI items in `activeTracks` except the one matching `targetItemId` contribute ghost notes if overlapping.
### B. Two viewport modes, togglable
| Mode | Viewport origin | Ghost notes | Bar labels |
|------|----------------|-------------|------------|
| **Session Sync** (default) | `item.startTime / secondsPerBar` | Visible | `Bar N` (session-absolute) |
| **Isolated** | bar 0 | Hidden | `Bar N` (0-based) |
### C. Ghost notes computed (not persisted)
No schema changes. Extraction runs in `useMemo` inside `PianoRollTabEditor`.
### D. Hit-testing exclusion is automatic
Ghost notes are in separate `ghostLayers` state; mouse handlers only iterate `notes`.
---
## Files to Modify
| File | Change |
|------|--------|
| **NEW** `app/static/js/services/ghostNoteExtractor.js` | Extraction logic |
| `app/static/js/app.jsx` (~16113) | Pass `activeTracks` prop to `PianoRollTabEditor` |
| `app/static/js/app.jsx` (~4563-6070) | All PianoRollTabEditor changes below |
---
## Step-by-Step Implementation
### Step 1: Create `ghostNoteExtractor.js`
`app/static/js/services/ghostNoteExtractor.js`
```js
export function extractGhostLayers(activeTracks, targetTrackId, targetItemId, bpm)
```
Algorithm:
1. `secondsPerBeat = 60 / bpm`
2. Find `targetItem` across all tracks → `windowStartBeat = targetItem.startTime / secondsPerBeat`, `windowEndBeat = (targetItem.startTime + targetItem.duration) / secondsPerBeat`
3. Iterate ALL tracks, ALL MIDI items:
- Skip non-MIDI tracks (`!t.midiItems || !t.midiItems.length`)
- Skip muted tracks (`t.muted`)
- Skip item matching `targetItemId` (the active item)
4. For each candidate item:
- `itemStartBeat = item.startTime / secondsPerBeat`
- `itemEndBeat = (item.startTime + item.duration) / secondsPerBeat`
- Overlap test: `itemStartBeat < windowEndBeat && itemEndBeat > windowStartBeat`
- For each overlapping note:
- `noteAbsStart = itemStartBeat + note.start_beat`
- `noteAbsEnd = noteAbsStart + note.duration_beats`
- Clip: keep if `noteAbsStart < windowEndBeat && noteAbsEnd > windowStartBeat`
- `clampedDur = Math.min(noteAbsEnd, windowEndBeat) - Math.max(noteAbsStart, windowStartBeat)`
- Push: `{ id: ghost_${note.id}, pitch, relative_start_beat: noteAbsStart - windowStartBeat, duration_beats: clampedDur, velocity, original_track_name: t.name, original_track_color: t.color || '#888' }`
5. Group by track → `ghostLayers: [{ track_id, track_name, track_color, notes }]`
6. Return `ghostLayers`
### Step 2: Pass `activeTracks` to PianoRollTabEditor
At render site (~line 16113), add:
```js
activeTracks: activeTracks,
```
Add `activeTracks` to destructured props in `PianoRollTabEditor` function signature (~line 4563).
### Step 3: New state & derived data
Inside `PianoRollTabEditor` (~line 4570), after existing `React.useState` declarations:
```js
const [showGhostNotes, setShowGhostNotes] = React.useState(true);
const [sessionSyncMode, setSessionSyncMode] = React.useState(true);
// Compute all MIDI items for dropdown
const allMidiItems = React.useMemo(() => {
const result = [];
(activeTracks || []).forEach(t => {
if (!t.midiItems || !t.midiItems.length) return;
t.midiItems.forEach(m => {
result.push({ ...m, _trackId: t.id, _trackName: t.name });
});
});
return result;
}, [activeTracks]);
// Compute ghost layers
const ghostLayers = React.useMemo(() => {
if (!activeTracks || !st || !st.target_id) return [];
return extractGhostLayers(activeTracks, st.trackId, st.target_id, parseInt(bpm) || 120);
}, [activeTracks, st.trackId, st.target_id, bpm]);
// Compute session offset for bar labels
const secondsPerBar = (60.0 / (parseInt(bpm) || 120)) * 4;
const targetTrack = React.useMemo(
() => (activeTracks || []).find(t => t.id === st.trackId),
[activeTracks, st.trackId]
);
const activeTargetItem = React.useMemo(
() => targetTrack ? (targetTrack.midiItems || []).find(m => m.id === st.target_id) : null,
[targetTrack, st.target_id]
);
const sessionStartBar = sessionSyncMode && activeTargetItem
? (activeTargetItem.startTime / secondsPerBar) : 0;
```
### Step 4: Dropdown at tab title position
Replace the static title (line 5748-5752) with a dropdown:
```js
/* 1a. TAB TITLE DROPDOWN */
React.createElement("div", { className: "relative inline-block text-xs" },
React.createElement("select", {
value: st.target_id,
onChange: e => handleSwitchMidiItem(e.target.value),
className: "bg-zinc-800 border border-zinc-700 text-yellow-500 font-bold rounded px-1.5 py-0.5 outline-none focus:border-yellow-500 max-w-[200px]"
}, allMidiItems.map(m =>
React.createElement("option", {
key: m.id,
value: m.id
}, `${m._trackName} - ${m.name || 'MIDI'}`)
))
)
```
### Step 5: Switch handler function
Add before the return statement:
```js
const handleSwitchMidiItem = (itemId) => {
if (itemId === st.target_id) return;
// Save current notes first
onSaveNotes(st.id, st.trackId, st.target_id, notes);
// Find selected item
const match = allMidiItems.find(m => m.id === itemId);
if (!match) return;
// Update subTab state (triggers ghost re-compute via useMemo)
setSubTabs(prev => prev.map(s => s.id === st.id ? {
...s,
trackId: match._trackId,
target_id: match.id,
label: `Piano Roll: ${match.name || 'MIDI'}`,
notes: match.notes || [],
duration: match.duration || 4,
instrumentProgram: activeTracks.find(t => t.id === match._trackId)?.instrumentProgram,
instrumentName: activeTracks.find(t => t.id === match._trackId)?.instrumentName,
note_selection: [],
currentTime: 0,
} : s));
// Reset local state
setSelectedNoteIds([]);
setLoopStartBeat(null);
setLoopEndBeat(null);
};
```
### Step 6: Session sync toggle button
In toolbar (~line 5798, near CC toggle), add:
```js
/* Session sync mode toggle */
React.createElement("button", {
onClick: () => setSessionSyncMode(!sessionSyncMode),
className: `px-2 py-1 rounded text-xs ${sessionSyncMode ? 'bg-cyan-900/60 text-cyan-300 border border-cyan-700' : 'text-zinc-500 hover:text-zinc-300'}`,
title: sessionSyncMode ? "Session-synced mode (ghost visible)" : "Isolated mode (bar 0, no ghost)"
}, sessionSyncMode ? "🌐 Session" : "📋 Isolated")
```
And the Ghost toggle:
```js
React.createElement("button", {
onClick: () => setShowGhostNotes(!showGhostNotes),
disabled: !sessionSyncMode,
className: `px-2 py-1 rounded text-xs ${!sessionSyncMode ? 'opacity-30 cursor-not-allowed' : showGhostNotes ? 'bg-purple-900/60 text-purple-300 border border-purple-700' : 'text-zinc-500 hover:text-zinc-300'}`,
title: "Toggle ghost notes visibility"
}, "👻 Ghost")
```
Ghost toggle disabled in isolated mode (no ghost notes to show).
### Step 7: Bar labels with session offset
Modify `renderBarLabels()` (~line 5711-5734):
Replace `Bar ${bar}` with:
```js
const displayBar = bar + Math.floor(sessionStartBar);
`Bar ${displayBar}`
```
And the seek click handler:
```js
const barTime = (bar + Math.floor(sessionStartBar)) * 4 * beatSec;
```
### Step 8: Auto-scroll to session position
Add `useEffect`:
```js
React.useEffect(() => {
if (sessionSyncMode && gridScrollRef.current && activeTargetItem) {
const scrollTargetBeats = sessionStartBar * 4;
gridScrollRef.current.scrollLeft = scrollTargetBeats * pixelsPerBeat;
}
}, [sessionSyncMode, sessionStartBar, st.target_id, pixelsPerBeat]);
```
### Step 9: Ghost note canvas layer
In the note-drawing `useLayoutEffect` (~line 4834), insert **before** active note rendering:
```js
/* Layer 2: Ghost Notes */
if (showGhostNotes && sessionSyncMode && ghostLayers.length > 0) {
ghostLayers.forEach(layer => {
ctx.save();
ctx.globalAlpha = 0.25;
ctx.fillStyle = layer.track_color || '#888';
ctx.strokeStyle = layer.track_color || '#888';
layer.notes.forEach(note => {
const x = note.relative_start_beat * pixelsPerBeat;
const y = (127 - note.pitch) * NoteHeight;
const w = note.duration_beats * pixelsPerBeat;
const h = NoteHeight - 1;
ctx.fillRect(x, y, w, h);
ctx.setLineDash([2, 2]);
ctx.lineWidth = 1;
ctx.strokeRect(x, y, w, h);
ctx.setLineDash([]);
});
ctx.restore();
});
}
```
Add `showGhostNotes`, `sessionSyncMode`, `ghostLayers` to dependency array.
### Step 10: Ghost notes in CC Lane
Skip ghost notes in CC lane (only active notes). CC lane already only iterates `notes`, not `ghostLayers`. No changes needed.
---
## Edge Cases
- **Dropdown with single MIDI item**: Only one option, no ghost notes (nothing to ghost).
- **Item deleted while piano roll is open**: `handleSwitchMidiItem` fails gracefully (item not found → no-op). `ghostLayers` `useMemo` returns `[]`.
- **BPM change mid-edit**: All beat computations update via `useMemo`/React reactivity.
- **Session sync → Isolated switch**: Scroll resets to 0, bar labels change to 0-based, ghost notes disappear.
- **Isolated → Session sync switch**: Scroll jumps to session position, ghost notes reappear.
- **Color fallback**: Use track's `color` prop; if `#888` as default.
---
## Validation
1. Open MIDI item → dropdown shows all MIDI items across all tracks
2. Select different item from dropdown → active notes switch, ghost notes re-compute
3. Ghost notes from ALL non-selected items appear (same track + other tracks)
4. Ghost toggle hides/shows ghost notes (disabled in isolated mode)
5. Session sync mode shows correct bar labels (e.g. `Bar 2` if item starts at bar 2)
6. Isolated mode shows `Bar 0, 1, 2...` regardless of item's session position
7. Switching items in isolated mode: notes change, viewport stays at bar 0
8. Ghost notes cannot be clicked/dragged (excluded from hit-testing)
9. Muted tracks' MIDI items are excluded from ghost notes
10. Items outside target window are excluded from ghost notes
+17 -8
View File
@@ -8,6 +8,7 @@ from app.core.vst_engine import PluginManager, HAS_PEDALBOARD, HAS_PYFLUIDSYNTH
from app.core.render_engine import PythonRenderEngine
from app.core.soundfont_inspector import SoundFontInspector
from app.core.soundfont_converter import SoundFontConverter
from app.core.soundfont_scanner import SoundFontAutoScanner
from app.api.v1.auth import get_current_user
router = APIRouter()
@@ -18,6 +19,7 @@ os.makedirs(UPLOAD_SF_DIR, exist_ok=True)
SYSTEM_SF_DIR = "/opt/daw_engine/soundfonts"
_inspector = None
_scanner = None
def get_inspector():
global _inspector
@@ -25,6 +27,13 @@ def get_inspector():
_inspector = SoundFontInspector(system_sf_dir=SYSTEM_SF_DIR, upload_sf_dir=UPLOAD_SF_DIR)
return _inspector
def get_scanner():
global _scanner
if _scanner is None:
_scanner = SoundFontAutoScanner(system_sf_dir=SYSTEM_SF_DIR, upload_sf_dir=UPLOAD_SF_DIR)
_scanner.scan_once()
return _scanner
@router.get("/available")
async def list_plugins(current_user: dict = Depends(get_current_user)):
@@ -50,9 +59,10 @@ async def list_default_soundfonts():
@router.get("/soundfonts/catalog")
async def soundfont_catalog(current_user: dict = Depends(get_current_user)):
scanner = get_scanner()
full_catalog = scanner.get_catalog()
inspector = get_inspector()
full_catalog = inspector.get_catalog()
condensed_catalog = inspector.get_condensed_catalog_summary()
condensed_catalog = inspector.get_condensed_catalog_summary(full_catalog)
return {"full_catalog": full_catalog, "condensed_catalog": condensed_catalog}
@@ -91,11 +101,11 @@ async def upload_soundfont(
import json
json.dump({"original_name": file.filename, "uuid": file_uuid, "file": file_id}, f)
inspector = get_inspector()
inspector.invalidate_catalog_cache()
scanner = get_scanner()
if background_tasks:
background_tasks.add_task(inspector.generate_full_catalog)
background_tasks.add_task(scanner.scan_once)
else:
scanner.scan_once()
return {"id": file_id, "name": file.filename, "path": dest_path, "size_bytes": len(contents)}
@@ -122,8 +132,7 @@ async def delete_soundfont(sf_id: str, current_user: dict = Depends(get_current_
break
if not deleted:
raise HTTPException(status_code=404, detail="SoundFont not found")
inspector = get_inspector()
inspector.invalidate_catalog_cache()
get_scanner().scan_once()
return {"deleted": True, "sf_id": sf_id}
+2 -2
View File
@@ -104,8 +104,8 @@ class SoundFontInspector:
return self._catalog_cache
return self.generate_full_catalog()
def get_condensed_catalog_summary(self) -> dict:
catalog = self.get_catalog()
def get_condensed_catalog_summary(self, catalog: dict = None) -> dict:
catalog = catalog if catalog is not None else self.get_catalog()
condensed = {}
for sf_id, sf_info in catalog.items():
instruments = sf_info.get("instruments", [])
+129
View File
@@ -0,0 +1,129 @@
import os
import json
import time
import logging
import threading
from app.config import settings
logger = logging.getLogger(__name__)
TRACK_FILE = os.path.join(settings.STORAGE_DIR, "sf_scan_state.json")
SYSTEM_SF_DIR = "/opt/daw_engine/soundfonts"
UPLOAD_SF_DIR = os.path.join(settings.STORAGE_DIR, "soundfonts")
def _file_sig(path: str) -> tuple:
s = os.path.getsize(path)
m = os.path.getmtime(path)
return (s, m)
class SoundFontAutoScanner:
def __init__(self, system_sf_dir=SYSTEM_SF_DIR, upload_sf_dir=UPLOAD_SF_DIR):
self.system_sf_dir = system_sf_dir
self.upload_sf_dir = upload_sf_dir
self._catalog = {}
self._lock = threading.Lock()
self._state = self._load_state()
def _load_state(self) -> dict:
if not os.path.exists(TRACK_FILE):
return {}
try:
with open(TRACK_FILE) as f:
return json.load(f)
except Exception as e:
logger.warning("scan_state load failed: %s", e)
return {}
def _save_state(self):
os.makedirs(os.path.dirname(TRACK_FILE), exist_ok=True)
with open(TRACK_FILE, "w") as f:
json.dump(self._state, f, indent=2)
def _sf_files(self, directory: str) -> list:
if not os.path.isdir(directory):
return []
out = []
for fname in os.listdir(directory):
if fname.lower().endswith((".sf2", ".sf3")):
out.append((fname, os.path.join(directory, fname)))
return out
def _inspect_single(self, fname: str, full: str, inspector) -> dict:
if fname.lower().endswith(".sf2"):
sf_info = inspector.inspect_sf2_file(full) or {}
else:
sf_info = {}
if not sf_info.get("soundfont_id"):
sf_id = os.path.splitext(fname)[0].lower()
sf_info = {
"soundfont_id": sf_id,
"filename": fname,
"total_instruments": 0,
"instruments": [],
"_sf3": True
}
return sf_info
def scan_once(self) -> bool:
from app.core.soundfont_inspector import SoundFontInspector
inspector = SoundFontInspector(self.system_sf_dir, self.upload_sf_dir)
found_new = False
dirs = [(self.system_sf_dir, "system")]
if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir):
dirs.append((self.upload_sf_dir, "upload"))
for directory, source in dirs:
for fname, full in self._sf_files(directory):
sf_id = os.path.splitext(fname)[0].lower()
key = f"{source}:{sf_id}"
sig = _file_sig(full)
prev = self._state.get(key)
unchanged = prev and prev["size"] == sig[0] and prev["mtime"] == sig[1]
if unchanged and sf_id in self._catalog:
continue
if unchanged:
with self._lock:
if sf_id in self._catalog:
continue
sf_info = self._inspect_single(fname, full, inspector)
self._catalog[sf_id] = sf_info
continue
found_new = True
logger.info("New/changed SF detected: %s", fname)
sf_info = self._inspect_single(fname, full, inspector)
with self._lock:
self._catalog[sf_id] = sf_info
self._state[key] = {"size": sig[0], "mtime": sig[1], "file": fname}
if found_new:
self._save_state()
inspector.invalidate_catalog_cache()
return found_new
def scan_loop(self, interval: int = 30, stop_event: threading.Event = None):
logger.info("SF auto-scanner started (interval=%ds)", interval)
self.scan_once()
while not (stop_event and stop_event.is_set()):
time.sleep(interval)
try:
self.scan_once()
except Exception as e:
logger.error("scan cycle error: %s", e)
def start_background(self, interval: int = 30) -> threading.Event:
ev = threading.Event()
t = threading.Thread(target=self.scan_loop, args=(interval, ev), daemon=True)
t.start()
return ev
def get_catalog(self) -> dict:
with self._lock:
return dict(self._catalog)
def get_instruments(self, sf_id: str) -> list:
entry = self._catalog.get(sf_id)
if entry:
return entry.get("instruments", [])
return []
+6
View File
@@ -15,6 +15,7 @@ from app.api.v1.ai_proxy import router as ai_proxy_router
from app.api.v1.plugins import router as plugins_router
from app.core.auth import seed_admin
from app.core.soundfont_converter import SoundFontConverter
from app.core.soundfont_scanner import SoundFontAutoScanner
# Ensure storage directories exist
os.makedirs(settings.UPLOADS_DIR, exist_ok=True)
@@ -65,6 +66,11 @@ async def startup_convert_soundfonts():
print(f"[Startup] SoundFont conversion error: {e}")
threading.Thread(target=_run, daemon=True).start()
@app.on_event("startup")
async def startup_sf_scanner():
scanner = SoundFontAutoScanner()
scanner.start_background(interval=30)
@app.get("/", response_class=HTMLResponse)
async def get_index():
index_path = os.path.join(settings.TEMPLATES_DIR, "index.html")
+810 -150
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,71 @@
// 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) continue;
trackGhostNotes.push({
id: 'ghost_' + (note.id || Math.random().toString(36).substr(2, 9)),
pitch: note.pitch,
relative_start_beat: noteAbsStart - windowStartBeat,
duration_beats: (note.duration_beats || 1),
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 };
})();
@@ -0,0 +1,43 @@
(function () {
'use strict';
window.SonicPianoRoll = {
getParentTrackByItemId: function (itemId, tracks) {
if (!tracks || !itemId) return null;
for (var i = 0; i < tracks.length; i++) {
var items = tracks[i].midiItems || [];
for (var j = 0; j < items.length; j++) {
if (items[j].id === itemId) return tracks[i];
}
}
return null;
},
getParentTrackIdByItemId: function (itemId, tracks) {
var trk = this.getParentTrackByItemId(itemId, tracks);
return trk ? trk.id : null;
},
buildActiveScope: function (itemId, tracks) {
var parentTrack = this.getParentTrackByItemId(itemId, tracks);
if (!parentTrack) return null;
var trackIndex = -1;
for (var i = 0; i < tracks.length; i++) {
if (tracks[i].id === parentTrack.id) { trackIndex = i; break; }
}
return {
item_id: itemId,
parent_track_id: parentTrack.id,
midi_channel: trackIndex >= 0 ? trackIndex % 16 : 0,
current_synth_engine: parentTrack.synth_engine || null,
instrument_program: parentTrack.instrumentProgram,
instrument_name: parentTrack.instrumentName
};
},
getTrackMidiChannel: function (track, tracks) {
if (track && track.midiChannel !== undefined) return track.midiChannel;
if (!track || !tracks) return 0;
for (var i = 0; i < tracks.length; i++) {
if (tracks[i].id === track.id) return i % 16;
}
return 0;
}
};
})();
+39 -25
View File
@@ -233,9 +233,13 @@
}
var engKey = (sfId || '') + ':' + bank + ':' + program;
if (!_engineChMap[engKey]) {
var allocCh = this.allocateChannel(bank);
_engineChMap[engKey] = allocCh;
channel = allocCh;
if (channel === undefined || channel === null) {
var allocCh = this.allocateChannel(bank);
_engineChMap[engKey] = allocCh;
channel = allocCh;
} else {
_engineChMap[engKey] = channel;
}
} else {
channel = _engineChMap[engKey];
}
@@ -358,36 +362,29 @@
typeof velocity === 'number' ? (velocity > 1 ? velocity : velocity * 127) : 100
)));
var _origChannel = channel;
var ch = channel;
var usedBank = 0, usedProg = 0;
if (synthEngine) {
usedBank = synthEngine.soundfont_bank || 0;
usedProg = synthEngine.soundfont_program || 0;
var engKey = (synthEngine.soundfont_id || '') + ':' + usedBank + ':' + usedProg;
var mappedCh = _engineChMap[engKey];
if (mappedCh !== undefined) {
ch = mappedCh;
} else if (ch === undefined) {
ch = usedBank === 128 ? 9 : 0;
}
if (ch === undefined) ch = (usedBank === 128 ? 9 : 0);
var sfHandle = synthEngine.soundfont_id ? _sfHandleMap.get(synthEngine.soundfont_id) : undefined;
if (sfHandle !== undefined) {
try {
_fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, usedBank, usedProg);
} catch (e) {}
} else {
try { _fluidModule._fluid_synth_bank_select(_synthPtr, ch, usedBank); } catch (e) {}
try { _fluidModule._fluid_synth_program_change(_synthPtr, ch, usedProg); } catch (e) {}
if (channel === undefined) {
var engKey = (synthEngine.soundfont_id || '') + ':' + usedBank + ':' + usedProg;
var mappedCh = _engineChMap[engKey];
if (mappedCh !== undefined) {
channel = mappedCh;
} else {
channel = usedBank === 128 ? 9 : 0;
}
}
if (channel === undefined) channel = (usedBank === 128 ? 9 : 0);
} else if (program !== undefined) {
usedProg = program;
if (ch === undefined) ch = 0;
try {
_fluidModule._fluid_synth_program_change(_synthPtr, ch, usedProg);
} catch (e) {}
if (channel === undefined) channel = 0;
} else {
// No instrument configured: silent — no FluidSynth, no oscillator.
return;
}
if (ch === undefined) ch = (usedBank === 128 ? 9 : 0);
if (channel === undefined) channel = (usedBank === 128 ? 9 : 0);
var ch = channel;
var ctx = getCtx();
var now = ctx.currentTime;
var delay = (typeof startTime === 'number' && startTime > now) ? (startTime - now) : 0;
@@ -396,6 +393,23 @@
var self = this;
var doNote = function () {
try {
// Program change at note time, not call time — ensures correct
// instrument for each item regardless of processing order.
if (synthEngine) {
var sfHandle = synthEngine.soundfont_id ? _sfHandleMap.get(synthEngine.soundfont_id) : undefined;
if (sfHandle !== undefined) {
try {
_fluidModule._fluid_synth_program_select(_synthPtr, ch, sfHandle, usedBank, usedProg);
} catch (e) {}
} else {
try { _fluidModule._fluid_synth_bank_select(_synthPtr, ch, usedBank); } catch (e) {}
try { _fluidModule._fluid_synth_program_change(_synthPtr, ch, usedProg); } catch (e) {}
}
} else if (program !== undefined) {
try {
_fluidModule._fluid_synth_program_change(_synthPtr, ch, usedProg);
} catch (e) {}
}
console.log("[SonicSF] noteOn ch:", ch, "pitch:", midiPitch, "vel:", midiVel);
_fluidModule._fluid_synth_noteon(_synthPtr, ch, midiPitch, midiVel);
var noteMapKey = (_origChannel !== undefined ? _origChannel : 0) + ':' + midiPitch;
Binary file not shown.
+2
View File
@@ -18,6 +18,8 @@
<script src="/static/js/services/soundfontPlayer.js?v=202607271245"></script>
<script src="/static/js/services/aiGateway.js?v=202607271016"></script>
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
<script src="/static/js/services/ghostNoteExtractor.js?v=202607271727"></script>
<script src="/static/js/app.precompiled.js?v=202607271245" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style>
+259
View File
@@ -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.
+230
View File
@@ -0,0 +1,230 @@
# TECHNICAL SPECIFICATION: INSTRUMENT MANAGEMENT & DATA ISOLATION FOR MULTI-ITEM PIANO ROLL TABS
This document specifies the data architecture, state flow, and audio channel routing rules required to ensure that when a single Piano Roll Tab opens multiple `MIDIItems` simultaneously from different Tracks, modifying the instrument/synth engine for the active item applies exclusively to its parent track without affecting any other items or tracks.
---
## 1. DATA OWNERSHIP HIERARCHY
To prevent cross-track instrument configuration leakage, the system enforces **Track-Level Ownership**:
* **Track (`TrackState`):** The sole owner of synth engine configurations (`synth_engine`), assigned MIDI channel (`midi_channel`), and mixing parameters (`volume_db`, `pan`).
* **MIDI Item (`MIDIItemState`):** Contains **no** independent synth configuration parameters. An item holds only its array of notes (`source_data.notes`) and a mandatory parent reference pointer (`parent_track_id`).
```text
[ Main Session / Project State ]
├── Track 1 (id: "track_01", midi_channel: 0, synth_engine: "DSK_Pipa")
│ └── Item 1 (id: "item_01", parent_track_id: "track_01") ──┐
│ │
└── Track 2 (id: "track_02", midi_channel: 1, synth_engine: "None")│
└── Item 2 (id: "item_02", parent_track_id: "track_02") ──┼─► [ Piano Roll Tab Context ]
│ (Active Item Selector Dropdown)
│ ├── Selected: Item 1 -> Scope: Track 1
└── └── Inactive: Item 2 -> Scope: Track 2
```
---
## 2. MULTI-ITEM PIANO ROLL TAB STATE STRUCTURE
When a user opens one or more `MIDIItems` inside the same Piano Roll Tab, the Tab Context State maintains a list of open item IDs along with an `active_item_id` representing the item currently selected in the toolbar dropdown:
```javascript
// Tab Context state structure for a Piano Roll Tab editing multiple items
const multiItemPianoRollTabContext = {
tab_id: "tab_pianoroll_multi_editor",
title: "Piano Roll Editor",
type: "PIANO_ROLL_TAB",
// 1. Array of all MIDI Item IDs currently loaded in this Tab
open_item_ids: ["item_01", "item_02"],
// 2. ID of the item currently selected for direct editing via the Toolbar Dropdown
active_item_id: "item_01",
// 3. Dynamic Computed Context (Derived State based on active_item_id)
active_scope: {
item_id: "item_01",
parent_track_id: "track_01", // Reverse pointer to Track 1
midi_channel: 0, // Dedicated MIDI Channel for Track 1
current_synth_engine: {
type: "soundfont",
plugin_id: "dsk_asian_dreamz",
soundfont_bank: 0,
soundfont_program: 0 // Pipa
}
}
};
```
---
## 3. ISOLATED INSTRUMENT SELECTION WORKFLOW
### Step 1: User switches `active_item_id` in the Toolbar Dropdown
When the user selects `MIDI Item 2` from the Piano Roll Tab dropdown:
1. The Tab Controller receives a `SWITCH_PIANO_ROLL_ACTIVE_ITEM` event.
2. The system queries `MIDI Item 2` for its `parent_track_id` (e.g., returning `"track_02"`).
3. The controller reads the current `synth_engine` configuration directly from `Track 2`.
4. The Piano Roll Tab's Synth button label updates to reflect `Track 2`'s instrument (or displays `🎵 None (Default Synth)` if unassigned).
### Step 2: User changes the instrument in the Piano Roll Synth Menu
When the user opens the Synth menu on the Piano Roll toolbar and selects a new instrument (e.g., selecting `Vital VST3` or `Saxophone SoundFont`):
1. The UI resolves the current `active_item_id` (`"item_01"`).
2. The UI queries the parent track ID: `targetTrackId = getItemParentTrackId(active_item_id)`.
3. The system dispatches an isolated mutation action:
```javascript
dispatch({
type: "UPDATE_TRACK_SYNTH_ENGINE",
payload: {
track_id: targetTrackId, // Modifies ONLY "track_01"; "track_02" remains untouched
synth_engine: {
type: "vst3",
plugin_id: "Vital",
soundfont_bank: 0,
soundfont_program: 0
}
}
});
```
---
## 4. AUDIO CHANNEL ISOLATION
To ensure that triggering notes on `Item 1` plays the Pipa sound while `Item 2` plays the Vital patch without audio cross-talk, dedicated MIDI channels are assigned per track:
### MIDI Channel Binding Rules
| Track | MIDI Item | Parent Track ID | Dedicated MIDI Channel | Applied Instrument |
| --- | --- | --- | --- | --- |
| **Track 1** | MIDI Item 1 | `track_01` | Channel 0 | DSK_Asian_DreamZ (Pipa) |
| **Track 2** | MIDI Item 2 | `track_02` | Channel 1 | Vital.vst3 |
* **When configuring Instrument for Track 1:**
The Client SoundEngine/Wasm routes configuration changes exclusively to Channel 0:
```javascript
soundFontPlayerInstance.selectInstrument(channel = 0, bank = 0, program = 0);
```
* **When configuring Instrument for Track 2:**
The Client SoundEngine/Wasm routes configuration changes exclusively to Channel 1:
```javascript
soundFontPlayerInstance.selectInstrument(channel = 1, bank = 0, program = 56);
```
* **When previewing notes in the Piano Roll:**
* If `MIDI Item 1` is active $\rightarrow$ Dispatch `noteOn(channel = 0, pitch, velocity)`.
* If `MIDI Item 2` is active $\rightarrow$ Dispatch `noteOn(channel = 1, pitch, velocity)`.
---
## 5. CORE SERVICE IMPLEMENTATION (`pianoRollTabService.js`)
```javascript
// app/static/js/services/pianoRollTabService.js
/**
* State Manager and Dispatcher for Multi-Item Piano Roll Tabs
*/
export class PianoRollTabManager {
constructor(sessionState, soundEngine) {
this.sessionState = sessionState;
this.soundEngine = soundEngine;
}
/**
* Retrieves parent Track by Item ID
*/
getParentTrackByItemId(itemId) {
for (const track of this.sessionState.main_session.tracks) {
const item = track.items.find(i => i.id === itemId);
if (item) return track;
}
return null;
}
/**
* Updates instrument configuration from the Piano Roll Tab Toolbar
* @param {string} activeItemId - Currently selected Item ID in dropdown
* @param {Object} newSynthConfig - New Synth configuration object
*/
setInstrumentFromPianoRoll(activeItemId, newSynthConfig) {
const parentTrack = this.getParentTrackByItemId(activeItemId);
if (!parentTrack) {
console.error(`[PianoRoll] Parent Track not found for Item ID: ${activeItemId}`);
return;
}
console.log(`[PianoRoll] Applying new instrument to Track "${parentTrack.name}" (ID: ${parentTrack.id})`);
// 1. Update state exclusively on the parent Track
parentTrack.synth_engine = { ...newSynthConfig };
// 2. Resolve parent Track's dedicated MIDI Channel
const trackIndex = this.sessionState.main_session.tracks.findIndex(t => t.id === parentTrack.id);
const dedicatedMidiChannel = trackIndex % 16; // Assign channels 0-15
// 3. Dispatch instrument change to the Client Sound Engine ONLY FOR THIS CHANNEL
if (this.soundEngine) {
this.soundEngine.selectInstrument(
dedicatedMidiChannel,
newSynthConfig.soundfont_bank || 0,
newSynthConfig.soundfont_program || 0
);
}
// 4. Dispatch UI re-render event
window.dispatchEvent(new CustomEvent('DAW_STATE_UPDATED', { detail: this.sessionState }));
}
/**
* Switches the active item in the toolbar dropdown
*/
switchActiveItem(tabContext, newActiveItemId) {
tabContext.active_item_id = newActiveItemId;
const parentTrack = this.getParentTrackByItemId(newActiveItemId);
if (parentTrack) {
tabContext.active_scope = {
item_id: newActiveItemId,
parent_track_id: parentTrack.id,
current_synth_engine: parentTrack.synth_engine || { type: "none" }
};
}
return tabContext;
}
}
```
---
## 6. UI/UX SAFETY CHECKS & ERROR PREVENTION
* **Explicit Parent Track Indicators on Toolbar:**
Next to the MIDI Item dropdown, the Piano Roll toolbar explicitly displays context labels:
`[ Item Selector: MIDI Item 1 ▾ ] ── (Belongs to: Track 1)`
The Synth Selector button displays: `[ 🎵 Synth (Track 1): DSK_Pipa ▾ ]`.
* **Isolated Event Bus Mutators:**
When invoking `setTrackInstrument`, global setters such as `setAllTracksInstrument()` or `global_synth_engine` mutations are strictly forbidden. Every state mutation function requires an explicit `track_id` parameter.
* **Multi-Item Ghost Notes Rendering:**
The item selected in the dropdown (`active_item_id`) is the sole editable item. All other items listed in `open_item_ids` render automatically as read-only Ghost Notes for visual reference without mixing note data or instrument parameters.
+120
View File
@@ -0,0 +1,120 @@
# DIAGNOSIS & BUG FIX: ENABLING ARM ON TRACK 1 CAUSES TRACK 1 INSTRUMENT TO OVERRIDE TRACK 2
---
## 1. ROOT CAUSE ANALYSIS
The issue where arming Track 1 causes Track 1's instrument to override or mute Track 2 stems from two common architectural bugs:
### 🔴 Cause 1: MIDI Channel Collision (Most Common)
* **Current State:** Both Track 1 and Track 2 share the default MIDI Channel (`Channel 0`) on the Synth Engine (`SpessaSynth` / `FluidSynth`).
* **Bug Sequence:**
1. Initially, Track 2 assigns its instrument patch to `Channel 0`.
2. When you arm Track 1, the UI issues a patch change command: `selectInstrument(channel = 0, bank_track1, program_track1)`.
3. This call **overwrites** `Channel 0`'s instrument patch with Track 1's instrument.
4. When the Timeline plays back over Track 2, Track 2 still reads notes on `Channel 0`. Consequently, all notes on Track 2 play using Track 1's instrument sound, or get muted completely if the voice allocation limit is exceeded.
### 🔴 Cause 2: Hardcoded Live MIDI Channel Handler
* When pressing keys on a hardware MIDI Keyboard, the `onmidimessage` handler sends a fixed `noteOn(0, pitch, velocity)` call to `Channel 0`.
* If Track 1 is armed and triggers `programChange(0, prog1)` while Track 2 on the timeline also feeds notes into `Channel 0`, live previews and timeline playback collide on the exact same audio channel.
---
## 2. TECHNICAL SOLUTION & FIX CODEBASE
To allow two tracks to play completely distinct instruments simultaneously—even with ARM Live Monitoring active—the system must enforce **Dedicated MIDI Channel Binding**:
### Step 1: Assign an Independent MIDI Channel Per Track (`sessionStore`)
During initialization or when adding a track to the session, allocate a distinct MIDI channel (from 0 to 15) to each track:
```javascript
// Assigns a dedicated MIDI Channel based on the Track's index in the session
export function getDedicatedMIDIChannel(trackIndex) {
// Channel 9 (10th channel) is reserved for Percussion/Drums
if (trackIndex === 9) return 10;
return trackIndex % 16;
}
```
### Step 2: Update Patch Selection Commands to Target Only the Assigned Track Channel
When selecting an instrument or when Track 1 is armed, apply patch changes exclusively to Track 1's assigned MIDI channel:
```javascript
// app/static/js/services/soundfontPlayer.js
export function setTrackInstrument(track, trackIndex, soundEngine) {
const dedicatedChannel = getDedicatedMIDIChannel(trackIndex);
const synthConfig = track.synth_engine || {};
const bank = synthConfig.soundfont_bank || 0;
const program = synthConfig.soundfont_program || 0;
// Change patch ONLY on this track's assigned channel; do NOT touch other channels
soundEngine.selectInstrument(dedicatedChannel, bank, program);
console.log(`[DAW Router] Track "${track.name}" mapped to Channel ${dedicatedChannel} (Bank:${bank}, Program:${program})`);
}
```
### Step 3: Route Live Hardware MIDI Keyboard Signals to the Armed Track's Assigned Channel
When the hardware MIDI keyboard emits events, identify the currently armed track and route `noteOn` / `noteOff` messages directly to that track's designated MIDI channel:
```javascript
// app/static/js/services/midiHandler.js
export function handleLiveMIDIMessage(event, sessionState, soundEngine) {
if (!event || !event.data || event.data.length < 3) return;
const [statusByte, pitch, velocityByte] = event.data;
const command = statusByte >> 4;
// 1. Locate the currently ARMED [R] track on the UI
const armedTrackIndex = sessionState.main_session.tracks.findIndex(t => t.is_armed);
if (armedTrackIndex === -1) {
// No track armed -> Suppress live preview
return;
}
// 2. Resolve the dedicated MIDI channel for the armed track
const targetChannel = getDedicatedMIDIChannel(armedTrackIndex);
// 3. Route live Note On / Note Off messages to the resolved target channel
if (command === 0x9 && velocityByte > 0) {
soundEngine.noteOn(targetChannel, pitch, velocityByte / 127.0);
} else if (command === 0x8 || (command === 0x9 && velocityByte === 0)) {
soundEngine.noteOff(targetChannel, pitch);
}
}
```
---
## 3. STANDARD AUDIO ROUTING MATRIX
| Object / Criteria | Track 1 (Violin) | Track 2 (Piano) |
| --- | --- | --- |
| **ARM State** | 🔴 ARMED (ON) | ⚪ DISARMED (OFF) |
| **Assigned MIDI Channel** | `Channel 0` | `Channel 1` |
| **Synth Command** | `selectInstrument(ch=0, bank=0, prog=40)` | `selectInstrument(ch=1, bank=0, prog=0)` |
| **Live Keyboard Source** | Keypress on SE49 $\rightarrow$ `noteOn(ch=0, pitch, vel)` | Does not receive live key events |
| **Timeline Play Source** | Emits notes from Item 1 $\rightarrow$ `noteOn(ch=0)` | Emits notes from Item 2 $\rightarrow$ `noteOn(ch=1)` |
| **Audio Output Result** | Smooth Violin output | Simultaneous Piano output without voice overriding |
---
## 4. VERIFICATION & BUG FIX CHECKLIST
* [ ] Console logs on project load confirm that Track 1 and Track 2 reside on separate channels (`Channel 0` and `Channel 1`).
* [ ] Arming Track 1 $\rightarrow$ Playing keys on SE49 outputs Track 1's instrument sound.
* [ ] Pressing Timeline Play $\rightarrow$ Track 2 outputs its assigned instrument sound on `Channel 1` in parallel with Track 1.
+185
View File
@@ -1,9 +1,33 @@
### [2026-07-28 07:56] Task: Fix PIANO_ROLL activeTracks + instrument auto-assign trong SECTION-TAB
- **Tóm tắt thay đổi:** Fix `activeTracks` useMemo: khi `activeTab` là PIANO_ROLL sub-tab từ SECTION-TAB (`parent_tab_id` bắt đầu `session_`), trả về tracks của section tab thay vì main tracks. Trước đây `activeTracks` luôn trả về main tracks khi activeTab !== session_xxx → dropdown MIDI item rỗng, track name không hiển thị, instrument lookup sai. Fix: check `subTabs` cho PIANO_ROLL type để resolve đúng parent session tracks.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass. Cần restart server + clear browser cache.
---
### [2026-07-28 07:47] Task: Sync MIDI item editing + ghost notes từ MAIN vào SECTION-TAB
- **Tóm tắt thay đổi:** Fix `handleEditSectionInTab` xoá `midiItems: []` khi clone section tracks. SECTION-TAB giờ giữ nguyên MIDI items từ section data → dropdown MIDI item switcher hoạt động, ghost notes extract từ các track khác trong section, instrument settings được preserve. Piano Roll Tab dùng chung component `PianoRollTabEditor` nên tất cả tính năng (ghost, session sync, CC lane, export, AI) đều hoạt động trên cả MAIN và SECTION-TAB.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` — build passes. Cần lưu section trước (handleSaveSectionTab) để tích luỹ midiItems vào section.tracks, sau đó mở lại section tab để kiểm tra ghost notes + MIDI dropdown.
---
### [2026-07-28 07:13] Task: Instrument selector modal — 2 cột + font 14px
- **Tóm tắt thay đổi:** Mở rộng modal `max-w-md``max-w-4xl`. Font size từ `text-[10px]`/`text-xs``text-sm` (14px). Bố cục 2 cột: trái danh sách soundfont, phải instruments của SF đang chọn. Click SF trái → lọc instrument phải. Nút "All Instruments" để xem tất cả. Search filter cả 2 cột.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` — build passes. Synth button → modal 2 cột → chọn SF trái → instrument phải.
---
### [2026-07-26 18:05] Task: Fix SpessaSynth CDN 404 + AudioWorklet init
- **Tóm tắt thay đổi:** Sửa CDN URL từ `@latest/dist/spessasynth_lib.js` (404) → `@4.3.1/dist/index.js` (200). Dùng `WorkletSynthesizer` class + `audioWorklet.addModule(processor.min.js)`, `soundBankManager.addSoundBank()`, `await isReady`. Fallback WorkerSynthesizer nếu AudioWorklet không khả dụng.
- **Các file ảnh hưởng:** `app/templates/index.html`, `app/static/js/services/soundfontPlayer.js`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** SpessaSynth CDN load OK (200). Cần kiểm tra browser console.
---
### [2026-07-27 16:16] Task: Tăng chiều cao + fix phím mũi tên cho Copilot prompt + fix Babel build
- **Tóm tắt thay đổi:** Tăng rows từ 2 → 4, thêm resize-y. Sửa ArrowUp/ArrowDown: chỉ kích hoạt lịch sử khi con trỏ ở đầu/cuối text. Fix lỗi dư `}` tại line 6795 khiến Babel build failed (pre-existing). Build thành công qua Babel, precompiled.js được sinh ra tự động.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Kiểm tra prompt input rows=4, resize-y, phím mũi tên di chuyển con trỏ trong multi-line text. Chạy `npm run build` để kiểm tra.
---
### [2026-07-26 20:15] Task: Stabilize client preview (oscillator + FluidSynth server)
- **Tóm tắt thay đổi:** Kết luận sau debug: SpessaSynth parser (fromArrayBuffer) không extract được presets từ SF2. Client luôn dùng oscillator (ADSR theo program). Server-side FluidSynth render cho âm thanh chính xác khi Export. Xoá debug logs, clean code.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/api/v1/plugins.py`, `app/static/js/app.precompiled.js`
@@ -445,7 +469,168 @@
- **Ghi chú/Test (nếu có):** ARM 2 track cùng SF khác instrument → cả 2 play. Đổi SF → noteOff vẫn tìm đúng voice.
---
### [2026-07-27 16:05] Fix: program_select thay bank_select+program_change (SF preset search order)
- **Tóm tắt thay đổi:** `bank_select` + `program_change` dùng search order qua tất cả SF đã load. Khi track 1 load SF2, preset lookup trên channel 0 tìm thấy preset của SF2 thay vì SF1 (SF2 là last-loaded → priority). Fix: `fluid_synth_program_select(synth, ch, sfHandle, bank, prog)` chỉ định rõ SoundFont ID, không phụ thuộc search order. Track 2 giữ nguyên instrument của SF1.
- **Các file ảnh hưởng:** `soundfontPlayer.js`
- **Ghi chú/Test (nếu có):** Track 1 SF1→SF2, track 2 SF1 → track 2 không bị ảnh hưởng.
---
### [2026-07-27 12:15] Task: Fix SpessaSynth loop voice not releasing (layer 2)
- **Tóm tắt thay đổi:** CC 120 vẫn không đủ vì SpessaSynth 4.3.1 AudioWorklet có bug: looped voices trong MIDI message pipeline xử lý CC 120 sai (`processMessage`). Fix: thêm `noteOn(ch, pitch, 0)` (MIDI noteOff alternate path) + `_synthInstance.post({channelNumber:ch, type:"stopAll", data:1})` gửi lệnh trực tiếp đến worklet qua `handleMessage` — bypass hoàn toàn MIDI pipeline.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Cần clear cache browser (index.html cache-bust param updated).
---
### [2026-07-27 17:07] Task: MIDI Ghost Notes + Dropdown Item Switcher + Session Sync Mode
- **Tóm tắt thay đổi:** Thêm ghost notes cho Piano Roll tab: tất cả MIDI items không được chọn thành ghost notes (25% opacity, dashed border). Dropdown thay thế tab title để chuyển nhanh MIDI item đang edit. Hai chế độ xem: Session Sync (ghost visible, bar labels aligned với session) và Isolated (bar 0, no ghost).
- **Các file ảnh hưởng:** `app/static/js/services/ghostNoteExtractor.js`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** Kiểm tra dropdown list đúng tất cả MIDI items. Switch item → ghost notes của item cũ hiện ra. Nút 🌐 Session/📋 Isolated chuyển chế độ. 👻 Ghost toggle chỉ hoạt động ở Session mode.
### [2026-07-28 07:03] Task: Auto-scan soundfont cho Synth button
- **Tóm tắt thay đổi:** Thêm `SoundFontAutoScanner` — background daemon thread quét thư mục `/opt/daw_engine/soundfonts``app/storage/soundfonts` mỗi 30s. Dùng `sf_scan_state.json` để track file đã quét (theo size+mtime), chỉ inspect file mới/thay đổi. Catalog dùng scanner cache thay vì regenerate từ đầu mỗi request. `/soundfonts/catalog` API dùng scanner catalog. Upload/delete trigger scan tức thì. Thêm `.gitignore` cho `*.db``sf_scan_state.json`.
- **Các file ảnh hưởng:** `app/core/soundfont_scanner.py` (NEW), `app/api/v1/plugins.py`, `app/core/soundfont_inspector.py`, `app/main.py`, `.gitignore`
- **Ghi chú/Test (nếu có):** `python3 -c "from app.core.soundfont_scanner import SoundFontAutoScanner; s=SoundFontAutoScanner(); print(s.scan_once())"` — scan hoạt động. Condensed catalog dùng scanner's full_catalog tránh duplicate scan.
---
- **Tóm tắt thay đổi:** Verify 4 rules: (1) MIDI item nhận instrument từ track cha qua `handleSwitchMidiItem` copy `instrumentProgram` từ track → sub-tab; (2) Section item KHÔNG nhận instrument (reset về null khi clone tracks trong `handleEditSectionInTab`); (3) Track khác không bị ảnh hưởng do `setTrackInstrumentWithProgram` filter by `trackId`; (4) Đổi instrument trong PianoRoll tab → áp dụng ngược lại cho track qua `openInstrumentSelector``setTrackInstrumentWithProgram`. Tất cả đều OK, không bug.
- **Các file ảnh hưởng:** `app/static/js/app.jsx` (verify lines 4642-4667, 5905, 6763-6807, 8422-8424), `app/core/render_engine.py` (verify lines 95-115)
- **Ghi chú/Test (nếu có):** No code changes needed. All 4 rules confirmed working.
---
### [2026-07-27 20:37] Task: Fix cross-track instrument interference (missed channel param)
- **Tóm tắt thay đổi:** Sửa 6 vị trí gọi `SonicSF.playNote()` thiếu tham số `channel` + `synthEngine`, khiến FluidSynth mặc định `ch=0` cho mọi track → `program_change` trên channel 0 đè lên nhau giữa các track. Fix: thêm channel computation (từ `track.midiChannel` hoặc track index) + truyền `synth_engine` vào `playNote` tại `startTrackPlayback`, section sub-track MIDI, `playMidiPreviewNote`, piano roll Alt+scroll preview, note click preview, keybed onMouseDown/onMouseEnter. `setTrackInstrumentWithProgram` đã filter đúng `trackId` — lỗi không nằm ở logic set instrument mà ở playback.
- **Các file ảnh hưởng:** `app/static/js/app.jsx` (lines 4846, 5121, 5661-5671, 10117-10124, 10179-10201, 10285-10307)
- **Ghi chú/Test (nếu có):** `npm run build` pass. Cần test: main session play 2 tracks MIDI khác instrument → mỗi track giữ instrument riêng. Piano Roll preview note → không ảnh hưởng track khác.
---
### [2026-07-27 20:44] Task: Apply md/41_INSTRUMENT.md spec to codebase
- **Tóm tắt thay đổi:** Implement spec: (1) Tạo `pianoRollTabService.js` với `getParentTrackByItemId`, `buildActiveScope`, `getTrackMidiChannel`; (2) Thêm `parent_track_id` vào tất cả MIDI items creation (deserialize, recording, AI, API); (3) `handleSwitchMidiItem` dùng `buildActiveScope` pattern — thêm `active_scope` vào sub-tab state; (4) Toolbar Piano Roll hiển thị `(Belongs to: Track X)``Synth (Track X): Instrument`; (5) Đồng bộ channel assignment về `trackIndex % 16` (spec §4); (6) Toàn bộ `playNote` calls dùng `SonicPianoRoll.getTrackMidiChannel` thay for-loop channel compute.
- **Các file ảnh hưởng:** `app/static/js/services/pianoRollTabService.js` (NEW), `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`, `app/templates/index.html`
- **Ghi chú/Test (nếu có):** `npm run build` pass. Cần test: multi-item piano roll switch item → scope cập nhật đúng. Parent track label xuất hiện trên toolbar.
---
### [2026-07-27 21:25] Task: Fix _playNoteFluid else branch leaking cross-track SF
- **Tóm tắt thay đổi:** Khi track chưa set instrument, `_playNoteFluid` gọi `program_change(ch, 0)`. FluidSynth search program 0 trong ALL loaded SoundFonts → nếu chỉ có SoundFont của Track 2 được load, program 0 của SF đó gán cho channel của Track 1. Fix: bỏ `program_change`, dùng oscillator fallback trực tiếp. Track không instrument → sine wave, không leak instrument từ track khác.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass. Cần test: Track 2 set instrument, Track 1 không set → Track 1 play sine wave (không phải SF program 0 của Track 2).
---
### [2026-07-27 21:29] Task: Fix multi-track ARM only first track produces sound
- **Tóm tắt thay đổi:** ONMIDIMESSAGE handler (`app.jsx:6954-6998`) dùng raw MIDI hardware channel (`msg.data[0] & 0x0F`) thay vì dedicated channel của track, và `find(t => t.isArmed)` chỉ lấy track ARM đầu tiên. Fix: dùng `filter(t => t.isArmed)` + `getTrackMidiChannel` cho từng track — MIDI Note On/Off, CC, Pitch Bend đều route tới ALL armed tracks trên dedicated channel của mỗi track.
- **Các file ảnh hưởng:** `app/static/js/app.jsx` (lines 6960-7040), `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass. Cần test: ARM cả 2 track, bấm phím MIDI → cả 2 track đều phát ra instrument riêng. NoteOff gửi đúng channel.
---
### [2026-07-27 21:37] Task: Ctrl+drag on ruler for global selection
- **Tóm tắt thay đổi:** `handleRulerMouseDown` cũ khi `e.ctrlKey` chỉ clear selection rồi return. Fix: Ctrl+click trên ruler khởi tạo `selectionMode='global'`, set `rulerDragStartRef``isDraggingRulerRef=true` → document mousemove handler (đã có sẵn) cập nhật selection range, selection overlay (đã render sẵn) hiển thị vùng chọn.
- **Các file ảnh hưởng:** `app/static/js/app.jsx` (line 11133-11142), `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass. Cần test: Ctrl+drag trên ruler → selection overlay xuất hiện. Shift+click vẫn extend selection.
---
### [2026-07-27 21:47] Task: Ctrl+drag sweep select + multi-item drag/copy
- **Tóm tắt thay đổi:** (1) Ctrl+drag trên empty space (track lane) → sweep select (vùng chọn màu amber), mouseup → chọn tất cả items chạm vùng sweep. (2) Selected items highlight (viền vàng dày). (3) Ctrl+drag trên section/MIDI item → duplicate item. (4) Drag trên selected item → move cả nhóm items đã chọn. (5) Ctrl+drag trên selected item → copy cả nhóm.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** Cần test: Ctrl+drag empty space → sweep overlay + selected items highlight. Click+drag selected item → cả nhóm move. Ctrl+drag selected item → copy nhóm.
---
### [2026-07-28 08:09] Task: Fix Ctrl+Click+Drag không chọn item trong SECTION-TAB
- **Tóm tắt thay đổi:** Xoá toggle selection (select/deselect) khỏi Ctrl+Click handler cho section/MIDI items. Trước đây Ctrl+Click vừa chọn item vừa set pending drag → item bị khoá cứng (selected), không thể kéo copy. Fix: Ctrl+Click chỉ set pending drag để copy, không chạm selection state.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass. Ctrl+Click+Drag section/MIDI item → copy đến vị trí mới, item không bị selected.
---
### [2026-07-28 10:10] Task: Ctrl+Click vùng trống không sweep select, chỉ deselect
- **Tóm tắt thay đổi:** Thay `onSweepSelectStart` bằng `onClearSelection` trong Ctrl+Click vùng không có items. Trước đây gọi `handleSweepSelectStart` → yellow cursor + mousemove chọn items. Fix: `onClearSelection = () => setSelectedItemIds(new Set())` — chỉ clear selection, không sweep.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass. Ctrl+click vùng trống → deselect hết, không yellow cursor.
---
### [2026-07-28 10:06] Task: Ctrl+S trong SECTION-TAB chỉ lưu section, không export SF
- **Tóm tắt thay đổi:** Di chuyển SECTION-TAB save check từ handler thứ 2 (dead code, shadowed bởi export handler) lên handler đầu tiên. Line 8066: nếu `activeTab.startsWith('session_')``handleSaveSectionTab`, else → `handleExportSFS`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass. SECTION-TAB Ctrl+S → lưu section. MAIN Ctrl+S → export SF.
---
### [2026-07-28 09:58] Task: Ctrl+S lưu section, Ctrl+A chọn tất cả items
- **Tóm tắt thay đổi:** Thêm `activeTab.startsWith('session_')` branch trong Ctrl+S handler → gọi `handleSaveSectionTab`. Thêm Ctrl+A handler: collect tất cả sections/midiItems/clips từ `activeTracksRef.current` vào `selectedItemIds`. Hoạt động trên MAIN SESSION và SECTION-TAB.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass. SECTION-TAB Ctrl+S → lưu section. Ctrl+A → chọn tất cả items.
---
### [2026-07-28 09:55] Task: Remove Insert Section từ context menu trong SECTION-TAB
- **Tóm tắt thay đổi:** Thêm `!sessionTabs.some(s => s.id === activeTab)` cho "Insert Section" button trong JSX context menu. Section không thể chứa section item. Data-driven menu (line 14905) đã có check này.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass. Right-click trong SECTION-TAB → không thấy "Insert Section".
---
### [2026-07-28 09:50] Task: Program change ở note time, không phải call time
- **Tóm tắt thay đổi:** Di chuyển `program_change`/`program_select`/`bank_select` từ synchronous (call time) vào trong `doNote` (note time via setTimeout) trong `_playNoteFluid`. Trước đây tất cả program_changes xảy ra ngay khi `startTrackPlayback` chạy → MIDI items processed trước sections → section's program_change override cuối cùng → ALL notes (section + MIDI) play với section instrument. Fix: mỗi note có program_change tại đúng thời điểm của nó.
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`
- **Ghi chú/Test (nếu có):** load. TRACK 1: | SECTION ITEM (inst B) | MIDI ITEM (inst A) | → section plays inst B, MIDI item plays inst A.
---
### [2026-07-28 09:30] Task: Thêm _isSectionClone flag — tắt âm thanh mặc định trong SECTION-TAB
- **Tóm tắt thay đổi:** Thêm `_isSectionClone: true` vào section cloned tracks. Line 10283 kiểm tra flag: nếu section clone và không instrument → `undefined` (silent); nếu MAIN track và không instrument → `0` (GM Piano). Trước đây section cloned tracks đi qua line 10283 với `instrumentProgram: undefined` → default 0 → play GM Piano.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass. SECTION-TAB không instrument → im lặng. MAIN track không instrument → GM Piano.
---
### [2026-07-28 09:27] Task: Silent fallback — không nghe âm thanh mặc định
- **Tóm tắt thay đổi:** Thay oscillator fallback bằng silent return trong `_playNoteFluid` (soundfontPlayer.js:395). Khi không có instrument data, không play bất kỳ âm thanh nào (cả oscillator lẫn FluidSynth).
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js`
- **Ghi chú/Test (nếu có):** Section sub-track MIDI items không instrument → im lặng. MAIN track MIDI items không instrument → GM Piano (program 0).
---
### [2026-07-28 09:25] Task: Restore program default 0 cho MAIN track MIDI playback
- **Tóm tắt thay đổi:** Restore line 10283 `instrumentProgram !== undefined ? instrumentProgram : 0` — MAIN track MIDI items cần program 0 để play (GM Piano) khi không load instrument. Chỉ section sub-track (line 10393) giữ `subTrack.instrumentProgram` (undefined → oscillator fallback) để tránh load MAIN SoundFont.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass. MAIN track MIDI items play GM Piano khi không instrument. Section sub-track MIDI items dùng oscillator fallback.
---
### [2026-07-28 09:21] Task: Revert fix playhead — gây lỗi không play được
- **Tóm tắt thay đổi:** Revert 2 changes từ 5d31563: (1) xoá `requestAnimationFrame(updatePlayhead)` thừa trong `handlePlayPause` (effect đã xử lý). (2) xoá `setCurrentTime(0)` trong `handleEditSectionInTab` (gây reset currentTime global). Giữ nguyên các fix instrument isolation + program default.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass. Cần kiểm tra Play trên MAIN và SECTION-TAB.
---
### [2026-07-28 09:40] Task: Cả main + section MIDI playback không default program 0
- **Tóm tắt thay đổi:** Thay đổi line 10283 giống line 10393: `instrumentProgram !== undefined ? ... : 0``instrumentProgram`. Trước đây main playback cũng default program 0 khi `instrumentProgram` undefined → `_playNoteFluid` gọi `program_change(ch,0)` trên FluidSynth → load program 0 của MAIN SoundFont. Fix: undefined → oscillator fallback.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass. MIDI items không instrument → oscillator fallback (không load SoundFont). MIDI items có instrument → FluidSynth play đúng program.
---
### [2026-07-28 09:37] Task: Section sub-track MIDI playback không default program 0
- **Tóm tắt thay đổi:** Line 10393 `subTrack.instrumentProgram !== undefined ? subTrack.instrumentProgram : 0``subTrack.instrumentProgram`. Trước đây khi `instrumentProgram` undefined (section clone), program default 0 → `_playNoteFluid` gọi `program_change(ch, 0)` trên FluidSynth → load program 0 của MAIN session's SoundFont. Fix: undefined program → `_playNoteFluid` dùng oscillator fallback (không load SoundFont).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass. Play MAIN session → section MIDI items không load SoundFont. User load instrument trong section tab → program set → FluidSynth play đúng.
---
### [2026-07-28 08:49] Task: Revert Synth button ẩn — track có thể chứa cả section + MIDI
- **Tóm tắt thay đổi:** Revert conditional Synth button (815a6fa). Track có section item con VÀ midi item con → Synth vẫn hiển thị. Instrument chỉ áp dụng cho MIDI item, không ảnh hưởng section item (được xử lý bởi data isolation fix trước đó: section clone null hết soundfont fields).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass. Track sections + midiItems → Synth hiển thị, instrument chỉ tác động midiItems.
---
### [2026-07-28 08:36] Task: Synth chỉ hiển thị cho MIDI track, không cho section/audio track
- **Tóm tắt thay đổi:** Synth button trong TCP giờ conditional: chỉ hiển thị khi track có midiItems (>0) và KHÔNG có sections. FX button giữ nguyên cho mọi track type. Logic đúng: Synth áp dụng cho MIDI item, FX áp dụng cho section/MIDI/audio clip item.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass. Track có sections → ẩn Synth. Track MIDI → hiện Synth. Track audio → ẩn Synth. FX hiện ở mọi track.
---
### [2026-07-28 08:31] Task: Section track auto-load soundfont 1/prog 0 từ MAIN qua `...t` spread
- **Tóm tắt thay đổi:** `...t` trong fallback branch spread `soundfont_id`, `soundfont_bank`, `soundfont_program`, `instrument_source` từ parent MAIN track. Dù đã null `instrumentProgram``synth_engine`, các field `soundfont_*` vẫn khiến FluidSynth load soundfont khi play. Fix: override tất cả soundfont fields thành null/undefined.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass. MAIN loaded SF1/prog5 → mở section tab → section tracks không có SF.
---
### [2026-07-28 08:27] Task: KHÔNG auto-load instrument MAIN vào SECTION-TAB track
- **Tóm tắt thay đổi:** Revert kế thừa instrument từ parent MAIN track. Section tracks giữ `instrumentId: null, instrumentProgram: undefined, instrumentName: null, synth_engine: undefined`. Trước đây kế thừa instrument (f2ac70e) → SECTION-TAB auto-load program 0 của SoundFont từ MAIN. User tự chọn instrument trong SECTION-TAB qua Synth button (đã hỗ trợ qua `updateActiveTracks` routing).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass. Load instrument MAIN → mở section tab → section tracks KHÔNG có instrument.
---
### [2026-07-28 08:12] Task: Sync instrument load methods từ MAIN vào SECTION-TAB PIANO_ROLL
- **Tóm tắt thay đổi:** Fix `updateActiveTracksRef` — khi `activeTab` là PIANO_ROLL sub-tab có `parent_tab_id` trỏ đến SECTION-TAB, routing instrument update vào `setSessionTabs` thay vì `setTracks` (main). Trước đây đổi instrument trong PIANO_ROLL của SECTION-TAB ghi vào main tracks (vô hiệu). Cùng logic với `activeTracks` useMemo fix trước đó.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
- **Ghi chú/Test (nếu có):** `npm run build` pass. Mở SECTION-TAB → PIANO_ROLL → Synth → chọn instrument → verify instrument được áp dụng cho section track (không phải main track).
---