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
This commit is contained in:
2026-08-16 16:42:19 +07:00
parent 56d8c577aa
commit 574c2f885d
2 changed files with 110 additions and 31 deletions
+96 -25
View File
@@ -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);
}, []);
+14 -6
View File
@@ -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.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;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)<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(){});});}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<loopStartBeats||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)<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);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<loopStartBeats||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.