From 8beef7cb4deae81dd50e9f61c780f60da7e1315a Mon Sep 17 00:00:00 2001 From: 3dtours Date: Mon, 27 Jul 2026 21:31:10 +0700 Subject: [PATCH] fix: multi-track ARM routes MIDI to all armed tracks - Use filter(t => t.isArmed) instead of find() to route MIDI input to ALL armed tracks simultaneously - Use getTrackMidiChannel per track instead of raw MIDI hardware channel (msg.data[0] & 0x0F) - NoteOff, CC, PitchBend also routed to each armed track's dedicated channel - Previous code sent to channel 0 + first armed track only --- app/static/js/app.jsx | 81 ++++++++++++++++++++++---------- app/static/js/app.precompiled.js | 16 ++++--- wiki.md | 6 +++ 3 files changed, 70 insertions(+), 33 deletions(-) diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 5458790..5134507 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -6954,7 +6954,7 @@ const App = () => { input.onmidimessage = msg => { console.log(`[DevLog] [Raw MIDI] Received from "${input.name}" (ID: ${input.id}) - Data:`, Array.from(msg.data)); if (msg.data.length < 3) return; - const cmd = msg.data[0] >> 4; + const cmd = msg.data[0] >> 4; const pitch = msg.data[1]; const velocity = msg.data[2]; if (cmd === 0x9 && velocity > 0) { @@ -6962,25 +6962,30 @@ const App = () => { setLastMidiNote({ pitch, velocity, length: 0, time: Date.now() }); activeMidiPitchesRef.current.add(pitch); setActiveMidiPitches(new Set(activeMidiPitchesRef.current)); - // Stop previous notes on this channel before playing new note - // Only send All Notes Off when sustain pedal is not active - if (window.SonicSF) { - const ch = msg.data[0] & 0x0F; - if (!window.SonicSF.sustainActive || !window.SonicSF.sustainActive(ch)) { - try { window.SonicSF.controllerChange(ch, 123, 0); } catch (e) {} - } - const arSub = subTabsRef && subTabsRef.current && activeTabRef && subTabsRef.current.find(s => s.id === activeTabRef.current && s.type === 'PIANO_ROLL' && s.isArmed); - if (arSub) { - window.SonicSF.playNote(pitch, velocity, 60000, undefined, arSub.instrumentProgram, null, ch, arSub.synth_engine); - } else { - const armedTrack = activeTracksRef.current ? activeTracksRef.current.find(t => t.isArmed) : null; - if (armedTrack) { - const prog = armedTrack.instrumentProgram; - const se = armedTrack.synth_engine; - const dest = activeTrackNodesRef.current[armedTrack.id]?.gainNode || null; - window.SonicSF.playNote(pitch, velocity, 60000, undefined, prog, dest, ch, se); - } + // Route MIDI input to ALL armed tracks on their dedicated channels + // Do NOT use raw MIDI hardware channel (msg.data[0] & 0x0F) + if (window.SonicSF) { + var allTracks = activeTracksRef.current || []; + var arSubs = subTabsRef && subTabsRef.current ? subTabsRef.current.filter(function(s) { return s.type === 'PIANO_ROLL' && s.isArmed; }) : []; + var armedTracks = allTracks.filter(function(t) { return t.isArmed; }); + // Piano Roll arming has priority: route to each armed sub-tab's parent track + if (arSubs.length > 0) { + arSubs.forEach(function(as) { + var asTrk = allTracks.find(function(t) { return t.id === as.trackId; }); + var asCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(asTrk, allTracks) : (asTrk ? asTrk.midiChannel : 0); + var asProg = as.instrumentProgram; + var asSe = as.synth_engine; + window.SonicSF.playNote(pitch, velocity, 60000, undefined, asProg, null, asCh, asSe); + }); } + // Route to ALL armed tracks (not just the first one) + armedTracks.forEach(function(at) { + var atCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(at, allTracks) : (at.midiChannel !== undefined ? at.midiChannel : 0); + var atProg = at.instrumentProgram; + var atSe = at.synth_engine; + var atDest = activeTrackNodesRef.current[at.id]?.gainNode || null; + window.SonicSF.playNote(pitch, velocity, 60000, undefined, atProg, atDest, atCh, atSe); + }); } } else if (cmd === 0x8 || (cmd === 0x9 && velocity === 0)) { activeMidiPitchesRef.current.delete(pitch); @@ -6991,29 +6996,53 @@ const App = () => { lastMidiNoteRef.current = { ...current, length: lenSec }; setLastMidiNote(prev => prev && prev.pitch === pitch ? { ...prev, length: lenSec, time: Date.now() } : prev); } - // Stop the note immediately via SpessaSynth + // Stop the note on ALL armed tracks' dedicated channels if (window.SonicSF && window.SonicSF.stopNote) { - const ch = msg.data[0] & 0x0F; - window.SonicSF.stopNote(ch, pitch); + var stopTracks = activeTracksRef.current || []; + stopTracks.forEach(function(st) { + if (!st.isArmed) return; + var stCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(st, stopTracks) : (st.midiChannel !== undefined ? st.midiChannel : 0); + window.SonicSF.stopNote(stCh, pitch); + }); } } // ── Sustain (CC64), Modulation (CC1), Pitch Bend ── const midiCh = msg.data[0] & 0x0F; if (cmd === 0xB) { - // Controller Change: forward all CCs to SpessaSynth + // Controller Change: forward to ALL armed tracks' dedicated channels const cc = msg.data[1]; const val = msg.data[2]; if (window.SonicSF && window.SonicSF.controllerChange) { - window.SonicSF.controllerChange(midiCh, cc, val); + var ccTracks = activeTracksRef.current || []; + var hasArmed = ccTracks.some(function(t) { return t.isArmed; }); + if (hasArmed) { + ccTracks.forEach(function(ct) { + if (!ct.isArmed) return; + var ctCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(ct, ccTracks) : (ct.midiChannel !== undefined ? ct.midiChannel : 0); + window.SonicSF.controllerChange(ctCh, cc, val); + }); + } else { + window.SonicSF.controllerChange(midiCh, cc, val); + } } } else if (cmd === 0xE) { - // Pitch Bend: 14-bit value (LSB + MSB) + // Pitch Bend: forward to ALL armed tracks' dedicated channels const lsb = msg.data[1]; const msb = msg.data[2]; const bendVal = (msb << 7) | lsb; if (window.SonicSF && window.SonicSF.pitchBend) { - window.SonicSF.pitchBend(midiCh, bendVal); + var pbTracks = activeTracksRef.current || []; + var hasArmedPB = pbTracks.some(function(t) { return t.isArmed; }); + if (hasArmedPB) { + pbTracks.forEach(function(pt) { + if (!pt.isArmed) return; + var ptCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(pt, pbTracks) : (pt.midiChannel !== undefined ? pt.midiChannel : 0); + window.SonicSF.pitchBend(ptCh, bendVal); + }); + } else { + window.SonicSF.pitchBend(midiCh, bendVal); + } } } diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index ae40676..4e66b0c 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -187,13 +187,15 @@ const cachedSf=(instrumentSelectorData?.soundfonts||[]).find(s=>s.id===instrumen const[snapValue,setSnapValue]=useState('free');// 'free', '1', '1/2', '1/4', '1/8', '1/16', '1/32' const snapTime=(time,snapVal,bpmVal)=>{if(snapVal==='free')return time;const beatDuration=60/parseFloat(bpmVal||120);let divisor=1;if(snapVal==='4')divisor=4;else if(snapVal==='1')divisor=1;else if(snapVal==='1/2')divisor=0.5;else if(snapVal==='1/4')divisor=0.25;else if(snapVal==='1/8')divisor=0.125;else if(snapVal==='1/16')divisor=0.0625;else if(snapVal==='1/32')divisor=0.03125;const gridSpacing=beatDuration*divisor;return Math.round(time/gridSpacing)*gridSpacing;};const snapValueRef=useRef(snapValue);snapValueRef.current=snapValue;const bpmRef=useRef(bpm);bpmRef.current=bpm;// BMP for Tempo Track - LOOP_EDITOR_2.md §6 const[selectedTrackId,setSelectedTrackId]=useState('1');const[currentTime,setCurrentTime]=useState(0);const[isPlaying,setIsPlaying]=useState(false);const[selectionStart,setSelectionStart]=useState(null);const[selectionEnd,setSelectionEnd]=useState(null);const[selectionFollowsTempo,setSelectionFollowsTempo]=useState(true);const selectionRef=useRef({start:null,end:null});selectionRef.current={start:selectionStart,end:selectionEnd};const[selectionMode,setSelectionMode]=useState(null);// 'global' (from ruler) | 'local' (from track) -const[localSelectionTrackId,setLocalSelectionTrackId]=useState(null);const[localSelectionStart,setLocalSelectionStart]=useState(null);const[localSelectionEnd,setLocalSelectionEnd]=useState(null);const[zoom,setZoom]=useState(100);const[isLoopingSelection,setIsLoopingSelection]=useState(false);const[beginBar,setBeginBar]=useState(0);const[endBar,setEndBar]=useState(0);const[numberBar,setNumberBar]=useState(1);const[subTabHeight,setSubTabHeight]=useState(96);const[isExporting,setIsExporting]=useState(false);const[projectName,setProjectName]=useState(()=>localStorage.getItem('sonic_project_name')||'');const[currentProjectId,setCurrentProjectId]=useState(()=>localStorage.getItem('sonic_project_id')||null);const[saveProjectModalOpen,setSaveProjectModalOpen]=useState(false);const[saveAsModalOpen,setSaveAsModalOpen]=useState(false);const soloedTrack=tracks.find(t=>t.solo);const soloedTrackId=soloedTrack?soloedTrack.id:null;const[toastMessage,setToastMessage]=useState(null);const[audioDevices,setAudioDevices]=useState([]);const[midiDevices,setMidiDevices]=useState([]);const[selectedMidiInputId,setSelectedMidiInputId]=useState('');const handleMidiInputSelect=id=>{setSelectedMidiInputId(id);if(window.SonicRecorderManager){window.SonicRecorderManager.setSelectedMidiInputId(id);}};useEffect(()=>{if(navigator.mediaDevices&&navigator.mediaDevices.enumerateDevices){navigator.mediaDevices.enumerateDevices().then(devices=>{setAudioDevices(devices.filter(d=>d.kind==='audioinput'));}).catch(err=>console.log('Enumerate audio devices error:',err));}if(navigator.requestMIDIAccess){navigator.requestMIDIAccess().then(access=>{const inputs=[];for(let input of access.inputs.values()){inputs.push(input);input.onmidimessage=msg=>{console.log(`[DevLog] [Raw MIDI] Received from "${input.name}" (ID: ${input.id}) - Data:`,Array.from(msg.data));if(msg.data.length<3)return;const cmd=msg.data[0]>>4;const pitch=msg.data[1];const velocity=msg.data[2];if(cmd===0x9&&velocity>0){lastMidiNoteRef.current={pitch,velocity,startTime:performance.now(),length:0};setLastMidiNote({pitch,velocity,length:0,time:Date.now()});activeMidiPitchesRef.current.add(pitch);setActiveMidiPitches(new Set(activeMidiPitchesRef.current));// Stop previous notes on this channel before playing new note -// Only send All Notes Off when sustain pedal is not active -if(window.SonicSF){const ch=msg.data[0]&0x0F;if(!window.SonicSF.sustainActive||!window.SonicSF.sustainActive(ch)){try{window.SonicSF.controllerChange(ch,123,0);}catch(e){}}const arSub=subTabsRef&&subTabsRef.current&&activeTabRef&&subTabsRef.current.find(s=>s.id===activeTabRef.current&&s.type==='PIANO_ROLL'&&s.isArmed);if(arSub){window.SonicSF.playNote(pitch,velocity,60000,undefined,arSub.instrumentProgram,null,ch,arSub.synth_engine);}else{const armedTrack=activeTracksRef.current?activeTracksRef.current.find(t=>t.isArmed):null;if(armedTrack){const prog=armedTrack.instrumentProgram;const se=armedTrack.synth_engine;const dest=activeTrackNodesRef.current[armedTrack.id]?.gainNode||null;window.SonicSF.playNote(pitch,velocity,60000,undefined,prog,dest,ch,se);}}}}else if(cmd===0x8||cmd===0x9&&velocity===0){activeMidiPitchesRef.current.delete(pitch);setActiveMidiPitches(new Set(activeMidiPitchesRef.current));const current=lastMidiNoteRef.current;if(current&¤t.pitch===pitch){const lenSec=(performance.now()-current.startTime)/1000;lastMidiNoteRef.current={...current,length:lenSec};setLastMidiNote(prev=>prev&&prev.pitch===pitch?{...prev,length:lenSec,time:Date.now()}:prev);}// Stop the note immediately via SpessaSynth -if(window.SonicSF&&window.SonicSF.stopNote){const ch=msg.data[0]&0x0F;window.SonicSF.stopNote(ch,pitch);}}// ── Sustain (CC64), Modulation (CC1), Pitch Bend ── -const midiCh=msg.data[0]&0x0F;if(cmd===0xB){// Controller Change: forward all CCs to SpessaSynth -const cc=msg.data[1];const val=msg.data[2];if(window.SonicSF&&window.SonicSF.controllerChange){window.SonicSF.controllerChange(midiCh,cc,val);}}else if(cmd===0xE){// Pitch Bend: 14-bit value (LSB + MSB) -const lsb=msg.data[1];const msb=msg.data[2];const bendVal=msb<<7|lsb;if(window.SonicSF&&window.SonicSF.pitchBend){window.SonicSF.pitchBend(midiCh,bendVal);}}// Forward to active MIDI recorders +const[localSelectionTrackId,setLocalSelectionTrackId]=useState(null);const[localSelectionStart,setLocalSelectionStart]=useState(null);const[localSelectionEnd,setLocalSelectionEnd]=useState(null);const[zoom,setZoom]=useState(100);const[isLoopingSelection,setIsLoopingSelection]=useState(false);const[beginBar,setBeginBar]=useState(0);const[endBar,setEndBar]=useState(0);const[numberBar,setNumberBar]=useState(1);const[subTabHeight,setSubTabHeight]=useState(96);const[isExporting,setIsExporting]=useState(false);const[projectName,setProjectName]=useState(()=>localStorage.getItem('sonic_project_name')||'');const[currentProjectId,setCurrentProjectId]=useState(()=>localStorage.getItem('sonic_project_id')||null);const[saveProjectModalOpen,setSaveProjectModalOpen]=useState(false);const[saveAsModalOpen,setSaveAsModalOpen]=useState(false);const soloedTrack=tracks.find(t=>t.solo);const soloedTrackId=soloedTrack?soloedTrack.id:null;const[toastMessage,setToastMessage]=useState(null);const[audioDevices,setAudioDevices]=useState([]);const[midiDevices,setMidiDevices]=useState([]);const[selectedMidiInputId,setSelectedMidiInputId]=useState('');const handleMidiInputSelect=id=>{setSelectedMidiInputId(id);if(window.SonicRecorderManager){window.SonicRecorderManager.setSelectedMidiInputId(id);}};useEffect(()=>{if(navigator.mediaDevices&&navigator.mediaDevices.enumerateDevices){navigator.mediaDevices.enumerateDevices().then(devices=>{setAudioDevices(devices.filter(d=>d.kind==='audioinput'));}).catch(err=>console.log('Enumerate audio devices error:',err));}if(navigator.requestMIDIAccess){navigator.requestMIDIAccess().then(access=>{const inputs=[];for(let input of access.inputs.values()){inputs.push(input);input.onmidimessage=msg=>{console.log(`[DevLog] [Raw MIDI] Received from "${input.name}" (ID: ${input.id}) - Data:`,Array.from(msg.data));if(msg.data.length<3)return;const cmd=msg.data[0]>>4;const pitch=msg.data[1];const velocity=msg.data[2];if(cmd===0x9&&velocity>0){lastMidiNoteRef.current={pitch,velocity,startTime:performance.now(),length:0};setLastMidiNote({pitch,velocity,length:0,time:Date.now()});activeMidiPitchesRef.current.add(pitch);setActiveMidiPitches(new Set(activeMidiPitchesRef.current));// Route MIDI input to ALL armed tracks on their dedicated channels +// Do NOT use raw MIDI hardware channel (msg.data[0] & 0x0F) +if(window.SonicSF){var allTracks=activeTracksRef.current||[];var arSubs=subTabsRef&&subTabsRef.current?subTabsRef.current.filter(function(s){return s.type==='PIANO_ROLL'&&s.isArmed;}):[];var armedTracks=allTracks.filter(function(t){return t.isArmed;});// Piano Roll arming has priority: route to each armed sub-tab's parent track +if(arSubs.length>0){arSubs.forEach(function(as){var asTrk=allTracks.find(function(t){return t.id===as.trackId;});var asCh=window.SonicPianoRoll?window.SonicPianoRoll.getTrackMidiChannel(asTrk,allTracks):asTrk?asTrk.midiChannel:0;var asProg=as.instrumentProgram;var asSe=as.synth_engine;window.SonicSF.playNote(pitch,velocity,60000,undefined,asProg,null,asCh,asSe);});}// Route to ALL armed tracks (not just the first one) +armedTracks.forEach(function(at){var atCh=window.SonicPianoRoll?window.SonicPianoRoll.getTrackMidiChannel(at,allTracks):at.midiChannel!==undefined?at.midiChannel:0;var atProg=at.instrumentProgram;var atSe=at.synth_engine;var atDest=activeTrackNodesRef.current[at.id]?.gainNode||null;window.SonicSF.playNote(pitch,velocity,60000,undefined,atProg,atDest,atCh,atSe);});}}else if(cmd===0x8||cmd===0x9&&velocity===0){activeMidiPitchesRef.current.delete(pitch);setActiveMidiPitches(new Set(activeMidiPitchesRef.current));const current=lastMidiNoteRef.current;if(current&¤t.pitch===pitch){const lenSec=(performance.now()-current.startTime)/1000;lastMidiNoteRef.current={...current,length:lenSec};setLastMidiNote(prev=>prev&&prev.pitch===pitch?{...prev,length:lenSec,time:Date.now()}:prev);}// Stop the note on ALL armed tracks' dedicated channels +if(window.SonicSF&&window.SonicSF.stopNote){var stopTracks=activeTracksRef.current||[];stopTracks.forEach(function(st){if(!st.isArmed)return;var stCh=window.SonicPianoRoll?window.SonicPianoRoll.getTrackMidiChannel(st,stopTracks):st.midiChannel!==undefined?st.midiChannel:0;window.SonicSF.stopNote(stCh,pitch);});}}// ── Sustain (CC64), Modulation (CC1), Pitch Bend ── +const midiCh=msg.data[0]&0x0F;if(cmd===0xB){// Controller Change: forward to ALL armed tracks' dedicated channels +const cc=msg.data[1];const val=msg.data[2];if(window.SonicSF&&window.SonicSF.controllerChange){var ccTracks=activeTracksRef.current||[];var hasArmed=ccTracks.some(function(t){return t.isArmed;});if(hasArmed){ccTracks.forEach(function(ct){if(!ct.isArmed)return;var ctCh=window.SonicPianoRoll?window.SonicPianoRoll.getTrackMidiChannel(ct,ccTracks):ct.midiChannel!==undefined?ct.midiChannel:0;window.SonicSF.controllerChange(ctCh,cc,val);});}else{window.SonicSF.controllerChange(midiCh,cc,val);}}}else if(cmd===0xE){// Pitch Bend: forward to ALL armed tracks' dedicated channels +const lsb=msg.data[1];const msb=msg.data[2];const bendVal=msb<<7|lsb;if(window.SonicSF&&window.SonicSF.pitchBend){var pbTracks=activeTracksRef.current||[];var hasArmedPB=pbTracks.some(function(t){return t.isArmed;});if(hasArmedPB){pbTracks.forEach(function(pt){if(!pt.isArmed)return;var ptCh=window.SonicPianoRoll?window.SonicPianoRoll.getTrackMidiChannel(pt,pbTracks):pt.midiChannel!==undefined?pt.midiChannel:0;window.SonicSF.pitchBend(ptCh,bendVal);});}else{window.SonicSF.pitchBend(midiCh,bendVal);}}}// Forward to active MIDI recorders if(activeMIDIRecordersRef.current){for(let trackId in activeMIDIRecordersRef.current){const rec=activeMIDIRecordersRef.current[trackId];if(rec){rec.handleMIDIMessage(msg,input.id);}}}};}setMidiDevices(inputs);access.onstatechange=()=>{const inputs=[];for(let input of access.inputs.values()){inputs.push(input);}setMidiDevices(inputs);};}).catch(err=>console.log('MIDI access error:',err));}},[]);const[showAIConfig,setShowAIConfig]=useState(false);const[recordingState,setRecordingState]=useState('IDLE');// 'IDLE' | 'COUNT_IN' | 'RECORDING' const[recTempMidiNotes,setRecTempMidiNotes]=useState([]);const[recTempAudioBuffer,setRecTempAudioBuffer]=useState(null);const[recStartTimelineTime,setRecStartTimelineTime]=useState(0);const[canvasRedrawCount,setCanvasRedrawCount]=useState(0);const[lastMidiNote,setLastMidiNote]=useState(null);const lastMidiNoteRef=useRef(null);const activeMIDIRecordersRef=useRef({});const activeAudioRecordersRef=useRef({});const pianoRollRecorderRef=useRef(null);const activeMidiPitchesRef=useRef(new Set());const[activeMidiPitches,setActiveMidiPitches]=useState(new Set());const recordingPCMDataRef=useRef({});const recordingStartTimeRef=useRef(0);const recordingSyncRef=useRef(null);const recordingStateRef=useRef(recordingState);recordingStateRef.current=recordingState;const lastTempCompileTimeRef=useRef(0);const nextMetronomeBeatRef=useRef(0);const[showExportPanel,setShowExportPanel]=useState(false);const[showAIPanel,setShowAIPanel]=useState(true);const[showSelectionPanel,setShowSelectionPanel]=useState(false);const[showPythonToolsPanel,setShowPythonToolsPanel]=useState(false);const[showMediaExplorer,setShowMediaExplorer]=useState(false);const[scrollBufferExtra,setScrollBufferExtra]=useState(0);const scrollBufferExtraRef=useRef(0);scrollBufferExtraRef.current=scrollBufferExtra;const[showFxRack,setShowFxRack]=useState(false);const[showMidiEvents,setShowMidiEvents]=useState(false);const[rightSidebarWidth,setRightSidebarWidth]=useState(320);const[tcpWidth,setTcpWidth]=useState(320);const[mediaExplorerHeight,setMediaExplorerHeight]=useState(50);const[panelPositions,setPanelPositions]=useState({export:'bottom',ai:'right',python_tools:'bottom',selection:'bottom',media_explorer:'bottom',fx_rack:'bottom',midi_events:'bottom'});const[panelDropZone,setPanelDropZone]=useState(null);const[dragGhostPos,setDragGhostPos]=useState(null);const[dragGhostPanel,setDragGhostPanel]=useState(null);const panelDragRef=useRef(null);const trackVuRefs=useRef({});const workspaceRef=useRef(null);const colResizerRef=useRef(null);const rowResizerRef=useRef(null);const[aiConfig,setAiConfig]=useState({baseUrl:localStorage.getItem('ai_base_url')||`${API_BASE_URL}`,apiKey:localStorage.getItem('ai_api_key')||'',model:localStorage.getItem('ai_model')||'deepseek-chat'});const[aiProviders,setAiProviders]=useState([]);useEffect(()=>{(async()=>{try{const data=await window.SonicAPI.getAIConfigs();if(data&&data.providers){setAiProviders(data.providers);const active=data.providers.find(p=>p.is_active)||data.providers[0];if(active)setSelectedProviderId(active.id);}}catch(e){/* server may not have config endpoint */}})();},[]);const[analysisState,setAnalysisState]=useState({status:'Sẵn sàng. Chạy AI để phân tích nhịp.',data:null,isRunning:false});const[aiPrompt,setAiPrompt]=useState('');const[promptHistory,setPromptHistory]=useState([]);const[promptHistIdx,setPromptHistIdx]=useState(-1);const promptHistRef=useRef([]);const[aiProvider,setAiProvider]=useState('OpenAI');const[aiModel,setAiModel]=useState('GPT-4o');const[aiActionLog,setAiActionLog]=useState([]);const actionLogContainerRef=useRef(null);useEffect(()=>{if(actionLogContainerRef.current){actionLogContainerRef.current.scrollTop=actionLogContainerRef.current.scrollHeight;}},[aiActionLog]);const[aiProcessing,setAiProcessing]=useState(false);const[selectedProviderId,setSelectedProviderId]=useState('');const[exportSettings,setExportSettings]=useState({sampleRate:'44100',bitDepth:'16',format:'wav',source:'project',quality:'44khz',channels:'stereo'});const[serverStatus,setServerStatus]=useState('checking...');const[menuOpen,setMenuOpen]=useState(null);const[selectedClipId,setSelectedClipId]=useState(null);// { trackId, clipId } const[stretchedClip,setStretchedClip]=useState(null);// { trackId, clipId, originalDuration, startTime, originalSpeed, beforeSnap } diff --git a/wiki.md b/wiki.md index 51c43ae..a0ee527 100644 --- a/wiki.md +++ b/wiki.md @@ -491,3 +491,9 @@ - **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. +---