diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index f07cab2..7afca52 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -7123,6 +7123,18 @@ const loadVstToBridge = async (track) => { return !!ok; } catch (e) { console.warn('[Bridge] loadVstToBridge fail:', track.id, e); return false; } }; +// GM font dự phòng khi soundfont track không có trong thư viện (VD id +// sf_generaluser_gs do AI sinh nhưng user chưa tải font) — ưu tiên font GM +// đầy đủ patch, fallback font đầu tiên liệt kê được. +const pickFallbackSoundfont = (list) => { + if (!list || !list.length) return null; + const hay = (s) => (String(s.id || '') + ' ' + String(s.file || '') + ' ' + String(s.name || '')).toLowerCase(); + for (const w of ['sgm', 'generaluser', 'tgsf21x', 'timgm']) { + const hit = list.find(s => hay(s).indexOf(w) !== -1); + if (hit) return hit; + } + return list[0]; +}; // D12: load track instrument (VST *hoặc* SF2) vào bridge. preloadTrackInstruments // trước đây chỉ load VST → mở project có SF2 track + bridge active → MIDI tới // channel bridge rỗng → C++ silent (không qua mastering chain native). @@ -7134,16 +7146,29 @@ const loadAnyTrackToBridge = async (track) => { const se = track.synth_engine || {}; const bank = se.soundfont_bank !== undefined ? se.soundfont_bank : (track.soundfont_bank !== undefined ? track.soundfont_bank : 0); const bch = window.SonicMidiRouter.allocateChannel(track.id || track.trackId, bank === 128); + let pl = null; try { const sfId = inst.replace('sf_', ''); let bpath = null; try { - const pl = await window.SonicAPI.listPlugins(); + pl = await window.SonicAPI.listPlugins(); const s0 = ((pl && pl.soundfonts) || []).find(s => String(s.id).replace('sf_', '') === sfId); bpath = s0 && (s0.file || s0.path); } catch (e) {} - const r = await window.SonicAPI.bridgeLoad({ name: inst, path: bpath || null, instrumentType: 'SF2', channel: bch }); - const p = r && r.path ? r.path : null; + let r = await window.SonicAPI.bridgeLoad({ name: inst, path: bpath || null, instrumentType: 'SF2', channel: bch }); + let p = r && r.path ? r.path : null; + if (!p) { + // FALLBACK: soundfont id KHÔNG tồn tại trong thư viện (AI prompt mặc + // định generaluser_gs nhưng user chưa tải font) → track câm im lặng. + // Load GM font có sẵn (SGM/GeneralUser/tgsf21x) vào cùng channel — + // __ensureBridgeProgram vẫn gửi CC0/CC32/PROGRAM → GM patch đúng. + const fb = pickFallbackSoundfont(pl && pl.soundfonts); + if (fb) { + console.warn('[Bridge] SF2 not found:', inst, '→ fallback', fb.file || fb.id, '@ch', bch); + const r2 = await window.SonicAPI.bridgeLoad({ name: inst, path: (fb.file || fb.path || null), instrumentType: 'SF2', channel: bch }); + p = r2 && r2.path ? r2.path : null; + } + } if (!p) { console.warn('[Bridge] SF2 resolve fail:', track.id, inst); return false; } const ok = await window.NativeBridgeService.loadInstrument(p, 'SF2', bch); if (ok) { if (!window.__bridgeLoadedChannels) window.__bridgeLoadedChannels = {}; window.__bridgeLoadedChannels[bch] = true; if (!window.__bridgeLoadedPaths) window.__bridgeLoadedPaths = {}; window.__bridgeLoadedPaths[bch] = p; } @@ -27993,31 +28018,13 @@ const App = () => { const c = midiCacheRef.current[t.id]; return c && c.chunks.length > 0 && c.duration >= needDur - 0.3 && cacheHasEnergy(c); }); - // MIDI chỉ render offline qua preview cache (tap sfEntry lúc play). Cache - // câm/thiếu khi routing bridge vào masterBus.input hoặc sub-node thay vì - // sfEntry → file offline đủ duration nhưng KHÔNG CÓ ÂM THANH. DAW-standard: - // project có MIDI → render OFFLINE deterministic qua server (đầy đủ MIDI + - // FX + mastering, đúng như DAW). Bounce REALTIME race không đáng tin (từng - // xuất file 1 note rồi câm / câm hẳn) → chỉ dùng làm fallback khi server lỗi. - if (midiTracks.length > 0 && exportSettings.source === 'project' && exportSettings.format === 'wav') { - if (serverStatus === 'connected') { - // Đóng export panel + hiện progress timeline (renderMasteringExport - // không tự đóng — bounce cũ có làm). - setShowExportPanel(false); - setShowExportProgress(true); - setExportProgress(-1); - try { - await renderMasteringExport(); - } finally { - setShowExportProgress(false); - } - return; - } - if (!cacheOK) { - showToast("Cache MIDI chưa đủ và server chưa kết nối — tự chuyển sang bounce REALTIME...", "info"); - await triggerBounceExport(); - return; - } + // sfEntry → file offline đủ duration nhưng KHÔNG CÓ ÂM THANH. Khi cache + // không đủ → TỰ chuyển sang bounce REALTIME (capture masterBus.output = + // đúng âm đang nghe) thay vì xuất file câm. Áp dụng Export Project (mix). + if (midiTracks.length > 0 && !cacheOK && exportSettings.source === 'project' && exportSettings.format === 'wav') { + showToast("Cache MIDI chưa đủ — tự chuyển sang bounce REALTIME để giữ nguyên âm thanh...", "info"); + await triggerBounceExport(); + return; } if (midiTracks.length > 0 && !cacheOK) { showToast("Project có MIDI items chưa đủ cache preview — file offline có thể thiếu âm MIDI. Bật Realtime để bounce trung thực.", "warning"); diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index d1fb8cc..5746b8b 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -620,10 +620,17 @@ if(!knownPath&&window.__bridgeLoadedState){const lc=window.__bridgeLoadedState.f if(knownPath&&window.__bridgeLoadedPaths[bch]===knownPath)return true;// Có path đáng tin → load thẳng qua Tauri invoke (không cần POST/auth/CORS). if(knownPath){const ok=await window.NativeBridgeService.loadInstrument(knownPath,btype,bch);if(ok){window.__bridgeLoadedChannels[bch]=true;window.__bridgeLoadedPaths[bch]=knownPath;if(!window.__bridgePluginPaths)window.__bridgePluginPaths={};window.__bridgePluginPaths[instrumentId]=knownPath;}console.log('[Bridge] auto-load track',track.id,'->',btype,'ch',bch,ok?'OK':'FAIL',knownPath);return!!ok;}// Không có path → nhờ engine resolve theo name (POST). Fail (auth/CORS) → // fallback listPlugins bên dưới. -const r=await window.SonicAPI.bridgeLoad({name:instrumentId,path:null,instrumentType:btype,channel:bch});let p=r&&r.path?r.path:null;if(!p&&window.SonicAPI.listPlugins){try{const pl=await window.SonicAPI.listPlugins();const v=(pl&&pl.vst_instruments||[]).find(x=>x.id===instrumentId||x.name===instrumentId);if(v&&v.path){p=v.path;if(v&&v.type)btype=v.type;if(!window.__bridgePluginPaths)window.__bridgePluginPaths={};window.__bridgePluginPaths[instrumentId]=v.path;}}catch(e2){console.warn('[Bridge] listPlugins fallback fail:',e2);}}if(!p)return false;const ok=await window.NativeBridgeService.loadInstrument(p,btype,bch);if(ok){window.__bridgeLoadedChannels[bch]=true;window.__bridgeLoadedPaths[bch]=p;}console.log('[Bridge] auto-load track',track.id,'->',btype,'ch',bch,ok?'OK':'FAIL',p);return!!ok;}catch(e){console.warn('[Bridge] loadVstToBridge fail:',track.id,e);return false;}};// D12: load track instrument (VST *hoặc* SF2) vào bridge. preloadTrackInstruments +const r=await window.SonicAPI.bridgeLoad({name:instrumentId,path:null,instrumentType:btype,channel:bch});let p=r&&r.path?r.path:null;if(!p&&window.SonicAPI.listPlugins){try{const pl=await window.SonicAPI.listPlugins();const v=(pl&&pl.vst_instruments||[]).find(x=>x.id===instrumentId||x.name===instrumentId);if(v&&v.path){p=v.path;if(v&&v.type)btype=v.type;if(!window.__bridgePluginPaths)window.__bridgePluginPaths={};window.__bridgePluginPaths[instrumentId]=v.path;}}catch(e2){console.warn('[Bridge] listPlugins fallback fail:',e2);}}if(!p)return false;const ok=await window.NativeBridgeService.loadInstrument(p,btype,bch);if(ok){window.__bridgeLoadedChannels[bch]=true;window.__bridgeLoadedPaths[bch]=p;}console.log('[Bridge] auto-load track',track.id,'->',btype,'ch',bch,ok?'OK':'FAIL',p);return!!ok;}catch(e){console.warn('[Bridge] loadVstToBridge fail:',track.id,e);return false;}};// GM font dự phòng khi soundfont track không có trong thư viện (VD id +// sf_generaluser_gs do AI sinh nhưng user chưa tải font) — ưu tiên font GM +// đầy đủ patch, fallback font đầu tiên liệt kê được. +const pickFallbackSoundfont=list=>{if(!list||!list.length)return null;const hay=s=>(String(s.id||'')+' '+String(s.file||'')+' '+String(s.name||'')).toLowerCase();for(const w of['sgm','generaluser','tgsf21x','timgm']){const hit=list.find(s=>hay(s).indexOf(w)!==-1);if(hit)return hit;}return list[0];};// D12: load track instrument (VST *hoặc* SF2) vào bridge. preloadTrackInstruments // trước đây chỉ load VST → mở project có SF2 track + bridge active → MIDI tới // channel bridge rỗng → C++ silent (không qua mastering chain native). -const loadAnyTrackToBridge=async track=>{if(!window.NativeBridgeService||!window.SonicMidiRouter||!window.SonicMidiRouter.isBridgeActive())return false;const inst=track.instrumentId||track.synth_engine&&track.synth_engine.plugin_id;if(!inst)return false;if(typeof inst==='string'&&inst.startsWith('sf_')){const se=track.synth_engine||{};const bank=se.soundfont_bank!==undefined?se.soundfont_bank:track.soundfont_bank!==undefined?track.soundfont_bank:0;const bch=window.SonicMidiRouter.allocateChannel(track.id||track.trackId,bank===128);try{const sfId=inst.replace('sf_','');let bpath=null;try{const pl=await window.SonicAPI.listPlugins();const s0=(pl&&pl.soundfonts||[]).find(s=>String(s.id).replace('sf_','')===sfId);bpath=s0&&(s0.file||s0.path);}catch(e){}const r=await window.SonicAPI.bridgeLoad({name:inst,path:bpath||null,instrumentType:'SF2',channel:bch});const p=r&&r.path?r.path:null;if(!p){console.warn('[Bridge] SF2 resolve fail:',track.id,inst);return false;}const ok=await window.NativeBridgeService.loadInstrument(p,'SF2',bch);if(ok){if(!window.__bridgeLoadedChannels)window.__bridgeLoadedChannels={};window.__bridgeLoadedChannels[bch]=true;if(!window.__bridgeLoadedPaths)window.__bridgeLoadedPaths={};window.__bridgeLoadedPaths[bch]=p;}console.log('[Bridge] preload SF2 track',track.id,'-> ch',bch,ok?'OK':'FAIL',p);return!!ok;}catch(e){console.warn('[Bridge] preload SF2 fail:',track.id,e);return false;}}try{return await loadVstToBridge(track);}catch(e){return false;}};const ensureAndOpenVstGui=async track=>{const trackId=track.id||track.trackId;const instrumentId=track.instrumentId||track.synth_engine&&track.synth_engine.plugin_id;if(!instrumentId||typeof instrumentId==='string'&&instrumentId.startsWith('sf_'))return;if(!window.__bridgeLoadedChannels)window.__bridgeLoadedChannels={};let bch=window.SonicMidiRouter&&window.SonicMidiRouter.allocateChannel?window.SonicMidiRouter.allocateChannel(trackId,false):0;// LAN/standalone (browser không Tauri): NativeBridgeService chỉ log, không +const loadAnyTrackToBridge=async track=>{if(!window.NativeBridgeService||!window.SonicMidiRouter||!window.SonicMidiRouter.isBridgeActive())return false;const inst=track.instrumentId||track.synth_engine&&track.synth_engine.plugin_id;if(!inst)return false;if(typeof inst==='string'&&inst.startsWith('sf_')){const se=track.synth_engine||{};const bank=se.soundfont_bank!==undefined?se.soundfont_bank:track.soundfont_bank!==undefined?track.soundfont_bank:0;const bch=window.SonicMidiRouter.allocateChannel(track.id||track.trackId,bank===128);let pl=null;try{const sfId=inst.replace('sf_','');let bpath=null;try{pl=await window.SonicAPI.listPlugins();const s0=(pl&&pl.soundfonts||[]).find(s=>String(s.id).replace('sf_','')===sfId);bpath=s0&&(s0.file||s0.path);}catch(e){}let r=await window.SonicAPI.bridgeLoad({name:inst,path:bpath||null,instrumentType:'SF2',channel:bch});let p=r&&r.path?r.path:null;if(!p){// FALLBACK: soundfont id KHÔNG tồn tại trong thư viện (AI prompt mặc +// định generaluser_gs nhưng user chưa tải font) → track câm im lặng. +// Load GM font có sẵn (SGM/GeneralUser/tgsf21x) vào cùng channel — +// __ensureBridgeProgram vẫn gửi CC0/CC32/PROGRAM → GM patch đúng. +const fb=pickFallbackSoundfont(pl&&pl.soundfonts);if(fb){console.warn('[Bridge] SF2 not found:',inst,'→ fallback',fb.file||fb.id,'@ch',bch);const r2=await window.SonicAPI.bridgeLoad({name:inst,path:fb.file||fb.path||null,instrumentType:'SF2',channel:bch});p=r2&&r2.path?r2.path:null;}}if(!p){console.warn('[Bridge] SF2 resolve fail:',track.id,inst);return false;}const ok=await window.NativeBridgeService.loadInstrument(p,'SF2',bch);if(ok){if(!window.__bridgeLoadedChannels)window.__bridgeLoadedChannels={};window.__bridgeLoadedChannels[bch]=true;if(!window.__bridgeLoadedPaths)window.__bridgeLoadedPaths={};window.__bridgeLoadedPaths[bch]=p;}console.log('[Bridge] preload SF2 track',track.id,'-> ch',bch,ok?'OK':'FAIL',p);return!!ok;}catch(e){console.warn('[Bridge] preload SF2 fail:',track.id,e);return false;}}try{return await loadVstToBridge(track);}catch(e){return false;}};const ensureAndOpenVstGui=async track=>{const trackId=track.id||track.trackId;const instrumentId=track.instrumentId||track.synth_engine&&track.synth_engine.plugin_id;if(!instrumentId||typeof instrumentId==='string'&&instrumentId.startsWith('sf_'))return;if(!window.__bridgeLoadedChannels)window.__bridgeLoadedChannels={};let bch=window.SonicMidiRouter&&window.SonicMidiRouter.allocateChannel?window.SonicMidiRouter.allocateChannel(trackId,false):0;// LAN/standalone (browser không Tauri): NativeBridgeService chỉ log, không // invoke được open_vst_gui → nhờ engine đẩy LOAD+OPEN_GUI vào SHM qua route // /bridge/gui (E6). GUI hiện trên desktop máy chạy server. if(!window.SonicMidiRouter||!window.SonicMidiRouter.isBridgeActive()||!window.NativeBridgeService){try{if(!window.SonicAPI||!window.SonicAPI.bridgeOpenGui)return;const r=await window.SonicAPI.bridgeOpenGui({name:instrumentId,path:null,instrumentType:null,channel:bch});if(r&&r.success)console.log('[Bridge] GUI open via API',instrumentId,'ch',bch,r.path);else console.warn('[Bridge] GUI via API response:',r);}catch(e){console.warn('[Bridge] GUI via API fail:',trackId,e);}return;}// V9: load (nếu chưa có ĐÚNG instrument) rồi CHỈ mở GUI khi load OK — không @@ -2117,15 +2124,10 @@ const triggerWavExport=async()=>{// Stop transport ngay khi bắt đầu export try{if(typeof stopAllPlayback==='function')stopAllPlayback();}catch(e){}try{if(window.SonicSF&&window.SonicSF.stopAllNotes)window.SonicSF.stopAllNotes();}catch(e){}// Realtime UNCHECKED → export OFFLINE thuần (server-side nếu connected, fallback client). // MIDI không render được trong OfflineAudioContext: nếu project có MIDI items mà // cache preview chưa đủ → file xuất có thể thiếu âm MIDI. Cảnh báo, vẫn xuất offline. -const allT=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:tracks;const hasMidiItems=allT.some(t=>t.midiItems&&t.midiItems.length>0)||(sessionTabs||[]).some(s=>(s.tracks||[]).some(t=>t.midiItems&&t.midiItems.length>0));if(hasMidiItems){const midiTracks=allT.filter(t=>t.midiItems&&t.midiItems.length>0&&!t.muted);let needDur=1.0;midiTracks.forEach(t=>{(t.midiItems||[]).forEach(m=>{if(m.endTime)needDur=Math.max(needDur,m.endTime);else if(m.startTime)needDur=Math.max(needDur,m.startTime+(m.duration||8));});});const cacheHasEnergy=c=>{if(!c)return false;const chunks=c.chunks||[];for(let i=0;i0.0005)return true;}}return false;};const cacheOK=midiTracks.length>0&&midiTracks.every(t=>{const c=midiCacheRef.current[t.id];return c&&c.chunks.length>0&&c.duration>=needDur-0.3&&cacheHasEnergy(c);});// MIDI chỉ render offline qua preview cache (tap sfEntry lúc play). Cache -// câm/thiếu khi routing bridge vào masterBus.input hoặc sub-node thay vì -// sfEntry → file offline đủ duration nhưng KHÔNG CÓ ÂM THANH. DAW-standard: -// project có MIDI → render OFFLINE deterministic qua server (đầy đủ MIDI + -// FX + mastering, đúng như DAW). Bounce REALTIME race không đáng tin (từng -// xuất file 1 note rồi câm / câm hẳn) → chỉ dùng làm fallback khi server lỗi. -if(midiTracks.length>0&&exportSettings.source==='project'&&exportSettings.format==='wav'){if(serverStatus==='connected'){// Đóng export panel + hiện progress timeline (renderMasteringExport -// không tự đóng — bounce cũ có làm). -setShowExportPanel(false);setShowExportProgress(true);setExportProgress(-1);try{await renderMasteringExport();}finally{setShowExportProgress(false);}return;}if(!cacheOK){showToast("Cache MIDI chưa đủ và server chưa kết nối — tự chuyển sang bounce REALTIME...","info");await triggerBounceExport();return;}}if(midiTracks.length>0&&!cacheOK){showToast("Project có MIDI items chưa đủ cache preview — file offline có thể thiếu âm MIDI. Bật Realtime để bounce trung thực.","warning");}}let exportTracks;let clipStart=0;let clipEnd=0;const src=exportSettings.source;if(src==='active_clip'||src==='clip_selection'){const selTrack=selectedTrackId?tracks.find(t=>t.id===selectedTrackId):null;if(!selTrack||!selTrack.buffer){showToast("Không có clip nào được chọn.","warning");return;}let rangeStart=selectionStart;let rangeEnd=selectionEnd;if(rangeStart===null||rangeEnd===null||rangeEnd<=rangeStart){rangeStart=0;rangeEnd=selTrack.buffer.duration;}const ctx=getAudioContext();const numCh=selTrack.buffer.numberOfChannels||1;const sr=selTrack.buffer.sampleRate;const startSample=Math.max(0,Math.floor(rangeStart*sr));const endSample=Math.min(selTrack.buffer.length,Math.floor(rangeEnd*sr));const len=endSample-startSample;if(len<=100){showToast("Vùng chọn quá ngắn hoặc không có dữ liệu.","warning");return;}const clipBuf=ctx.createBuffer(numCh,len,sr);for(let ch=0;ch!t.muted&&(t.buffer||t.midiItems&&t.midiItems.length>0));const selTrk=selectedTrackId?sel.filter(t=>t.id===selectedTrackId):sel;if(selTrk.length===0){showToast("Track được chọn không có dữ liệu.","warning");return;}exportTracks=selTrk;}else{exportTracks=tracks.filter(t=>!t.muted&&(t.buffer||t.midiItems&&t.midiItems.length>0));}if(exportTracks.length===0){showToast("Không tìm thấy dữ liệu âm thanh hợp lệ để xuất.","warning");return;}// LOOP export (BIG_PHASE_3 Phase 2): cắt chính xác vùng loop — bắt đầu từ +const allT=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:tracks;const hasMidiItems=allT.some(t=>t.midiItems&&t.midiItems.length>0)||(sessionTabs||[]).some(s=>(s.tracks||[]).some(t=>t.midiItems&&t.midiItems.length>0));if(hasMidiItems){const midiTracks=allT.filter(t=>t.midiItems&&t.midiItems.length>0&&!t.muted);let needDur=1.0;midiTracks.forEach(t=>{(t.midiItems||[]).forEach(m=>{if(m.endTime)needDur=Math.max(needDur,m.endTime);else if(m.startTime)needDur=Math.max(needDur,m.startTime+(m.duration||8));});});const cacheHasEnergy=c=>{if(!c)return false;const chunks=c.chunks||[];for(let i=0;i0.0005)return true;}}return false;};const cacheOK=midiTracks.length>0&&midiTracks.every(t=>{const c=midiCacheRef.current[t.id];return c&&c.chunks.length>0&&c.duration>=needDur-0.3&&cacheHasEnergy(c);});// sfEntry → file offline đủ duration nhưng KHÔNG CÓ ÂM THANH. Khi cache +// không đủ → TỰ chuyển sang bounce REALTIME (capture masterBus.output = +// đúng âm đang nghe) thay vì xuất file câm. Áp dụng Export Project (mix). +if(midiTracks.length>0&&!cacheOK&&exportSettings.source==='project'&&exportSettings.format==='wav'){showToast("Cache MIDI chưa đủ — tự chuyển sang bounce REALTIME để giữ nguyên âm thanh...","info");await triggerBounceExport();return;}if(midiTracks.length>0&&!cacheOK){showToast("Project có MIDI items chưa đủ cache preview — file offline có thể thiếu âm MIDI. Bật Realtime để bounce trung thực.","warning");}}let exportTracks;let clipStart=0;let clipEnd=0;const src=exportSettings.source;if(src==='active_clip'||src==='clip_selection'){const selTrack=selectedTrackId?tracks.find(t=>t.id===selectedTrackId):null;if(!selTrack||!selTrack.buffer){showToast("Không có clip nào được chọn.","warning");return;}let rangeStart=selectionStart;let rangeEnd=selectionEnd;if(rangeStart===null||rangeEnd===null||rangeEnd<=rangeStart){rangeStart=0;rangeEnd=selTrack.buffer.duration;}const ctx=getAudioContext();const numCh=selTrack.buffer.numberOfChannels||1;const sr=selTrack.buffer.sampleRate;const startSample=Math.max(0,Math.floor(rangeStart*sr));const endSample=Math.min(selTrack.buffer.length,Math.floor(rangeEnd*sr));const len=endSample-startSample;if(len<=100){showToast("Vùng chọn quá ngắn hoặc không có dữ liệu.","warning");return;}const clipBuf=ctx.createBuffer(numCh,len,sr);for(let ch=0;ch!t.muted&&(t.buffer||t.midiItems&&t.midiItems.length>0));const selTrk=selectedTrackId?sel.filter(t=>t.id===selectedTrackId):sel;if(selTrk.length===0){showToast("Track được chọn không có dữ liệu.","warning");return;}exportTracks=selTrk;}else{exportTracks=tracks.filter(t=>!t.muted&&(t.buffer||t.midiItems&&t.midiItems.length>0));}if(exportTracks.length===0){showToast("Không tìm thấy dữ liệu âm thanh hợp lệ để xuất.","warning");return;}// LOOP export (BIG_PHASE_3 Phase 2): cắt chính xác vùng loop — bắt đầu từ // selection (nếu có) hoặc playhead, độ dài = loopBars * secondsPerBar. // Không fade in/out, không kéo đuôi (cắt cứng tại biên). let loopStart=0,loopDur=0;if(exportSettings.loop){const loopSecPerBar=60.0/(parseFloat(bpm)||120)*4;loopStart=selectionStart!==null&&selectionEnd!==null&&selectionEnd>selectionStart?selectionStart:currentTime;loopDur=Math.max(0.1,(exportSettings.loopBars||4)*loopSecPerBar);const lctx=getAudioContext();exportTracks=exportTracks.map(t=>{if(!t.buffer)return null;const ls=Math.max(0,Math.floor(loopStart*lctx.sampleRate));const le=Math.min(t.buffer.length,Math.ceil((loopStart+loopDur)*lctx.sampleRate));if(le<=ls)return null;const buf=lctx.createBuffer(t.buffer.numberOfChannels,le-ls,t.buffer.sampleRate);for(let ch=0;ch - +