diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 100fa84..53028f1 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -9334,6 +9334,9 @@ const MediaExplorerPanel = ({ height }) => { if (synthInst && window.SonicSF.selectInstrument) { try { await window.SonicSF.selectInstrument(0, 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 = (synthInst && synthInst.program !== undefined) ? synthInst.program : undefined; const eng = synthInst ? { soundfont_id: sfId, soundfont_bank: bank, soundfont_program: prog !== undefined ? prog : 0 } : undefined; midiResult.forEach(track => { diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index ce7bd36..1c4d5bc 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -236,7 +236,9 @@ const MEDIA_LIBRARY_SAMPLES=[{name:"MIDI_Loop_01.mid",events:95,lengthQn:16,time },[]);const isMidiFile=f=>f&&(f.kind==='midi'||/\.(mid|midi)$/i.test(f.name||f.original_name||''));const fileDuration=f=>{if(!f)return 0;if(isMidiFile(f)){if(midiTotal&&midiNotes&&midiNotes.length)return midiTotal;return(f.lengthQn||16)*60/(f.bpm||120);}const matches=selected&&(f.path&&f.path===selected.path||!f.path&&(f.file_id||f.fileId)===(selected.file_id||selected.fileId));return f.duration||(audioBuffer&&matches?audioBuffer.duration:0)||0;};const folderFiles=React.useMemo(()=>{if(folder==='library')return MEDIA_LIBRARY_SAMPLES;if(folder==='computer'){const node=computerTree[computerPath]||{dirs:[]};return[...(node.dirs||[]),...computerFiles];}return userFiles.filter(f=>folder==='uploads'?(f.type||'Upload')==='Upload':(f.type||'Processed')==='Processed');},[folder,userFiles,computerFiles,computerTree,computerPath]);const visibleFiles=React.useMemo(()=>{const q=filterText.toLowerCase().trim();if(!q)return folderFiles;return folderFiles.filter(f=>(f.name||f.original_name||'').toLowerCase().includes(q));},[folderFiles,filterText]);const loadWaveform=async f=>{const token=selectTokenRef.current;if(f&&(f.handle||f.path)){try{const buf=await readLocalFileBuffer(f);if(selectTokenRef.current!==token)return;if(!buf){setPeaks(null);return;}const ctx=getAudioContext();const decoded=await ctx.decodeAudioData(buf);if(selectTokenRef.current!==token)return;setAudioBuffer(decoded);const data=decoded.getChannelData(0);const count=600;const step=Math.max(1,Math.floor(data.length/count));const pk=[];for(let i=0;im)m=v;}pk.push(m);}setPeaks(pk);}catch(e){if(selectTokenRef.current===token)setPeaks(null);}return;}const fid=f.file_id||f.fileId;if(!fid){setPeaks(null);return;}try{const resp=await fetch(`${API_BASE_URL}/api/v1/audio/waveform/${fid}?num_peaks=600`);const data=await resp.json();if(selectTokenRef.current!==token)return;setPeaks(data.peaks||[]);}catch(e){setPeaks(null);}};const openMyComputer=async()=>{setFolder('computer');if(window.showDirectoryPicker){try{const handle=await window.showDirectoryPicker({mode:'read'});setComputerMode('client');setClientRoot(handle);const rootEntry={name:handle.name,path:'root',is_dir:true,handle};setComputerRoots([rootEntry]);setComputerPath('root');setComputerFiles([]);setComputerTree({root:{handle,parent:null,dirs:[],expanded:true}});await browseComputerDir(rootEntry);return;}catch(e){if(e&&e.name==='AbortError')return;window.showToast&&window.showToast('Không thể mở thư mục: '+e.message,'error');return;}}// Fallback: server API (trình duyệt không hỗ trợ File System Access API) setComputerMode('server');if(computerRoots)return;setComputerRoots(null);try{const resp=await fetch(`${API_BASE_URL}/api/v1/media/computer`);if(!resp.ok)throw new Error('HTTP '+resp.status);const data=await resp.json();const roots=data.roots||[];setComputerRoots(roots.length?roots:[{path:'/',name:'Root (/)',is_dir:true}]);if(!roots.length)window.showToast&&window.showToast('Không tìm thấy ổ đĩa nào','warning');}catch(e){setComputerRoots([{path:'/',name:'Root (/)',is_dir:true}]);window.showToast&&window.showToast('Không thể truy cập My Computer: '+e.message,'error');}};const browseClientDir=async entry=>{const handle=entry&&entry.handle;if(!handle||!handle.entries)return;const dirs=[];const files=[];const parentPath=entry.path;try{for await(const[name,h]of handle.entries()){if(name.startsWith('.'))continue;if(h.kind==='directory'){dirs.push({name,path:parentPath+'/'+name,is_dir:true,handle:h});}else{const ext=(name.split('.').pop()||'').toLowerCase();const kind=ext==='mid'||ext==='midi'?'midi':['wav','mp3','ogg','flac','aiff','aif','m4a','aac','opus'].includes(ext)?'audio':'other';let size=0;try{const f=await h.getFile();size=f.size;}catch(e2){}files.push({name,path:parentPath+'/'+name,is_dir:false,size_mb:size?+(size/1048576).toFixed(2):0,ext:'.'+ext,kind,handle:h});}}dirs.sort((a,b)=>a.name.localeCompare(b.name));files.sort((a,b)=>a.name.localeCompare(b.name));setComputerPath(parentPath);setComputerFiles(files);setSelected(null);setCurrentTime(0);stopMediaPlayback();setComputerTree(prev=>({...prev,[parentPath]:{handle,parent:entry.parent||null,dirs,expanded:true}}));}catch(e){window.showToast&&window.showToast('Không thể đọc thư mục: '+e.message,'error');}};const browseComputerDir=async entry=>{if(!entry)return;if(computerMode==='client'||entry.handle){if(entry.handle&&entry.handle.entries){const parentInfo=computerTree[entry.path];await browseClientDir({...entry,parent:parentInfo?parentInfo.parent:null});return;}}const path=entry.path||entry;if(!path)return;try{const resp=await fetch(`${API_BASE_URL}/api/v1/media/browse?path=${encodeURIComponent(path)}`);if(!resp.ok)throw new Error('HTTP '+resp.status);const data=await resp.json();setComputerPath(data.path);setComputerFiles(data.files||[]);setSelected(null);setCurrentTime(0);stopMediaPlayback();setComputerTree(prev=>({...prev,[path]:{dirs:data.dirs||[],expanded:true}}));}catch(e){window.showToast&&window.showToast('Không thể mở thư mục: '+e.message,'error');}};const toggleComputerDir=async entry=>{if(!entry)return;const path=entry.path||entry;const node=computerTree[path];if(node&&node.expanded){setComputerTree(prev=>({...prev,[path]:{...prev[path],expanded:false}}));}else{await browseComputerDir(entry);}};const goComputerParent=()=>{if(!computerPath)return;if(computerMode==='client'||clientRoot){const node=computerTree[computerPath];const parentHandle=node&&node.parent;if(parentHandle){const parentEntry=computerRoots&&computerRoots[0]&&parentHandle===clientRoot?computerRoots[0]:{name:parentHandle.name,path:computerPath.split('/').slice(0,-1).join('/')||'root',is_dir:true,handle:parentHandle};browseComputerDir({...parentEntry,parent:parentHandle===clientRoot?null:computerTree[parentEntry.path]?computerTree[parentEntry.path].parent:null});}return;}const isUnix=computerPath.startsWith('/');const parts=computerPath.split(/[\\/]/).filter(Boolean);parts.pop();if(isUnix){browseComputerDir(parts.length?'/'+parts.join('/'):'/');}else{// Windows: quay về root ổ đĩa nếu đã lên tới đỉnh browseComputerDir(parts.length?parts.join('\\'):computerPath.split(/[\\/]/)[0]+'\\');}};const readLocalFileBuffer=async f=>{if(f&&f.handle&&typeof f.handle.getFile==='function'){const file=await f.handle.getFile();return await file.arrayBuffer();}const url=filePreviewUrl(f);if(!url)return null;const resp=await fetch(url);return await resp.arrayBuffer();};const filePreviewUrl=f=>{if(f&&f.path&&!(f.handle&&f.handle.getFile))return`${API_BASE_URL}/api/v1/media/file?path=${encodeURIComponent(f.path)}`;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;}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);setSynthOpen(false);localStorage.setItem('studio_media_explorer_synth',JSON.stringify(inst));};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;const ctx=getAudioContext();const bpmVal=parseInt(synthInst&&synthInst.bpm)||120;const secondsPerBeat=60.0/bpmVal;const startWallTime=ctx.currentTime+0.05;const program=synthInst?synthInst.program:undefined;const sfId=synthInst?synthInst.sfId:undefined;const bank=synthInst?synthInst.bank:0;if(synthInst&&window.SonicSF.selectInstrument){try{await window.SonicSF.selectInstrument(0,bank,program,sfId);}catch(e2){}}const prog=synthInst&&synthInst.program!==undefined?synthInst.program:undefined;const eng=synthInst?{soundfont_id:sfId,soundfont_bank:bank,soundfont_program:prog!==undefined?prog:0}:undefined;midiResult.forEach(track=>{(track.notes||[]).forEach(note=>{const startSec=(note.start_beat||0)*secondsPerBeat;const durMs=Math.max(80,(note.duration_beats||1)*secondsPerBeat*1000);window.SonicSF.playNote(note.pitch||60,note.velocity||0.8,durMs,startWallTime+startSec,prog,null,0,eng);});});// Keep a fake clock so canvas playhead animates; loop uses playStateRef +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;const ctx=getAudioContext();const bpmVal=parseInt(synthInst&&synthInst.bpm)||120;const secondsPerBeat=60.0/bpmVal;const startWallTime=ctx.currentTime+0.05;const program=synthInst?synthInst.program:undefined;const sfId=synthInst?synthInst.sfId:undefined;const bank=synthInst?synthInst.bank:0;if(synthInst&&window.SonicSF.selectInstrument){try{await window.SonicSF.selectInstrument(0,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=synthInst&&synthInst.program!==undefined?synthInst.program:undefined;const eng=synthInst?{soundfont_id:sfId,soundfont_bank:bank,soundfont_program:prog!==undefined?prog:0}:undefined;midiResult.forEach(track=>{(track.notes||[]).forEach(note=>{const startSec=(note.start_beat||0)*secondsPerBeat;const durMs=Math.max(80,(note.duration_beats||1)*secondsPerBeat*1000);window.SonicSF.playNote(note.pitch||60,note.velocity||0.8,durMs,startWallTime+startSec,prog,null,0,eng);});});// Keep a fake clock so canvas playhead animates; loop uses playStateRef playStateRef.current={source:null,ctx,startedAt:startWallTime,fakeStart:startWallTime,midiTotal:midiResult[0].duration||4};setMidiNotes(midiResult[0].notes||[]);setMidiTotal(midiResult[0].duration||4);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(window.SonicSF&&typeof window.SonicSF.stopAll==='function'){try{window.SonicSF.stopAll();}catch(e){}}setIsPlaying(false);setIsPaused(false);},[]);const playSelected=async(f,token)=>{if(!f)return;stopMediaPlayback();if(isMidiFile(f)){if(f.handle||f.path||f.file_id||f.fileId){// 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);const src=ctx.createBufferSource();src.buffer=decoded;src.loop=isLooping;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();const startedAt=ctx.currentTime;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=currentTime;const st=playStateRef.current;if(st&&!isPaused){t=st.ctx?st.ctx.currentTime-st.startedAt:performance.now()/1000-st.fakeStart;}setCurrentTime(t);drawCanvas(t);};tick();};const drawCanvas=t=>{const canvas=canvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const w=canvas.clientWidth,h=canvas.clientHeight;if(!w||!h)return;canvas.width=w*2;canvas.height=h*2;ctx.scale(2,2);ctx.clearRect(0,0,w,h);ctx.fillStyle='#181818';ctx.fillRect(0,0,w,h);const f=selected;if(!f){ctx.fillStyle='#555';ctx.font='12px JetBrains Mono, monospace';ctx.fillText('No file selected',10,h/2);return;}const dur=fileDuration(f);if(isMidiFile(f)){ctx.strokeStyle='#333';for(let y=0;y{const x=n.start_beat/beats*w;const nw=Math.max(3,n.duration_beats/beats*w);const y=h-14-8-(Math.min(pitchMax,Math.max(pitchMin,n.pitch))-pitchMin)/pitchRange*(h-30);ctx.fillRect(x,y,nw,5);});}else{const events=f.events||76;for(let i=0;i0?peaks:null;if(pk){const mid=h/2;ctx.fillStyle='#22c55e';for(let i=0;i - +