From 574c2f885dad851889e172430284377e4f69c455 Mon Sep 17 00:00:00 2001 From: locphamtran Date: Sun, 16 Aug 2026 16:42:19 +0700 Subject: [PATCH] fix(media-explorer): preview MIDI qua native bridge FluidSynth thay soundfontRender 501 - playMidiPreview standalone: thay soundfontRender backend (pyfluidsynth khong co trong bundle -> 501 -> cam) bang playMediaExplorerBridgePreview: allocate channel rieng, bridgeLoad SF2 + loadInstrument, CC0/CC32/PROGRAM, NOTE_ON/OFF theo beat, loop timer; bridge down -> fallback WASM - stopMediaPlayback: NOTE_OFF toan pitch cho channel preview de note khong con ngan sau khi stop --- app/static/js/app.jsx | 121 ++++++++++++++++++++++++------- app/static/js/app.precompiled.js | 20 +++-- 2 files changed, 110 insertions(+), 31 deletions(-) diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index f545074..e9bd63f 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -14233,6 +14233,69 @@ const MediaExplorerPanel = ({ height, clipboardRef, active }) => { }).filter(group => group.presets.length > 0); }, [synthList, synthFilter]); + // Media Explorer preview qua native bridge (FluidSynth trong daw_vst_bridge): + // thay soundfontRender backend — backend trả 501 khi pyfluidsynth không có + // trong bundle (engine.log: "pyfluidsynth không khả dụng") → playMidiPreview + // return im lặng → autoplay câm. Bridge load SF2 vào channel riêng rồi + // pushEvent NOTE_ON/OFF theo beat (giống scheduleMidiNoteDispatch cho track). + const _mePreview = { sfId: null, ch: null }; + const playMediaExplorerBridgePreview = async (notes, o) => { + try { + const isPerc = !!(o.bank === 128); + const ch = window.SonicMidiRouter.allocateChannel('media_explorer', isPerc); + const sfName = String(o.sfId || '').replace(/^sf_/, ''); + if (!sfName) return false; + const needLoad = _mePreview.sfId !== sfName || _mePreview.ch !== ch; + if (needLoad) { + const r = await window.SonicAPI.bridgeLoad({ name: sfName, instrumentType: 'SF2', channel: ch }); + if (!r || !r.path) return false; + const ok = await window.NativeBridgeService.loadInstrument(r.path, 'SF2', ch); + if (!ok) return false; + _mePreview.sfId = sfName; + _mePreview.ch = ch; + } + window.SonicMidiRouter.pushEvent({ cmd: 'CC', channel: ch, pitch: 0, velocity: 0, data2: ((o.bank || 0) >> 7) & 0x7F, percussion: isPerc }); + window.SonicMidiRouter.pushEvent({ cmd: 'CC', channel: ch, pitch: 32, velocity: 0, data2: (o.bank || 0) & 0x7F, percussion: isPerc }); + window.SonicMidiRouter.pushEvent({ cmd: 'PROGRAM', channel: ch, pitch: 0, velocity: 0, data2: o.prog || 0, percussion: isPerc }); + const ctx = getAudioContext(); + const beatSec = 60.0 / (o.bpm || 120); + // Load SF2 vào bridge bất đồng bộ → note đầu trễ thêm khi load mới. + const passStart = ctx.currentTime + (needLoad ? 0.5 : 0.08); + const schedulePassBridge = (passStartTime) => { + notes.forEach(note => { + const nStart = note.start_beat || 0; + if (o.hasSelection && (nStart < o.loopStartBeats || nStart >= o.loopEndBeats)) return; + const shifted = o.hasSelection ? (nStart - o.loopStartBeats) : nStart; + const startSec = shifted * beatSec; + const durMs = Math.max(80, (note.duration_beats || 1) * beatSec * 1000); + const at = passStartTime + startSec; + const delayMs = Math.max(0, (at - ctx.currentTime) * 1000); + const pitch = note.pitch || 60; + const vel = note.velocity != null ? note.velocity : 0.8; + window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_ON', channel: ch, pitch: pitch, velocity: vel, percussion: isPerc, live: true }); + setTimeout(() => { + if (selectTokenRef.current !== (o.token || selectTokenRef.current)) return; + window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_OFF', channel: ch, pitch: pitch, velocity: 0, percussion: isPerc, live: true }); + }, delayMs + durMs + 30); + }); + }; + schedulePassBridge(passStart); + if (loopTimerRef.current) { clearInterval(loopTimerRef.current); loopTimerRef.current = null; } + if (o.isLooping) { + loopTimerRef.current = setInterval(() => { + if (selectTokenRef.current !== (o.token || selectTokenRef.current)) { clearInterval(loopTimerRef.current); loopTimerRef.current = null; return; } + if (!isPlayingRef.current || isPausedRef.current) return; + if (!isLoopingRef.current) { clearInterval(loopTimerRef.current); loopTimerRef.current = null; return; } + schedulePassBridge(ctx.currentTime + 0.05); + }, Math.max(200, o.loopDurationSec * 1000)); + } + return true; + } catch (e) { + console.warn('[MediaExplorer] bridge preview fail → WASM:', e); + return false; + } + }; + const playMidiPreview = async (f, token) => { // Play real MIDI file through selected synth instrument (SonicSF) if (!f || !window.SonicSF) return; @@ -14291,31 +14354,29 @@ const MediaExplorerPanel = ({ height, clipboardRef, active }) => { allNotes.push(Object.assign({}, note, { trackOffset: track.startTime || 0 })); }); }); - // Standalone + instrument soundfont → render CẢ FILE bằng native - // FluidSynth (backend) → play WAV (loop theo flag/lựa chọn), bỏ per-note - // WASM (WASM không phải luồng âm của standalone). - if (isStandaloneSf() && sfId) { - const nativeNotes = allNotes - .filter(note => !(hasSelection && ((note.start_beat || 0) < loopStartBeats || (note.start_beat || 0) >= loopEndBeats))) - .map(note => { - const shiftedStartBeat = hasSelection ? ((note.start_beat || 0) - loopStartBeats) : (note.start_beat || 0); - return { - pitch: note.pitch || 60, - start_beat: shiftedStartBeat + ((note.trackOffset || 0) / secondsPerBeat), - duration_beats: note.duration_beats || 1, - velocity: note.velocity != null ? note.velocity : 0.8 - }; - }); - if (nativeNotes.length) { - window.SonicAPI.soundfontRender({ soundfont_id: sfId, bank: bank, program: prog !== undefined ? prog : 0, bpm: bpmVal, notes: nativeNotes }).then(function (res) { - if (!res || !res.success || !res.url) return; - if (selectTokenRef.current !== (token || selectTokenRef.current)) return; - const audio = new Audio(API_BASE_URL + res.url); - audio.loop = !!isLoopingRef.current; - _nativeSfPreviews['midifile'] = { audio: audio, token: selectTokenRef.current }; - audio.play().catch(function () {}); - }); - } + // Standalone + instrument soundfont → preview qua native bridge + // (FluidSynth trong daw_vst_bridge): thay soundfontRender backend (501 khi + // pyfluidsynth không có trong bundle) → câm. Bridge down → fallback WASM. + const nativeNotes = allNotes + .filter(note => !(hasSelection && ((note.start_beat || 0) < loopStartBeats || (note.start_beat || 0) >= loopEndBeats))) + .map(note => { + const shiftedStartBeat = hasSelection ? ((note.start_beat || 0) - loopStartBeats) : (note.start_beat || 0); + return { + pitch: note.pitch || 60, + start_beat: shiftedStartBeat + ((note.trackOffset || 0) / secondsPerBeat), + duration_beats: note.duration_beats || 1, + velocity: note.velocity != null ? note.velocity : 0.8 + }; + }); + let bridgeScheduled = false; + if (nativeNotes.length && sfId && window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive() && window.NativeBridgeService) { + bridgeScheduled = await playMediaExplorerBridgePreview(nativeNotes, { + sfId: sfId, bank: bank, prog: prog, bpm: bpmVal, + hasSelection: hasSelection, loopStartBeats: loopStartBeats, loopEndBeats: loopEndBeats, + loopDurationSec: loopDurationSec, token: token, isLooping: !!isLoopingRef.current + }); + } + if (bridgeScheduled) { const startedAtTime = startWallTime - loopStartSec; playStateRef.current = { source: null, ctx, startedAt: startedAtTime, fakeStart: startedAtTime, midiTotal: totalSec }; setMidiNotes(allNotes); @@ -14328,6 +14389,7 @@ const MediaExplorerPanel = ({ height, clipboardRef, active }) => { startCanvasClock(); return; } + // bridge down / chưa load → rơi xuống schedulePass WASM bên dưới const schedulePass = (passStartTime) => { allNotes.forEach(note => { const noteStartBeat = note.start_beat || 0; @@ -14380,6 +14442,15 @@ const MediaExplorerPanel = ({ height, clipboardRef, active }) => { if (window.SonicSF && typeof window.SonicSF.stopAll === 'function') { try { window.SonicSF.stopAll(); } catch (e) {} } + // Dừng note bridge đang ngân từ Media Explorer preview (NOTE_OFF toàn pitch). + if (_mePreview.ch !== null && window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()) { + try { + const ch = _mePreview.ch; + for (let p = 0; p < 128; p++) { + window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_OFF', channel: ch, pitch: p, velocity: 0, percussion: ch === 9, live: true }); + } + } catch (e) {} + } setIsPlaying(false); setIsPaused(false); }, []); diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index 73f1db3..df21a50 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -744,7 +744,13 @@ browseComputerDir(parts.length?parts.join('\\'):computerPath.split(/[\\/]/)[0]+' // resolveMediaExplorerDropFile cũng dùng cùng endpoint). if(f&&f.kind==='midi'&&f.name)return`${API_BASE_URL}/static/midi/${encodeURIComponent(f.name)}`;const fid=f&&(f.file_id||f.fileId);return fid?`${API_BASE_URL}/api/v1/audio/download/${fid}`:null;};const toggleSynthDropdown=()=>{if(synthOpen){setSynthOpen(false);return;}setSynthFilter('');// Clear search filter on open setSynthOpen(true);if(synthListRef.current)return;setSynthLoading(true);(window.SonicAPI&&window.SonicAPI.listPlugins?window.SonicAPI.listPlugins():Promise.resolve({soundfonts:[]})).then(async data=>{const sfonts=data&&data.soundfonts||[];if(!sfonts.length){setSynthList([]);setSynthLoading(false);return;}const results=await Promise.all(sfonts.map(sf=>{const baseId=String(sf.id||'').replace('sf_','');return(window.SonicAPI.listSoundfontInstruments?window.SonicAPI.listSoundfontInstruments(baseId):Promise.resolve({presets:[]})).then(r=>({sf,presets:r&&r.presets||[]})).catch(()=>({sf,presets:[]}));}));setSynthList(results);setSynthLoading(false);}).catch(()=>{setSynthList([]);setSynthLoading(false);});};const selectSynthInst=inst=>{setSynthInst(inst);synthInstRef.current=inst;setSynthOpen(false);localStorage.setItem('studio_media_explorer_synth',JSON.stringify(inst));// Realtime: re-schedule current MIDI preview with the newly selected instrument -const cur=selectedRef.current;if(isPlayingRef.current&&cur&&isMidiFile(cur)&&(cur.handle||cur.path||cur.file_id||cur.fileId||cur.kind==='midi'&&cur.name)){selectTokenRef.current++;const token=selectTokenRef.current;try{if(window.SonicMidiRouter)window.SonicMidiRouter.panic();else if(window.SonicSF&&typeof window.SonicSF.stopAll==='function')window.SonicSF.stopAll();}catch(e){}stopAllNativeSfNotes();playMidiPreview(cur,token);}};const filteredSynthList=React.useMemo(()=>{if(!synthList)return[];if(!synthFilter.trim())return synthList;const query=synthFilter.toLowerCase().trim();return synthList.map(group=>{const presets=(group.presets||[]).filter(p=>(p.name||'').toLowerCase().includes(query)||String(p.program).includes(query));return{...group,presets};}).filter(group=>group.presets.length>0);},[synthList,synthFilter]);const playMidiPreview=async(f,token)=>{// Play real MIDI file through selected synth instrument (SonicSF) +const cur=selectedRef.current;if(isPlayingRef.current&&cur&&isMidiFile(cur)&&(cur.handle||cur.path||cur.file_id||cur.fileId||cur.kind==='midi'&&cur.name)){selectTokenRef.current++;const token=selectTokenRef.current;try{if(window.SonicMidiRouter)window.SonicMidiRouter.panic();else if(window.SonicSF&&typeof window.SonicSF.stopAll==='function')window.SonicSF.stopAll();}catch(e){}stopAllNativeSfNotes();playMidiPreview(cur,token);}};const filteredSynthList=React.useMemo(()=>{if(!synthList)return[];if(!synthFilter.trim())return synthList;const query=synthFilter.toLowerCase().trim();return synthList.map(group=>{const presets=(group.presets||[]).filter(p=>(p.name||'').toLowerCase().includes(query)||String(p.program).includes(query));return{...group,presets};}).filter(group=>group.presets.length>0);},[synthList,synthFilter]);// Media Explorer preview qua native bridge (FluidSynth trong daw_vst_bridge): +// thay soundfontRender backend — backend trả 501 khi pyfluidsynth không có +// trong bundle (engine.log: "pyfluidsynth không khả dụng") → playMidiPreview +// return im lặng → autoplay câm. Bridge load SF2 vào channel riêng rồi +// pushEvent NOTE_ON/OFF theo beat (giống scheduleMidiNoteDispatch cho track). +const _mePreview={sfId:null,ch:null};const playMediaExplorerBridgePreview=async(notes,o)=>{try{const isPerc=!!(o.bank===128);const ch=window.SonicMidiRouter.allocateChannel('media_explorer',isPerc);const sfName=String(o.sfId||'').replace(/^sf_/,'');if(!sfName)return false;const needLoad=_mePreview.sfId!==sfName||_mePreview.ch!==ch;if(needLoad){const r=await window.SonicAPI.bridgeLoad({name:sfName,instrumentType:'SF2',channel:ch});if(!r||!r.path)return false;const ok=await window.NativeBridgeService.loadInstrument(r.path,'SF2',ch);if(!ok)return false;_mePreview.sfId=sfName;_mePreview.ch=ch;}window.SonicMidiRouter.pushEvent({cmd:'CC',channel:ch,pitch:0,velocity:0,data2:(o.bank||0)>>7&0x7F,percussion:isPerc});window.SonicMidiRouter.pushEvent({cmd:'CC',channel:ch,pitch:32,velocity:0,data2:(o.bank||0)&0x7F,percussion:isPerc});window.SonicMidiRouter.pushEvent({cmd:'PROGRAM',channel:ch,pitch:0,velocity:0,data2:o.prog||0,percussion:isPerc});const ctx=getAudioContext();const beatSec=60.0/(o.bpm||120);// Load SF2 vào bridge bất đồng bộ → note đầu trễ thêm khi load mới. +const passStart=ctx.currentTime+(needLoad?0.5:0.08);const schedulePassBridge=passStartTime=>{notes.forEach(note=>{const nStart=note.start_beat||0;if(o.hasSelection&&(nStart=o.loopEndBeats))return;const shifted=o.hasSelection?nStart-o.loopStartBeats:nStart;const startSec=shifted*beatSec;const durMs=Math.max(80,(note.duration_beats||1)*beatSec*1000);const at=passStartTime+startSec;const delayMs=Math.max(0,(at-ctx.currentTime)*1000);const pitch=note.pitch||60;const vel=note.velocity!=null?note.velocity:0.8;window.SonicMidiRouter.pushEvent({cmd:'NOTE_ON',channel:ch,pitch:pitch,velocity:vel,percussion:isPerc,live:true});setTimeout(()=>{if(selectTokenRef.current!==(o.token||selectTokenRef.current))return;window.SonicMidiRouter.pushEvent({cmd:'NOTE_OFF',channel:ch,pitch:pitch,velocity:0,percussion:isPerc,live:true});},delayMs+durMs+30);});};schedulePassBridge(passStart);if(loopTimerRef.current){clearInterval(loopTimerRef.current);loopTimerRef.current=null;}if(o.isLooping){loopTimerRef.current=setInterval(()=>{if(selectTokenRef.current!==(o.token||selectTokenRef.current)){clearInterval(loopTimerRef.current);loopTimerRef.current=null;return;}if(!isPlayingRef.current||isPausedRef.current)return;if(!isLoopingRef.current){clearInterval(loopTimerRef.current);loopTimerRef.current=null;return;}schedulePassBridge(ctx.currentTime+0.05);},Math.max(200,o.loopDurationSec*1000));}return true;}catch(e){console.warn('[MediaExplorer] bridge preview fail → WASM:',e);return false;}};const playMidiPreview=async(f,token)=>{// Play real MIDI file through selected synth instrument (SonicSF) if(!f||!window.SonicSF)return;try{const buf=await readLocalFileBuffer(f);if(!buf)return;if(selectTokenRef.current!==(token||selectTokenRef.current))return;const midiResult=(typeof parseMidiFile==='function'?parseMidiFile:window.parseMidiFile)(buf);if(!midiResult||!midiResult.length)return;// Feature: selecting/playing a MIDI file auto-sets the playback tempo to // the file's own BPM (so the preview plays in time). const fileBpm=midiResult[0]&&midiResult[0].bpm||120;const newTempo=Math.max(40,Math.min(300,Math.round(fileBpm)));setTempo(newTempo);setTempoText(String(newTempo));tempoRef.current=newTempo;try{localStorage.setItem('studio_media_explorer_tempo',String(newTempo));}catch(e){}const ctx=getAudioContext();// Đồng bộ mastering + routing SF trước khi preview MIDI file (âm qua @@ -755,12 +761,14 @@ try{if(window.__ensureMasteringRouting)window.__ensureMasteringRouting();}catch( // instrument khi track khác đang dùng chung channel). let pvCh=0;if(curInst&&window.SonicSF.applyAITrackInstrument){try{pvCh=window.SonicSF.applyAITrackInstrument(bank,program,{soundfont_id:sfId,soundfont_bank:bank,soundfont_program:program!==undefined?program:0})||0;}catch(e2){pvCh=0;}}else if(curInst&&window.SonicSF.selectInstrument){try{await window.SonicSF.selectInstrument(pvCh,bank,program,sfId);}catch(e2){}}// Re-check token after the async await — stale playMidiPreview (older file) // must not schedule notes over the newly selected file. -if(selectTokenRef.current!==(token||selectTokenRef.current))return;const prog=curInst&&curInst.program!==undefined?curInst.program:undefined;const eng=curInst?{soundfont_id:sfId,soundfont_bank:bank,soundfont_program:prog!==undefined?prog:0}:undefined;const totalSec=midiResult[0].duration||4;const totalBeats=midiResult[0].totalBeats||16;const hasSelection=selStart!==null&&selEnd!==null&&Math.abs(selStart-selEnd)>0.01;const loopStartBeats=hasSelection?Math.min(selStart,selEnd):0;const loopEndBeats=hasSelection?Math.max(selStart,selEnd):totalBeats;const loopDurationBeats=loopEndBeats-loopStartBeats;const loopDurationSec=loopDurationBeats*secondsPerBeat;const loopStartSec=loopStartBeats*secondsPerBeat;const allNotes=[];midiResult.forEach(track=>{(track.notes||[]).forEach(note=>{allNotes.push(Object.assign({},note,{trackOffset:track.startTime||0}));});});// Standalone + instrument soundfont → render CẢ FILE bằng native -// FluidSynth (backend) → play WAV (loop theo flag/lựa chọn), bỏ per-note -// WASM (WASM không phải luồng âm của standalone). -if(isStandaloneSf()&&sfId){const nativeNotes=allNotes.filter(note=>!(hasSelection&&((note.start_beat||0)=loopEndBeats))).map(note=>{const shiftedStartBeat=hasSelection?(note.start_beat||0)-loopStartBeats:note.start_beat||0;return{pitch:note.pitch||60,start_beat:shiftedStartBeat+(note.trackOffset||0)/secondsPerBeat,duration_beats:note.duration_beats||1,velocity:note.velocity!=null?note.velocity:0.8};});if(nativeNotes.length){window.SonicAPI.soundfontRender({soundfont_id:sfId,bank:bank,program:prog!==undefined?prog:0,bpm:bpmVal,notes:nativeNotes}).then(function(res){if(!res||!res.success||!res.url)return;if(selectTokenRef.current!==(token||selectTokenRef.current))return;const audio=new Audio(API_BASE_URL+res.url);audio.loop=!!isLoopingRef.current;_nativeSfPreviews['midifile']={audio:audio,token:selectTokenRef.current};audio.play().catch(function(){});});}const startedAtTime=startWallTime-loopStartSec;playStateRef.current={source:null,ctx,startedAt:startedAtTime,fakeStart:startedAtTime,midiTotal:totalSec};setMidiNotes(allNotes);setMidiTotal(totalSec);setMidiBars(midiResult[0].bars||1);setMidiTotalBeats(midiResult[0].totalBeats||16);setMidiFileBpm(midiResult[0].bpm||120);setIsPlaying(true);setIsPaused(false);startCanvasClock();return;}const schedulePass=passStartTime=>{allNotes.forEach(note=>{const noteStartBeat=note.start_beat||0;if(hasSelection){if(noteStartBeat=loopEndBeats)return;}const shiftedStartBeat=hasSelection?noteStartBeat-loopStartBeats:noteStartBeat;const startSec=shiftedStartBeat*secondsPerBeat+(note.trackOffset||0);const durMs=Math.max(80,(note.duration_beats||1)*secondsPerBeat*1000);window.SonicSF.playNote(note.pitch||60,note.velocity||0.8,durMs,passStartTime+startSec,prog,null,pvCh,eng);});};schedulePass(startWallTime);// Loop scheduling: keep looping continuously until Stop is pressed. +if(selectTokenRef.current!==(token||selectTokenRef.current))return;const prog=curInst&&curInst.program!==undefined?curInst.program:undefined;const eng=curInst?{soundfont_id:sfId,soundfont_bank:bank,soundfont_program:prog!==undefined?prog:0}:undefined;const totalSec=midiResult[0].duration||4;const totalBeats=midiResult[0].totalBeats||16;const hasSelection=selStart!==null&&selEnd!==null&&Math.abs(selStart-selEnd)>0.01;const loopStartBeats=hasSelection?Math.min(selStart,selEnd):0;const loopEndBeats=hasSelection?Math.max(selStart,selEnd):totalBeats;const loopDurationBeats=loopEndBeats-loopStartBeats;const loopDurationSec=loopDurationBeats*secondsPerBeat;const loopStartSec=loopStartBeats*secondsPerBeat;const allNotes=[];midiResult.forEach(track=>{(track.notes||[]).forEach(note=>{allNotes.push(Object.assign({},note,{trackOffset:track.startTime||0}));});});// Standalone + instrument soundfont → preview qua native bridge +// (FluidSynth trong daw_vst_bridge): thay soundfontRender backend (501 khi +// pyfluidsynth không có trong bundle) → câm. Bridge down → fallback WASM. +const nativeNotes=allNotes.filter(note=>!(hasSelection&&((note.start_beat||0)=loopEndBeats))).map(note=>{const shiftedStartBeat=hasSelection?(note.start_beat||0)-loopStartBeats:note.start_beat||0;return{pitch:note.pitch||60,start_beat:shiftedStartBeat+(note.trackOffset||0)/secondsPerBeat,duration_beats:note.duration_beats||1,velocity:note.velocity!=null?note.velocity:0.8};});let bridgeScheduled=false;if(nativeNotes.length&&sfId&&window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()&&window.NativeBridgeService){bridgeScheduled=await playMediaExplorerBridgePreview(nativeNotes,{sfId:sfId,bank:bank,prog:prog,bpm:bpmVal,hasSelection:hasSelection,loopStartBeats:loopStartBeats,loopEndBeats:loopEndBeats,loopDurationSec:loopDurationSec,token:token,isLooping:!!isLoopingRef.current});}if(bridgeScheduled){const startedAtTime=startWallTime-loopStartSec;playStateRef.current={source:null,ctx,startedAt:startedAtTime,fakeStart:startedAtTime,midiTotal:totalSec};setMidiNotes(allNotes);setMidiTotal(totalSec);setMidiBars(midiResult[0].bars||1);setMidiTotalBeats(midiResult[0].totalBeats||16);setMidiFileBpm(midiResult[0].bpm||120);setIsPlaying(true);setIsPaused(false);startCanvasClock();return;}// bridge down / chưa load → rơi xuống schedulePass WASM bên dưới +const schedulePass=passStartTime=>{allNotes.forEach(note=>{const noteStartBeat=note.start_beat||0;if(hasSelection){if(noteStartBeat=loopEndBeats)return;}const shiftedStartBeat=hasSelection?noteStartBeat-loopStartBeats:noteStartBeat;const startSec=shiftedStartBeat*secondsPerBeat+(note.trackOffset||0);const durMs=Math.max(80,(note.duration_beats||1)*secondsPerBeat*1000);window.SonicSF.playNote(note.pitch||60,note.velocity||0.8,durMs,passStartTime+startSec,prog,null,pvCh,eng);});};schedulePass(startWallTime);// Loop scheduling: keep looping continuously until Stop is pressed. if(loopTimerRef.current){clearInterval(loopTimerRef.current);loopTimerRef.current=null;}if(isLoopingRef.current){loopTimerRef.current=setInterval(()=>{if(selectTokenRef.current!==(token||selectTokenRef.current)){clearInterval(loopTimerRef.current);loopTimerRef.current=null;return;}if(!isPlayingRef.current||isPausedRef.current)return;if(!isLoopingRef.current){clearInterval(loopTimerRef.current);loopTimerRef.current=null;return;}const passStart=ctx.currentTime+0.05;schedulePass(passStart);playStateRef.current=Object.assign({},playStateRef.current,{startedAt:passStart,fakeStart:passStart});},Math.max(200,loopDurationSec*1000));}// Keep a fake clock so canvas playhead animates; loop uses playStateRef -const startedAtTime=startWallTime-loopStartSec;playStateRef.current={source:null,ctx,startedAt:startedAtTime,fakeStart:startedAtTime,midiTotal:totalSec};setMidiNotes(allNotes);setMidiTotal(totalSec);setMidiBars(midiResult[0].bars||1);setMidiTotalBeats(midiResult[0].totalBeats||16);setMidiFileBpm(midiResult[0].bpm||120);setIsPlaying(true);setIsPaused(false);startCanvasClock();}catch(e){console.error('MIDI preview failed',e);}};const stopMediaPlayback=React.useCallback(()=>{if(playStateRef.current){try{if(playStateRef.current.source)playStateRef.current.source.stop();}catch(e){}try{if(playStateRef.current.source)playStateRef.current.source.disconnect();}catch(e){}playStateRef.current=null;}if(rafRef.current)cancelAnimationFrame(rafRef.current);rafRef.current=null;if(loopTimerRef.current){clearInterval(loopTimerRef.current);loopTimerRef.current=null;}stopNativeSfNote('midifile');if(window.SonicSF&&typeof window.SonicSF.stopAll==='function'){try{window.SonicSF.stopAll();}catch(e){}}setIsPlaying(false);setIsPaused(false);},[]);// F6 close / panel bị ẩn (display:none — KHÔNG unmount) → tắt preview âm +const startedAtTime=startWallTime-loopStartSec;playStateRef.current={source:null,ctx,startedAt:startedAtTime,fakeStart:startedAtTime,midiTotal:totalSec};setMidiNotes(allNotes);setMidiTotal(totalSec);setMidiBars(midiResult[0].bars||1);setMidiTotalBeats(midiResult[0].totalBeats||16);setMidiFileBpm(midiResult[0].bpm||120);setIsPlaying(true);setIsPaused(false);startCanvasClock();}catch(e){console.error('MIDI preview failed',e);}};const stopMediaPlayback=React.useCallback(()=>{if(playStateRef.current){try{if(playStateRef.current.source)playStateRef.current.source.stop();}catch(e){}try{if(playStateRef.current.source)playStateRef.current.source.disconnect();}catch(e){}playStateRef.current=null;}if(rafRef.current)cancelAnimationFrame(rafRef.current);rafRef.current=null;if(loopTimerRef.current){clearInterval(loopTimerRef.current);loopTimerRef.current=null;}stopNativeSfNote('midifile');if(window.SonicSF&&typeof window.SonicSF.stopAll==='function'){try{window.SonicSF.stopAll();}catch(e){}}// Dừng note bridge đang ngân từ Media Explorer preview (NOTE_OFF toàn pitch). +if(_mePreview.ch!==null&&window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){try{const ch=_mePreview.ch;for(let p=0;p<128;p++){window.SonicMidiRouter.pushEvent({cmd:'NOTE_OFF',channel:ch,pitch:p,velocity:0,percussion:ch===9,live:true});}}catch(e){}}setIsPlaying(false);setIsPaused(false);},[]);// F6 close / panel bị ẩn (display:none — KHÔNG unmount) → tắt preview âm // thanh (trước đây preview tiếp tục phát sau khi đóng Media Explorer). React.useEffect(function(){if(active===false)stopMediaPlayback();},[active,stopMediaPlayback]);const playSelected=async(f,token)=>{if(!f)return;stopMediaPlayback();if(isMidiFile(f)){if(f.handle||f.path||f.file_id||f.fileId||f.kind==='midi'&&f.name){// Real MIDI file: play through selected synth instrument await playMidiPreview(f,token);return;}playStateRef.current={source:null,ctx:null,fakeStart:performance.now()/1000};setIsPlaying(true);setIsPaused(false);startCanvasClock();return;}const fid=f.file_id||f.fileId;const buf=await readLocalFileBuffer(f);if(selectTokenRef.current!==(token||selectTokenRef.current))return;if(!buf)return;try{const ctx=getAudioContext();const decoded=await ctx.decodeAudioData(buf);if(selectTokenRef.current!==(token||selectTokenRef.current))return;setAudioBuffer(decoded);setAudioDuration(decoded.duration);const src=ctx.createBufferSource();src.buffer=decoded;const sStart=selStartRef.current;const sEnd=selEndRef.current;const hasSelection=sStart!==null&&sEnd!==null&&Math.abs(sStart-sEnd)>0.01;let startOffset=0;if(hasSelection){startOffset=Math.min(sStart,sEnd);}src.loop=isLoopingRef.current;if(isLoopingRef.current){if(hasSelection){src.loopStart=Math.min(sStart,sEnd);src.loopEnd=Math.max(sStart,sEnd);}else{src.loopStart=0;src.loopEnd=decoded.duration;}}src.playbackRate.value=rate;const gain=ctx.createGain();const linear=volumeDb<=-50?0:Math.pow(10,volumeDb/20);gain.gain.value=linear;src.connect(gain);gain.connect(ctx.destination);src.start(0,startOffset);const startedAt=ctx.currentTime-startOffset;playStateRef.current={source:src,ctx,startedAt};setIsPlaying(true);setIsPaused(false);startCanvasClock();}catch(e){console.error('Preview failed',e);}};const togglePause=()=>{const st=playStateRef.current;if(!st)return;if(isPaused){if(st.ctx)st.ctx.resume();setIsPaused(false);}else{if(st.ctx)st.ctx.suspend();setIsPaused(true);}};const startCanvasClock=()=>{if(rafRef.current)cancelAnimationFrame(rafRef.current);const tick=()=>{rafRef.current=requestAnimationFrame(tick);let t=currentTimeRef.current;const st=playStateRef.current;if(st&&!isPausedRef.current){const rawElapsed=st.ctx?st.ctx.currentTime-st.startedAt:performance.now()/1000-st.fakeStart;const f=selectedRef.current;const dur=fileDuration(f);const isMidi=isMidiFile(f);const bpmVal=tempoRef.current||120;const secondsPerBeat=60.0/bpmVal;const sStart=selStartRef.current;const sEnd=selEndRef.current;const hasSelection=sStart!==null&&sEnd!==null&&Math.abs(sStart-sEnd)>0.01;// Loop plays continuously (forever) until Stop is pressed.