From 1db892bb99dc96c22ea02c7bb4239b334a4201ba Mon Sep 17 00:00:00 2001 From: locpham Date: Mon, 10 Aug 2026 10:04:54 +0700 Subject: [PATCH] =?UTF-8?q?FIX:=20s=E1=BB=ADa=20l=E1=BB=97i=20kh=C3=B4ng?= =?UTF-8?q?=20load=20instrument=20c=E1=BB=A7a=20soundfont=20tr=C3=AAn=20wi?= =?UTF-8?q?ndow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/v1/plugins.py | 84 ++++++++++++++++++++++++++++++- app/core/render_engine.py | 15 ++++++ app/core/vst_engine.py | 6 +++ app/static/js/app.jsx | 16 ++++++ app/static/js/app.precompiled.js | 6 ++- app/static/js/services/api.js | 2 + app/static/js/services/runtime.js | 30 +++++++++++ wiki.md | 5 ++ 8 files changed, 160 insertions(+), 4 deletions(-) diff --git a/app/api/v1/plugins.py b/app/api/v1/plugins.py index cb776ae..edbeae2 100644 --- a/app/api/v1/plugins.py +++ b/app/api/v1/plugins.py @@ -164,7 +164,7 @@ def get_scanner(): @router.get("/available") async def list_plugins(current_user: dict = Depends(get_current_user)): d = _effective_dirs() - pm = PluginManager(vst_dir=d["vst_dir"], sf_dir=d["soundfont_dir"], upload_sf_dir=UPLOAD_SF_DIR) + pm = PluginManager(vst_dir=d["vst_dir"], sf_dir=d["soundfont_dir"], upload_sf_dir=UPLOAD_SF_DIR, extra_vst_dirs=d["plugin_dirs"]) avail = pm.list_available() # Gộp VST từ plugin_dirs user đã scan — list_available() CHỈ quét vst_dir # env (mặc định /opt/daw_engine/vst3) → Synth dropdown không thấy VSTi mà @@ -369,7 +369,9 @@ async def soundfont_catalog(current_user: dict = Depends(get_current_user)): @router.get("/soundfont-instruments/{sf_id}") async def list_soundfont_instruments(sf_id: str, current_user: dict = Depends(get_current_user)): - pm = PluginManager(upload_sf_dir=UPLOAD_SF_DIR) + d = _effective_dirs() + # extra_vst_dirs = plugin_dirs user (chứa cả VST lẫn SoundFont) → tìm sf2 ở đó + pm = PluginManager(upload_sf_dir=UPLOAD_SF_DIR, extra_vst_dirs=d["plugin_dirs"]) presets = pm.list_soundfont_instruments(sf_id) return {"presets": presets, "count": len(presets)} @@ -512,6 +514,84 @@ class PreviewRequest(BaseModel): preset_data: Optional[str] = None # base64 bytes .vstpreset (project nhúng) +class CarlaMidiRequest(BaseModel): + """Gửi MIDI note từ track ARM → Carla (OSC /Carla/0/note_on|note_off). + + Carla standalone bật OSC UDP mặc định cổng 22752 (source: CarlaEngineOsc, + CarlaEngineData oscPortUDP=22752; override: env CARLA_OSC_UDP_PORT của + Carla, hoặc SF_CARLA_OSC_PORT / osc_port trong carla_path.json của app). + Plugin đầu tiên trong project .carxs do app sinh có pluginId = 0.""" + event: str # "note_on" | "note_off" + note: int + velocity: Optional[int] = 100 + channel: Optional[int] = 0 + + +@router.post("/carla-midi") +async def carla_midi(req: CarlaMidiRequest): + """MIDI keyboard (piano roll / keybed) → Carla để preview VSTi realtime.""" + if req.event not in ("note_on", "note_off"): + raise HTTPException(status_code=400, detail="event phải là note_on hoặc note_off") + ok = _send_carla_osc(req.event, req.note, req.velocity if req.event == "note_on" else 0, req.channel) + if not ok: + raise HTTPException( + status_code=502, + detail="Không gửi được OSC tới Carla — Carla đã mở chưa? (cổng OSC UDP mặc định 22752; " + "nếu đổi cổng trong Carla, đặt SF_CARLA_OSC_PORT hoặc osc_port trong carla_path.json)", + ) + return {"success": True, "event": req.event, "note": req.note, "channel": req.channel} + + +def _carla_osc_port() -> int: + """Cổng OSC UDP của Carla: SF_CARLA_OSC_PORT env → osc_port trong + storage/carla_path.json → mặc định 22752 (CarlaEngineData).""" + try: + p = os.environ.get("SF_CARLA_OSC_PORT") + if p: + return int(p) + except Exception: + pass + try: + from app.core.runtime import _carla_config_path + with open(_carla_config_path(), "r", encoding="utf-8") as f: + data = json.load(f) + p = data.get("osc_port") + if p: + return int(p) + except Exception: + pass + return 22752 + + +def _send_carla_osc(event: str, note: int, velocity: int, channel: int) -> bool: + """Gửi OSC UDP tới `/Carla/0/{event}` (plugin đầu tiên = plugin auto-load). + + OSC message: path + typetag + int args, mỗi phần pad '\0' tới bội số 4. + Đã xác minh từ source Carla: handleMsgNoteOn/NoteOff nhận `iii`/`ii` và + tên client mặc định của app standalone là "Carla" (carla_host.py + fClientName = CARLA_CLIENT_NAME or "Carla").""" + try: + import socket + import struct + port = _carla_osc_port() + path = f"/Carla/0/{event}".encode("utf-8") + typetag = b",iii" if event == "note_on" else b",ii" + vals = [int(channel), int(note), int(velocity)] if event == "note_on" else [int(channel), int(note)] + + def _pad(b: bytes) -> bytes: + rem = len(b) % 4 + return b + b"\x00" * (4 - rem) if rem else b + + msg = _pad(path) + _pad(typetag) + b"".join(struct.pack(">i", v) for v in vals) + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(0.5) + s.sendto(msg, ("127.0.0.1", port)) + s.close() + return True + except Exception: + return False + + @router.post("/open-in-carla") async def open_in_carla(req: OpenInCarlaRequest, current_user: dict = Depends(get_current_user)): """Mở Carla với VSTi đã chọn — TỰ ĐỘNG load plugin (native GUI + keyboard). diff --git a/app/core/render_engine.py b/app/core/render_engine.py index 09d7fc2..c604bd7 100644 --- a/app/core/render_engine.py +++ b/app/core/render_engine.py @@ -35,6 +35,21 @@ def _find_sf2_path(sf_id: str) -> str: fbase, fext = os.path.splitext(fname) if fext.lower() in (".sf2", ".sf3") and (fbase.lower() == clean_lower or fbase.lower() == sf_lower): return os.path.join(base_dir, fname) + # Thư mục user thêm qua Plugin Manager (plugin_dirs — Add Directory): + # soundfont trong thư mục user phải render được (Windows thường dùng cách này) + try: + from app.core.vst_engine import _load_user_plugin_dirs + for base_dir in _load_user_plugin_dirs(): + if not os.path.isdir(base_dir): + continue + for root, dirs, files in os.walk(base_dir): + for fname in files: + fbase, fext = os.path.splitext(fname) + if fext.lower() in (".sf2", ".sf3") and (fbase.lower() == clean_lower or fbase.lower() == sf_lower): + return os.path.join(root, fname) + dirs[:] = [] # không walk sâu + except Exception: + pass static_dir = os.path.join(settings.APP_DIR, "static", "soundfonts") if os.path.isdir(static_dir): for fname in os.listdir(static_dir): diff --git a/app/core/vst_engine.py b/app/core/vst_engine.py index 24392f8..16d7884 100644 --- a/app/core/vst_engine.py +++ b/app/core/vst_engine.py @@ -287,6 +287,12 @@ class PluginManager: search_dirs.append(self.sf_dir) if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir) and self.upload_sf_dir != self.sf_dir: search_dirs.append(self.upload_sf_dir) + # Thư mục user thêm qua Plugin Manager (plugin_dirs — chứa cả VST lẫn + # SoundFont): nếu không có, instrument của soundfont trong thư mục user + # không liệt kê được (bug "nhấn tên SF không thấy instrument"). + for d in (self.extra_vst_dirs or []): + if d and os.path.isdir(d) and d not in search_dirs: + search_dirs.append(d) for d in search_dirs: for f in os.listdir(d): if not (f.endswith(".sf2") or f.endswith(".sf3")): diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 2bc858b..a86443b 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -8687,6 +8687,11 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos window.SonicSF.playNote(p, pvVel, durMs, pvCtx.currentTime, pvCtxInst.program, null, pvCtxInst.ch, pvCtxInst.synthEngine); previewPitchRef.current = p; } + // MIDI → Carla (track VSTi + ARM + Carla local): preview realtime + if (window.SonicCarlaMidi && window.SonicCarlaMidi.shouldRoute(pvTrk && pvTrk.synth_engine, st.isArmed)) { + var pvVel2 = Math.round((brushVelocityRef.current || 0.8) * 127); + window.SonicCarlaMidi.playNote(pvTrk && pvTrk.synth_engine ? (pvTrk.synth_engine.midi_channel || 0) : 0, p, pvVel2, durMs); + } } if (pitchChanged) { @@ -9064,6 +9069,12 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos if (window.SonicSF) { window.SonicSF.playNote(pitch, 100, 500, undefined, kbCtx.program, null, kbCtx.ch, kbCtx.synthEngine); } + // MIDI → Carla (track VSTi + ARM + Carla local): preview realtime + if (window.SonicCarlaMidi && window.SonicCarlaMidi.shouldRoute(kbCtx.synthEngine, st.isArmed)) { + window.SonicCarlaMidi.noteOn(kbCtx.ch, pitch, 100); + if (window.__carlaKeybedTimer) clearTimeout(window.__carlaKeybedTimer); + window.__carlaKeybedTimer = setTimeout(function () { window.SonicCarlaMidi.noteOff(kbCtx.ch, pitch); }, 650); + } } catch (err) { console.error('playNote error:', err); } @@ -9077,6 +9088,11 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos if (window.SonicSF) { window.SonicSF.playNote(pitch, 100, 200, undefined, kbCtx.program, null, kbCtx.ch, kbCtx.synthEngine); } + if (window.SonicCarlaMidi && window.SonicCarlaMidi.shouldRoute(kbCtx.synthEngine, st.isArmed)) { + window.SonicCarlaMidi.noteOn(kbCtx.ch, pitch, 100); + if (window.__carlaKeybedTimer) clearTimeout(window.__carlaKeybedTimer); + window.__carlaKeybedTimer = setTimeout(function () { window.SonicCarlaMidi.noteOff(kbCtx.ch, pitch); }, 650); + } } catch (err) { console.error('playNote error:', err); } } }, diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index 5a2d43a..ce3b32f 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -497,7 +497,8 @@ return n.start_beat<=maxBeat&¬eEnd>=minBeat;}else{// Right to left: select on return n.start_beat>=minBeat&¬eEnd<=maxBeat;}}).map(n=>n.id);setSelectedNoteIds(insideIds);return;}// Right-click drag → erase sweep const rc=rightClickDragRef.current;if(rc.active&&(Math.abs(e.clientX-rc.startX)>5||Math.abs(e.clientY-rc.startY)>5)){rc.active=false;swallowContextMenuRef.current=true;notesBeforeDragRef.current=JSON.parse(JSON.stringify(notes));setDraggedNote({mode:'erase_sweep',visitedPitches:[]});return;}if(!draggedNote){let foundIdx=-1;for(let i=0;i=n.start_beat){foundIdx=i;break;}}}if(foundIdx!==-1){canvas.style.cursor='ew-resize';setHoveredResizeIdx(foundIdx);}else{canvas.style.cursor=activeRollTool==='eraser'?'pointer':'crosshair';setHoveredResizeIdx(-1);}return;}if(draggedNote.mode==='draw'){const snappedPitch=snapToScaleRef.current?snapPitchToScale(pitch,selectedScaleRef.current):pitch;const lastPitch=draggedNote.lastDrawnPitch!==undefined?draggedNote.lastDrawnPitch:draggedNote.startOffsetPitch;const pitchChanged=snappedPitch!==lastPitch;const noteBeats=draggedNote.noteStartBeats||[];const defaultDur=getSnapDuration(snapValue);function playDrawPreview(p,durMs){stopPreviewNote();if(window.SonicSF&&window.SonicSF.playNote){var pvCtx=getAudioContext();var pvTrk=activeTracks.find(function(t){return t.id===st.trackId;});var pvCtxInst=resolveTrackInstrumentCtx(pvTrk,activeTracks);var pvVel=Math.round(brushVelocityRef.current*127);// playNote (FluidSynth — nhạc cụ THẬT). _playNoteFallback = oscillator // beep sai âm (percussion/soundfont). -window.SonicSF.playNote(p,pvVel,durMs,pvCtx.currentTime,pvCtxInst.program,null,pvCtxInst.ch,pvCtxInst.synthEngine);previewPitchRef.current=p;}}if(pitchChanged){const brushIds=draggedNote.brushIds||[];if(brushIds.length>0&¬eBeats.length>0){const prevNoteId=brushIds[brushIds.length-1];const prevNoteBeat=noteBeats[noteBeats.length-1];const prevDur=Math.max(0.125,beat-prevNoteBeat);setNotes(prev=>prev.map(n=>{if(n.id!==prevNoteId)return n;return{...n,duration_beats:prevDur};}));}const newNote={id:'note_'+Date.now()+Math.random().toString(36).substr(2,8)+'_'+(noteBeats.length+1),pitch:snappedPitch,start_beat:beat,duration_beats:defaultDur,velocity:brushVelocityRef.current,pan:0.0};setNotes(prev=>[...prev,newNote]);setSelectedNoteIds(prev=>[...prev,newNote.id]);draggedNote.brushIds=[...brushIds,newNote.id];draggedNote.lastDrawnPitch=snappedPitch;draggedNote.noteStartBeats=[...noteBeats,beat];playDrawPreview(snappedPitch,Math.max(100,Math.round(defaultDur*(60/bpm)*1000)));}else{const brushIds=draggedNote.brushIds||[];const lastBrushId=brushIds.length>0?brushIds[brushIds.length-1]:draggedNote.drawNoteId;const lastNoteBeat=noteBeats.length>0?noteBeats[noteBeats.length-1]:draggedNote.startOffsetBeat;if(lastBrushId){const extDur=Math.max(0.125,beat-lastNoteBeat);setNotes(prev=>prev.map(n=>{if(n.id!==lastBrushId)return n;return{...n,duration_beats:extDur};}));playDrawPreview(snappedPitch,Math.max(100,Math.round(extDur*(60/bpm)*1000)));}}const container=gridScrollRef.current;if(container){const cr=container.getBoundingClientRect();const visTop=container.scrollTop;const visBot=visTop+container.clientHeight;const pitchPixel=(127-snappedPitch)*NoteHeight;const safeMargin=NoteHeight*2;if(pitchPixel{if(gridScrollRef.current)gridScrollRef.current.scrollTop=Math.max(0,gridScrollRef.current.scrollTop-Math.max(1,Math.floor(NoteHeight*0.5)));},16)};}}else if(pitchPixel+NoteHeight>visBot-safeMargin){const target=Math.min(container.scrollHeight-container.clientHeight,pitchPixel-container.clientHeight+safeMargin+NoteHeight);if(container.scrollTop!==target)container.scrollTop=target;if(!brushAutoScrollRef.current||brushAutoScrollRef.current.direction!=='down'){if(brushAutoScrollRef.current)clearInterval(brushAutoScrollRef.current.id);brushAutoScrollRef.current={direction:'down',id:setInterval(()=>{if(gridScrollRef.current)gridScrollRef.current.scrollTop=Math.min(gridScrollRef.current.scrollHeight-gridScrollRef.current.clientHeight,gridScrollRef.current.scrollTop+Math.max(1,Math.floor(NoteHeight*0.5)));},16)};}}else{if(brushAutoScrollRef.current){clearInterval(brushAutoScrollRef.current.id);brushAutoScrollRef.current=null;}}}return;}if(draggedNote.mode==='erase_sweep'){const erased=draggedNote.erasedIds||[];const target=notes.find(n=>n.pitch===pitch&&beat>=n.start_beat&&beatprev.filter(n=>n.id!==target.id));setSelectedNoteIds(prev=>prev.filter(id=>id!==target.id));}return;}if(draggedNote.mode==='scale'){const newEnd=getSnapBeat(Math.max(draggedNote.firstStart+0.125,beat),snapValue);const range=draggedNote.originalEnd-draggedNote.firstStart;if(range<=0)return;const scaleFactor=Math.max(0.01,(newEnd-draggedNote.firstStart)/range);const ids=draggedNote.selectedNoteIds||[];const firstStart=draggedNote.firstStart;const notesBefore=notesBeforeDragRef.current;if(!notesBefore)return;setNotes(prev=>prev.map(n=>{if(!ids.includes(n.id))return n;const orig=notesBefore?notesBefore.find(o=>o.id===n.id):null;const origStart=orig?orig.start_beat:n.start_beat;const origDur=orig?orig.duration_beats:n.duration_beats;const relStart=origStart-firstStart;const relEnd=relStart+origDur;return{...n,start_beat:firstStart+relStart*scaleFactor,duration_beats:Math.max(0.125,relEnd*scaleFactor-relStart*scaleFactor)};}));return;}if(draggedNote.mode==='resize'){const newDuration=getSnapBeat(Math.max(0.125,beat-draggedNote.originalStart),snapValue);setNotes(prev=>prev.map((n,idx)=>{if(idx!==draggedNote.idx)return n;return{...n,duration_beats:newDuration};}));}else if(draggedNote.mode==='move'){const refOrigStart=draggedNote.clickedOriginalStartBeat;if(refOrigStart===undefined)return;const deltaBeat=getSnapBeat(beat-draggedNote.startOffsetBeat,snapValue)-refOrigStart;// Clamp so no note goes past beat 0 +window.SonicSF.playNote(p,pvVel,durMs,pvCtx.currentTime,pvCtxInst.program,null,pvCtxInst.ch,pvCtxInst.synthEngine);previewPitchRef.current=p;}// MIDI → Carla (track VSTi + ARM + Carla local): preview realtime +if(window.SonicCarlaMidi&&window.SonicCarlaMidi.shouldRoute(pvTrk&&pvTrk.synth_engine,st.isArmed)){var pvVel2=Math.round((brushVelocityRef.current||0.8)*127);window.SonicCarlaMidi.playNote(pvTrk&&pvTrk.synth_engine?pvTrk.synth_engine.midi_channel||0:0,p,pvVel2,durMs);}}if(pitchChanged){const brushIds=draggedNote.brushIds||[];if(brushIds.length>0&¬eBeats.length>0){const prevNoteId=brushIds[brushIds.length-1];const prevNoteBeat=noteBeats[noteBeats.length-1];const prevDur=Math.max(0.125,beat-prevNoteBeat);setNotes(prev=>prev.map(n=>{if(n.id!==prevNoteId)return n;return{...n,duration_beats:prevDur};}));}const newNote={id:'note_'+Date.now()+Math.random().toString(36).substr(2,8)+'_'+(noteBeats.length+1),pitch:snappedPitch,start_beat:beat,duration_beats:defaultDur,velocity:brushVelocityRef.current,pan:0.0};setNotes(prev=>[...prev,newNote]);setSelectedNoteIds(prev=>[...prev,newNote.id]);draggedNote.brushIds=[...brushIds,newNote.id];draggedNote.lastDrawnPitch=snappedPitch;draggedNote.noteStartBeats=[...noteBeats,beat];playDrawPreview(snappedPitch,Math.max(100,Math.round(defaultDur*(60/bpm)*1000)));}else{const brushIds=draggedNote.brushIds||[];const lastBrushId=brushIds.length>0?brushIds[brushIds.length-1]:draggedNote.drawNoteId;const lastNoteBeat=noteBeats.length>0?noteBeats[noteBeats.length-1]:draggedNote.startOffsetBeat;if(lastBrushId){const extDur=Math.max(0.125,beat-lastNoteBeat);setNotes(prev=>prev.map(n=>{if(n.id!==lastBrushId)return n;return{...n,duration_beats:extDur};}));playDrawPreview(snappedPitch,Math.max(100,Math.round(extDur*(60/bpm)*1000)));}}const container=gridScrollRef.current;if(container){const cr=container.getBoundingClientRect();const visTop=container.scrollTop;const visBot=visTop+container.clientHeight;const pitchPixel=(127-snappedPitch)*NoteHeight;const safeMargin=NoteHeight*2;if(pitchPixel{if(gridScrollRef.current)gridScrollRef.current.scrollTop=Math.max(0,gridScrollRef.current.scrollTop-Math.max(1,Math.floor(NoteHeight*0.5)));},16)};}}else if(pitchPixel+NoteHeight>visBot-safeMargin){const target=Math.min(container.scrollHeight-container.clientHeight,pitchPixel-container.clientHeight+safeMargin+NoteHeight);if(container.scrollTop!==target)container.scrollTop=target;if(!brushAutoScrollRef.current||brushAutoScrollRef.current.direction!=='down'){if(brushAutoScrollRef.current)clearInterval(brushAutoScrollRef.current.id);brushAutoScrollRef.current={direction:'down',id:setInterval(()=>{if(gridScrollRef.current)gridScrollRef.current.scrollTop=Math.min(gridScrollRef.current.scrollHeight-gridScrollRef.current.clientHeight,gridScrollRef.current.scrollTop+Math.max(1,Math.floor(NoteHeight*0.5)));},16)};}}else{if(brushAutoScrollRef.current){clearInterval(brushAutoScrollRef.current.id);brushAutoScrollRef.current=null;}}}return;}if(draggedNote.mode==='erase_sweep'){const erased=draggedNote.erasedIds||[];const target=notes.find(n=>n.pitch===pitch&&beat>=n.start_beat&&beatprev.filter(n=>n.id!==target.id));setSelectedNoteIds(prev=>prev.filter(id=>id!==target.id));}return;}if(draggedNote.mode==='scale'){const newEnd=getSnapBeat(Math.max(draggedNote.firstStart+0.125,beat),snapValue);const range=draggedNote.originalEnd-draggedNote.firstStart;if(range<=0)return;const scaleFactor=Math.max(0.01,(newEnd-draggedNote.firstStart)/range);const ids=draggedNote.selectedNoteIds||[];const firstStart=draggedNote.firstStart;const notesBefore=notesBeforeDragRef.current;if(!notesBefore)return;setNotes(prev=>prev.map(n=>{if(!ids.includes(n.id))return n;const orig=notesBefore?notesBefore.find(o=>o.id===n.id):null;const origStart=orig?orig.start_beat:n.start_beat;const origDur=orig?orig.duration_beats:n.duration_beats;const relStart=origStart-firstStart;const relEnd=relStart+origDur;return{...n,start_beat:firstStart+relStart*scaleFactor,duration_beats:Math.max(0.125,relEnd*scaleFactor-relStart*scaleFactor)};}));return;}if(draggedNote.mode==='resize'){const newDuration=getSnapBeat(Math.max(0.125,beat-draggedNote.originalStart),snapValue);setNotes(prev=>prev.map((n,idx)=>{if(idx!==draggedNote.idx)return n;return{...n,duration_beats:newDuration};}));}else if(draggedNote.mode==='move'){const refOrigStart=draggedNote.clickedOriginalStartBeat;if(refOrigStart===undefined)return;const deltaBeat=getSnapBeat(beat-draggedNote.startOffsetBeat,snapValue)-refOrigStart;// Clamp so no note goes past beat 0 const minOrigStart=Math.min(...draggedNote.selectedNotesOffset.map(o=>o.originalStartBeat));const clampedDeltaBeat=minOrigStart+deltaBeat<0?-minOrigStart:deltaBeat;const deltaPitch=Math.round(pitch-draggedNote.startOffsetPitch);setNotes(prev=>prev.map(n=>{const offset=draggedNote.selectedNotesOffset.find(o=>o.id===n.id);if(!offset)return n;return{...n,start_beat:getSnapBeat(Math.max(0,offset.originalStartBeat+clampedDeltaBeat),snapValue),pitch:Math.max(0,Math.min(127,offset.originalPitch+deltaPitch))};}));}};const handleGridMouseUp=()=>{if(draggedNote&&draggedNote.mode==='draw'){const dn=draggedNote;const brushIds=dn.brushIds||[];const lastBrushId=brushIds.length>0?brushIds[brushIds.length-1]:dn.drawNoteId;if(lastBrushId){const lastNote=notes.find(n=>n.id===lastBrushId);if(lastNote)lastNoteDurationRef.current=lastNote.duration_beats;}}setDraggedNote(null);// Marquee (Ctrl+drag): CHỌN notes nằm trong vùng (user 09:15 — trước đây // chỉ vẽ highlight, không set selectedNoteIds → Ctrl+X báo "chọn notes") if(selectionMarquee){const minBeat=Math.min(selectionMarquee.startBeat,selectionMarquee.currentBeat);const maxBeat=Math.max(selectionMarquee.startBeat,selectionMarquee.currentBeat);const minPitch=Math.min(selectionMarquee.startPitch,selectionMarquee.currentPitch);const maxPitch=Math.max(selectionMarquee.startPitch,selectionMarquee.currentPitch);const inMarquee=notes.filter(n=>{const center=n.start_beat+n.duration_beats/2;return center>=minBeat&¢er<=maxBeat&&n.pitch>=minPitch&&n.pitch<=maxPitch;});setSelectedNoteIds(inMarquee.map(n=>n.id));}setSelectionMarquee(null);rightClickDragRef.current={active:false,startX:0,startY:0};if(brushAutoScrollRef.current){clearInterval(brushAutoScrollRef.current.id);brushAutoScrollRef.current=null;}stopPreviewNote();previewPitchRef.current=null;};const handleContextMenu=e=>{e.preventDefault();if(swallowContextMenuRef.current){swallowContextMenuRef.current=false;return;}const pos={x:e.clientX,y:e.clientY,parentKey:null};scaleMenuOriginRef.current={x:pos.x,y:pos.y};setScaleMenuPos(pos);};const ccDragRef=React.useRef(null);const brushAutoScrollRef=React.useRef(null);const rightClickDragRef=React.useRef({active:false,startX:0,startY:0});const swallowContextMenuRef=React.useRef(false);// Status hint động (user 09:40): theo dõi Shift/Ctrl + mouse trong piano roll @@ -506,7 +507,8 @@ const prKeyStateRef=React.useRef({shift:false,ctrl:false});const prMouseInRef=Re // (trước đây findCCNoteIndex chỉ trả 1 note → chord khó draw). const findCCNoteIndicesAtBeat=b=>{const snapped=getSnapBeat(b,snapValue);const out=[];notes.forEach((n,idx)=>{if(Math.abs(n.start_beat-snapped)<0.01)out.push(idx);});return out;};const handleCCMouseDown=e=>{const canvas=ccCanvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const x=e.clientX-rect.left;const y=e.clientY-rect.top;const h=rect.height;const beat=x/pixelsPerBeat-renderBeatOffset;const noteIdx=findCCNoteIndex(beat,y,h);const val=Math.max(0,Math.min(1,(h-y)/h));if(e.ctrlKey){if(selectedNoteIds.length>0){const cursorNoteIdx=findCCNoteIndex(beat,y,h);if(cursorNoteIdx!==-1&&selectedNoteIds.includes(notes[cursorNoteIdx]?notes[cursorNoteIdx].id:-1)){const currentNote=notes[cursorNoteIdx];const currentVal=ccMode==='pan'?(currentNote.pan||0)/2.0+0.5:currentNote.velocity!==undefined?currentNote.velocity:0.8;if(Math.abs(currentVal-val)>0.001){const updatedNotes=notes.map((n,i)=>{if(i!==cursorNoteIdx)return n;if(ccMode==='pan')return{...n,pan:(val-0.5)*2.0};return{...n,velocity:val};});setNotes(updatedNotes);if(isPlaying&&onRescheduleMidi)onRescheduleMidi(updatedNotes);}ccDragRef.current={active:true,lastBeat:beat,selectedMode:true,lastPainted:[cursorNoteIdx]};}else{ccDragRef.current={active:true,lastBeat:beat,selectedMode:true,lastPainted:[]};}}else{// Không chọn notes: vẽ TẤT CẢ notes cùng beat (chord — user 08:25) const idxs=findCCNoteIndicesAtBeat(beat);ccDragRef.current={active:true,lastBeat:beat,lastPainted:idxs};}return;}if(noteIdx!==-1){const currentVal=ccMode==='pan'?(notes[noteIdx].pan||0)/2.0+0.5:notes[noteIdx].velocity!==undefined?notes[noteIdx].velocity:0.8;if(Math.abs(currentVal-val)>0.001){const updatedNotes=notes.map((n,i)=>{if(i!==noteIdx)return n;if(ccMode==='pan')return{...n,pan:(val-0.5)*2.0};return{...n,velocity:val};});setNotes(updatedNotes);if(isPlaying&&onRescheduleMidi)onRescheduleMidi(updatedNotes);}}};const handleCCMouseMove=e=>{if(!ccDragRef.current||!ccDragRef.current.active)return;const canvas=ccCanvasRef.current;if(!canvas)return;const rect=canvas.getBoundingClientRect();const x=e.clientX-rect.left;const y=e.clientY-rect.top;const h=rect.height;const beat=x/pixelsPerBeat-renderBeatOffset;const val=Math.max(0,Math.min(1,(h-y)/h));const drag=ccDragRef.current;const painted=drag.lastPainted||[];if(drag.selectedMode&&selectedNoteIds.length>0){const cursorNoteIdx=findCCNoteIndex(beat,y,h);if(cursorNoteIdx!==-1){const cursorNote=notes[cursorNoteIdx];if(cursorNote&&selectedNoteIds.includes(cursorNote.id)&&!painted.includes(cursorNoteIdx)){const currentVal=ccMode==='pan'?(cursorNote.pan||0)/2.0+0.5:cursorNote.velocity!==undefined?cursorNote.velocity:0.8;if(Math.abs(currentVal-val)>0.001){const updatedNotes=notes.map((n,i)=>{if(i!==cursorNoteIdx)return n;if(ccMode==='pan')return{...n,pan:(val-0.5)*2.0};return{...n,velocity:val};});setNotes(updatedNotes);if(isPlaying&&onRescheduleMidi)onRescheduleMidi(updatedNotes);}drag.lastPainted=[...painted,cursorNoteIdx];}}return;}// Vẽ TẤT CẢ notes cùng beat (chord — user 08:25) — trước đây chỉ 1 note -const candidateIdxs=findCCNoteIndicesAtBeat(beat);const unpainted=candidateIdxs.filter(ci=>!painted.includes(ci));if(unpainted.length>0){const updatedNotes=notes.map((n,i)=>{if(!unpainted.includes(i))return n;if(ccMode==='pan')return{...n,pan:(val-0.5)*2.0};return{...n,velocity:val};});setNotes(updatedNotes);if(isPlaying&&onRescheduleMidi)onRescheduleMidi(updatedNotes);drag.lastPainted=[...painted,...unpainted];}};const renderKeybed=()=>{var kbTrk=activeTracks.find(function(t){return t.id===st.trackId;});var kbCtx=resolveTrackInstrumentCtx(kbTrk,activeTracks);ensureSonicInstrument(kbCtx);const keys=[];for(let pitch=127;pitch>=PITCH_START;pitch--){const isBlack=[1,3,6,8,10].includes(pitch%12);const notesArray=['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];const octave=Math.floor(pitch/12)-1;const label=`${notesArray[pitch%12]}${octave}`;const showLabel=pitch%12===0;keys.push(/*#__PURE__*/React.createElement("div",{key:pitch,style:{height:`${NoteHeight}px`},className:`w-[60px] shrink-0 border-b border-zinc-800 text-[9px] font-bold flex items-center justify-end pr-2 transition cursor-pointer ${activeMidiPitches&&activeMidiPitches.has(pitch)?'bg-emerald-500 text-white border-emerald-400':isBlack?'bg-zinc-950 text-slate-500 border-zinc-900 hover:bg-zinc-800':'bg-white text-zinc-800 border-r border-zinc-400 hover:bg-amber-100'}`,onMouseDown:e=>{e.stopPropagation();keybedMouseDownRef.current=true;try{if(window.triggerMidiVuActivity){window.triggerMidiVuActivity((st.parent_tab_id&&st.parent_tab_id.startsWith('session_')?'_sess_':'')+st.trackId,100);}if(window.SonicSF){window.SonicSF.playNote(pitch,100,500,undefined,kbCtx.program,null,kbCtx.ch,kbCtx.synthEngine);}}catch(err){console.error('playNote error:',err);}},onMouseEnter:e=>{if(keybedMouseDownRef.current){try{if(window.triggerMidiVuActivity){window.triggerMidiVuActivity((st.parent_tab_id&&st.parent_tab_id.startsWith('session_')?'_sess_':'')+st.trackId,100);}if(window.SonicSF){window.SonicSF.playNote(pitch,100,200,undefined,kbCtx.program,null,kbCtx.ch,kbCtx.synthEngine);}}catch(err){console.error('playNote error:',err);}}},onMouseUp:()=>{keybedMouseDownRef.current=false;}},showLabel&&label));}return keys;};const handleScroll=e=>{if(keybedRef.current){keybedRef.current.scrollTop=e.currentTarget.scrollTop;}if(rulerScrollRef.current){rulerScrollRef.current.scrollLeft=e.currentTarget.scrollLeft;}if(ccWrapperRef.current){ccWrapperRef.current.scrollLeft=e.currentTarget.scrollLeft;}const el=e.currentTarget;const THRESHOLD=200;if(el.scrollLeft+el.clientWidth>=el.scrollWidth-THRESHOLD){const newBeats=rollBeatsRef.current+16;setRollBeats(newBeats);}};const handleKeybedScroll=e=>{if(gridScrollRef.current){gridScrollRef.current.scrollTop=e.currentTarget.scrollTop;}};const SCALES={"None":null,"Diatonic":{"Major":[0,2,4,5,7,9,11],"Minor":[0,2,3,5,7,8,10],"Dorian":[0,2,3,5,7,9,10],"Phrygian":[0,1,3,5,7,8,10],"Lydian":[0,2,4,6,7,9,11],"Mixolydian":[0,2,4,5,7,9,10],"Locrian":[0,1,3,5,6,8,10]},"Pentatonic":{"Major":[0,2,4,7,9],"Minor":[0,3,5,7,10],"Blues":[0,3,5,6,7,10],"Chinese":[0,2,4,7,9],"Vietnam":[0,2,6,7,10],"India":[0,2,5,7,9],"Japan":[0,2,5,7,9],"Africa":[0,3,5,7,10]},"Church":{"Dorian":[0,2,3,5,7,9,10],"Phrygian":[0,1,3,5,7,8,10],"Lydian":[0,2,4,6,7,9,11],"Mixolydian":[0,2,4,5,7,9,10]},"Jazz":{"Blues":[0,3,5,6,7,10],"Bebop":[0,2,4,5,7,9,10,11],"Diminished":[0,2,3,5,6,8,9,11]},"Asian Traditional":{"Chinese":[0,2,4,7,9],"Vietnam":[0,2,6,7,10],"Japan":[0,2,5,7,9],"India":[0,2,5,7,9],"Gamelan":[0,2,4,7,9],"Korea":[0,2,4,5,7,9]},"Middle Eastern":{"Hijaz":[0,1,4,5,7,8,11],"Nikriz":[0,1,4,5,7,8,10],"Rast":[0,2,4,5,7,8,10],"Saba":[0,1,3,4,7,8,10],"Bayati":[0,2,3,4,7,8,10]}};const[selectedScale,setSelectedScale]=React.useState(null);const selectedScaleRef=React.useRef(null);selectedScaleRef.current=selectedScale;// Root note (0-11 — C..B) — transpose scale highlight + snap (spec 20:12) +const candidateIdxs=findCCNoteIndicesAtBeat(beat);const unpainted=candidateIdxs.filter(ci=>!painted.includes(ci));if(unpainted.length>0){const updatedNotes=notes.map((n,i)=>{if(!unpainted.includes(i))return n;if(ccMode==='pan')return{...n,pan:(val-0.5)*2.0};return{...n,velocity:val};});setNotes(updatedNotes);if(isPlaying&&onRescheduleMidi)onRescheduleMidi(updatedNotes);drag.lastPainted=[...painted,...unpainted];}};const renderKeybed=()=>{var kbTrk=activeTracks.find(function(t){return t.id===st.trackId;});var kbCtx=resolveTrackInstrumentCtx(kbTrk,activeTracks);ensureSonicInstrument(kbCtx);const keys=[];for(let pitch=127;pitch>=PITCH_START;pitch--){const isBlack=[1,3,6,8,10].includes(pitch%12);const notesArray=['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];const octave=Math.floor(pitch/12)-1;const label=`${notesArray[pitch%12]}${octave}`;const showLabel=pitch%12===0;keys.push(/*#__PURE__*/React.createElement("div",{key:pitch,style:{height:`${NoteHeight}px`},className:`w-[60px] shrink-0 border-b border-zinc-800 text-[9px] font-bold flex items-center justify-end pr-2 transition cursor-pointer ${activeMidiPitches&&activeMidiPitches.has(pitch)?'bg-emerald-500 text-white border-emerald-400':isBlack?'bg-zinc-950 text-slate-500 border-zinc-900 hover:bg-zinc-800':'bg-white text-zinc-800 border-r border-zinc-400 hover:bg-amber-100'}`,onMouseDown:e=>{e.stopPropagation();keybedMouseDownRef.current=true;try{if(window.triggerMidiVuActivity){window.triggerMidiVuActivity((st.parent_tab_id&&st.parent_tab_id.startsWith('session_')?'_sess_':'')+st.trackId,100);}if(window.SonicSF){window.SonicSF.playNote(pitch,100,500,undefined,kbCtx.program,null,kbCtx.ch,kbCtx.synthEngine);}// MIDI → Carla (track VSTi + ARM + Carla local): preview realtime +if(window.SonicCarlaMidi&&window.SonicCarlaMidi.shouldRoute(kbCtx.synthEngine,st.isArmed)){window.SonicCarlaMidi.noteOn(kbCtx.ch,pitch,100);if(window.__carlaKeybedTimer)clearTimeout(window.__carlaKeybedTimer);window.__carlaKeybedTimer=setTimeout(function(){window.SonicCarlaMidi.noteOff(kbCtx.ch,pitch);},650);}}catch(err){console.error('playNote error:',err);}},onMouseEnter:e=>{if(keybedMouseDownRef.current){try{if(window.triggerMidiVuActivity){window.triggerMidiVuActivity((st.parent_tab_id&&st.parent_tab_id.startsWith('session_')?'_sess_':'')+st.trackId,100);}if(window.SonicSF){window.SonicSF.playNote(pitch,100,200,undefined,kbCtx.program,null,kbCtx.ch,kbCtx.synthEngine);}if(window.SonicCarlaMidi&&window.SonicCarlaMidi.shouldRoute(kbCtx.synthEngine,st.isArmed)){window.SonicCarlaMidi.noteOn(kbCtx.ch,pitch,100);if(window.__carlaKeybedTimer)clearTimeout(window.__carlaKeybedTimer);window.__carlaKeybedTimer=setTimeout(function(){window.SonicCarlaMidi.noteOff(kbCtx.ch,pitch);},650);}}catch(err){console.error('playNote error:',err);}}},onMouseUp:()=>{keybedMouseDownRef.current=false;}},showLabel&&label));}return keys;};const handleScroll=e=>{if(keybedRef.current){keybedRef.current.scrollTop=e.currentTarget.scrollTop;}if(rulerScrollRef.current){rulerScrollRef.current.scrollLeft=e.currentTarget.scrollLeft;}if(ccWrapperRef.current){ccWrapperRef.current.scrollLeft=e.currentTarget.scrollLeft;}const el=e.currentTarget;const THRESHOLD=200;if(el.scrollLeft+el.clientWidth>=el.scrollWidth-THRESHOLD){const newBeats=rollBeatsRef.current+16;setRollBeats(newBeats);}};const handleKeybedScroll=e=>{if(gridScrollRef.current){gridScrollRef.current.scrollTop=e.currentTarget.scrollTop;}};const SCALES={"None":null,"Diatonic":{"Major":[0,2,4,5,7,9,11],"Minor":[0,2,3,5,7,8,10],"Dorian":[0,2,3,5,7,9,10],"Phrygian":[0,1,3,5,7,8,10],"Lydian":[0,2,4,6,7,9,11],"Mixolydian":[0,2,4,5,7,9,10],"Locrian":[0,1,3,5,6,8,10]},"Pentatonic":{"Major":[0,2,4,7,9],"Minor":[0,3,5,7,10],"Blues":[0,3,5,6,7,10],"Chinese":[0,2,4,7,9],"Vietnam":[0,2,6,7,10],"India":[0,2,5,7,9],"Japan":[0,2,5,7,9],"Africa":[0,3,5,7,10]},"Church":{"Dorian":[0,2,3,5,7,9,10],"Phrygian":[0,1,3,5,7,8,10],"Lydian":[0,2,4,6,7,9,11],"Mixolydian":[0,2,4,5,7,9,10]},"Jazz":{"Blues":[0,3,5,6,7,10],"Bebop":[0,2,4,5,7,9,10,11],"Diminished":[0,2,3,5,6,8,9,11]},"Asian Traditional":{"Chinese":[0,2,4,7,9],"Vietnam":[0,2,6,7,10],"Japan":[0,2,5,7,9],"India":[0,2,5,7,9],"Gamelan":[0,2,4,7,9],"Korea":[0,2,4,5,7,9]},"Middle Eastern":{"Hijaz":[0,1,4,5,7,8,11],"Nikriz":[0,1,4,5,7,8,10],"Rast":[0,2,4,5,7,8,10],"Saba":[0,1,3,4,7,8,10],"Bayati":[0,2,3,4,7,8,10]}};const[selectedScale,setSelectedScale]=React.useState(null);const selectedScaleRef=React.useRef(null);selectedScaleRef.current=selectedScale;// Root note (0-11 — C..B) — transpose scale highlight + snap (spec 20:12) const[scaleRoot,setScaleRoot]=React.useState(0);const scaleRootRef=React.useRef(0);scaleRootRef.current=scaleRoot;// Modal states: Arpeggiator / Strummer / Humanize (spec 20:12) const[arpModal,setArpModal]=React.useState(null);// { pattern, rate, octaves, gate } const[strumModal,setStrumModal]=React.useState(null);// { ms, direction } diff --git a/app/static/js/services/api.js b/app/static/js/services/api.js index 61bad4c..72cc2ff 100644 --- a/app/static/js/services/api.js +++ b/app/static/js/services/api.js @@ -76,6 +76,8 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin; setCarlaPath: (path) => apiRequest('/api/v1/system/carla-path', { method: 'POST', body: JSON.stringify({ carla_path: path }) }), // Mở native GUI VSTi trong Carla (chỉ khi runtime=desktop + có Carla local) openInCarla: (pluginName, pluginPath) => apiRequest('/api/v1/plugins/open-in-carla', { method: 'POST', body: JSON.stringify({ plugin_name: pluginName, plugin_path: pluginPath }) }), + // Gửi MIDI note (track ARM → Carla OSC) để preview VSTi realtime + carlaMidi: (payload) => apiRequest('/api/v1/plugins/carla-midi', { method: 'POST', body: JSON.stringify(payload) }), // Quick-render preview VSTi (âm thật = âm export, cùng code path) previewInstrument: (payload) => apiRequest('/api/v1/plugins/preview', { method: 'POST', body: JSON.stringify(payload) }), // Thư viện preset VST3 (.vstpreset) — cầu nối Carla → pedalboard diff --git a/app/static/js/services/runtime.js b/app/static/js/services/runtime.js index 84a08a9..2224104 100644 --- a/app/static/js/services/runtime.js +++ b/app/static/js/services/runtime.js @@ -60,3 +60,33 @@ window.SonicRuntime = window.SonicRuntime || { loaded: false, capabilities: null load(); } })(); + +// ── SonicCarlaMidi: cầu nối MIDI từ track ARM → Carla (OSC /Carla/0/note_*) ── +// Khi track dùng VSTi (synth_engine.type chứa 'vst') + được ARM + máy có Carla +// local → phím bấm trên piano roll / keybed được gửi tới Carla để phát realtime +// (Carla standalone bật OSC UDP mặc định cổng 22752). +window.SonicCarlaMidi = window.SonicCarlaMidi || { + shouldRoute: function (synthEngine, isArmed) { + try { + var c = window.SonicRuntime && window.SonicRuntime.capabilities; + if (!c || !c.features || !c.features.carla_local) return false; + if (!isArmed) return false; + var se = synthEngine || {}; + return String(se.type || '').indexOf('vst') !== -1 && !!se.plugin_id; + } catch (e) { return false; } + }, + noteOn: function (channel, note, velocity) { + if (!window.SonicAPI || !window.SonicAPI.carlaMidi) return; + window.SonicAPI.carlaMidi({ event: 'note_on', note: note, velocity: velocity || 100, channel: channel || 0 }).catch(function () {}); + }, + noteOff: function (channel, note) { + if (!window.SonicAPI || !window.SonicAPI.carlaMidi) return; + window.SonicAPI.carlaMidi({ event: 'note_off', note: note, channel: channel || 0 }).catch(function () {}); + }, + // Bật note rồi tự tắt sau durMs (preview ngắn) + playNote: function (channel, note, velocity, durMs) { + this.noteOn(channel, note, velocity); + var self = this; + setTimeout(function () { self.noteOff(channel, note); }, (durMs || 300) + 50); + } +}; diff --git a/wiki.md b/wiki.md index ac41d46..4f0ce2d 100644 --- a/wiki.md +++ b/wiki.md @@ -3046,3 +3046,8 @@ - **Tóm tắt thay đổi:** (1) Bug backend: `GET /api/v1/plugins/available` chỉ gộp VST từ `plugin_dirs` user, KHÔNG gộp soundfont → nút Synth rỗng khi soundfont nằm trong thư mục user thêm. Fix: thêm `_scan_soundfonts_in_dirs()` (walk .sf2/.sf3 + meta) và merge vào `avail["soundfonts"]`. (2) Auto-launch Carla giờ phủ TẤT CẢ bề mặt click VSTi: modal "Select Instrument" (vst_instruments), dropdown track strip (đã có), Plugin Manager tab VST + kết quả Scan (thêm nút 🎛 mỗi dòng, scan dùng path trực tiếp). (3) Plugin Manager: Add Directory / remove dir → **tự động lưu + scan** (debounce 600ms) — không cần bấm Scan tay. (4) Sync file static mới (app.precompiled.js, api.js, runtime.js, app.jsx, index.html) vào `dist/daw_engine/_internal/app/` để bản packaged nhận fix frontend. - **Các file ảnh hưởng:** `app/api/v1/plugins.py`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `dist/daw_engine/_internal/app/static/**`, `dist/daw_engine/_internal/app/templates/index.html`, `wiki.md` - **Ghi chú/Test (nếu có):** Test /available với thư mục sf2 user → soundfont xuất hiện (total 2). pytest: 86 passed, 7 skipped. Rebuild bundle BUILD OK. ⚠️ Backend fix (plugins.py) cần **rebuild PyInstaller trên Windows** (dist backend nằm trong exe); frontend đã patch sẵn trong dist. + +### [2026-08-09] Task: MIDI ARM → Carla (OSC bridge) + fix instrument soundfont từ thư mục user +- **Tóm tắt thay đổi:** (1) **MIDI keyboard → Carla realtime**: `POST /api/v1/plugins/carla-midi` (note_on/note_off) gửi OSC UDP tới `/Carla/0/note_on|note_off` (pluginId 0 = plugin auto-load qua .carxs; cổng mặc định 22752 — đã xác minh từ source Carla: CarlaEngineOsc handleMsgNoteOn/NoteOff nhận `iii`/`ii`, CarlaEngineData oscPortUDP=22752, tên client standalone "Carla" từ carla_host.py; override qua env SF_CARLA_OSC_PORT hoặc `osc_port` trong carla_path.json). Frontend: `window.SonicCarlaMidi` (runtime.js) + hook vào **keybed piano roll** (onMouseDown/onMouseEnter) và **playDrawPreview** (vẽ/click note) — chỉ route khi track VSTi + ARM + carla_local. (2) **Soundfont từ thư mục user**: endpoint `/soundfont-instruments/{sf_id}` giờ truyền plugin_dirs → `PluginManager.list_soundfont_instruments` tìm cả thư mục user (bug "nhấn tên SF không thấy instrument"); `/available` truyền extra dirs; `render_engine._find_sf2_path` tìm thêm thư mục user (trước chỉ UPLOAD + /opt/daw_engine/soundfonts + static → soundfont user render câm). +- **Các file ảnh hưởng:** `app/api/v1/plugins.py` (+carla-midi, _send_carla_osc, _carla_osc_port), `app/core/vst_engine.py`, `app/core/render_engine.py`, `app/static/js/services/runtime.js` (+SonicCarlaMidi), `app/static/js/services/api.js` (+carlaMidi), `app/static/js/app.jsx` (keybed + playDrawPreview), `app/static/js/app.precompiled.js` (rebuild), `dist/daw_engine/_internal/app/static/**`, `wiki.md` +- **Ghi chú/Test (nếu có):** pytest: 86 passed, 7 skipped. OSC: hexdump 32B = `/Carla/0/note_on` + `,iii` + [0,60,100] (big-endian int32) — khớp expected; note_off = `,ii` + [ch,note]. Rebuild bundle BUILD OK. ⚠️ Cần rebuild PyInstaller trên Windows để nhận backend fix (dist backend nằm trong exe). Lưu ý: Carla phải ĐANG MỞ để nhận OSC; nếu đổi cổng OSC trong Carla → đặt SF_CARLA_OSC_PORT.