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
This commit is contained in:
Binary file not shown.
@@ -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.
|
||||||
Reference in New Issue
Block a user