Compare commits
91 Commits
47d1a14f50
...
ae71acef0f
| Author | SHA1 | Date | |
|---|---|---|---|
| ae71acef0f | |||
| f0b62d5a1c | |||
| f84431c9a6 | |||
| b5f3f987b7 | |||
| 5ec329933a | |||
| aafc750274 | |||
| 62a41d4502 | |||
| fa7ffe88bf | |||
| 385d62cfaf | |||
| f4a364df51 | |||
| 32b61553f9 | |||
| 67551075be | |||
| fd31e8051d | |||
| 43691a127e | |||
| 6fa6578dd1 | |||
| 2dec94aa7e | |||
| ea40f0c540 | |||
| 5d31563df6 | |||
| 2f2f84066c | |||
| 52ec9379d6 | |||
| 9c2fa52e1b | |||
| 815a6faaca | |||
| 9befc20851 | |||
| 18e177fa2d | |||
| f2ac70eee0 | |||
| ed51cab765 | |||
| 447504ea67 | |||
| e4d6405b37 | |||
| 2baf034852 | |||
| d40ac47d65 | |||
| 14e3a561a3 | |||
| d0def4bf1e | |||
| e68fe27d7f | |||
| 1dbece1fc7 | |||
| 1ca2ec6d01 | |||
| 292c787a31 | |||
| b84507bd64 | |||
| 0992879b49 | |||
| 74fdc2b303 | |||
| c99527e687 | |||
| e055e8b5c8 | |||
| 85ad22ee4f | |||
| b8dfc9b211 | |||
| fd24c2dcd8 | |||
| 80a5352645 | |||
| 4b732844c9 | |||
| 430e87445e | |||
| 74992f7b55 | |||
| 8beef7cb4d | |||
| 08cd4d72cf | |||
| 081a17d537 | |||
| 6dec171af7 | |||
| e38391ac7d | |||
| b42190e6bc | |||
| 40502eda86 | |||
| f429f6b1d3 | |||
| 912fb2d354 | |||
| 59cf1d67db | |||
| 0420eccb82 | |||
| b5344e41f7 | |||
| 8c5d71c2f0 | |||
| 4a9cbcdef1 | |||
| e14554a75a | |||
| a60607c673 | |||
| c9176a27fa | |||
| db4bdd2471 | |||
| 60141cef3f | |||
| 9c0909c3fa | |||
| 6ff2f72c58 | |||
| ab4b91a158 | |||
| 1628a34ea0 | |||
| db45efe53c | |||
| fd23b8c4b0 | |||
| 1d3c7a7955 | |||
| 3ed62b3343 | |||
| 3a775fdda7 | |||
| 509e171c20 | |||
| 0500d16bc7 | |||
| a98dd58744 | |||
| 1ba9e8379f | |||
| b9113f8a06 | |||
| 91ddc3cb6f | |||
| fb0cd248ad | |||
| d21091b69b | |||
| 53a27b5af4 | |||
| 035d75e504 | |||
| c4a320a1c1 | |||
| 095a0680e8 | |||
| 4ed0b1e5be | |||
| 29206d62a3 | |||
| 57235e68d2 |
@@ -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
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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", [])
|
||||
|
||||
@@ -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 []
|
||||
@@ -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
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;
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -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.
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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` và `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` và `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)` và `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` và `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` và `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).
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user