diff --git a/app/storage/sonicforge.db b/app/storage/sonicforge.db index bbd260a..121e539 100644 Binary files a/app/storage/sonicforge.db and b/app/storage/sonicforge.db differ diff --git a/md/41_INSTRUMENT.md b/md/41_INSTRUMENT.md new file mode 100644 index 0000000..2b1459a --- /dev/null +++ b/md/41_INSTRUMENT.md @@ -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. \ No newline at end of file diff --git a/md/42_DOUBLEINST.md b/md/42_DOUBLEINST.md new file mode 100644 index 0000000..259263f --- /dev/null +++ b/md/42_DOUBLEINST.md @@ -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. \ No newline at end of file