From fefe95d0a984dedd1e7672dd75185b9094b5217a Mon Sep 17 00:00:00 2001 From: locphamtran Date: Mon, 24 Aug 2026 18:26:52 +0700 Subject: [PATCH] feat(export): stop transport khi export offline + check file export khong cam (peak < -60dB -> canh bao) --- app/static/js/app.jsx | 8 ++++++++ app/static/js/app.precompiled.js | 5 +++-- app/templates/index.html | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 8b570b2..9ae6dbc 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -27935,6 +27935,9 @@ const App = () => { // ── Server-side Export ── const triggerWavExport = async () => { + // Stop transport ngay khi bắt đầu export offline → tắt âm thanh đang phát. + 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. @@ -28669,11 +28672,13 @@ const App = () => { writeString(36, 'data'); view.setUint32(40, exportLength * bytesPerSample * numExportCh, true); let offset = 44; + let peak = 0; for (let i = 0; i < exportLength; i++) { if ((i & 0x3FFF) === 0) setExportProgress(60 + Math.round((i / Math.max(1, exportLength)) * 32)); for (let ch = 0; ch < numExportCh; ch++) { const chData = renderedBuffer.getChannelData(ch); const sample = Math.max(-1, Math.min(1, chData[i])); + if (Math.abs(sample) > peak) peak = Math.abs(sample); if (bitDepth === 8) { view.setUint8(offset, Math.floor((sample + 1.0) * 127.5)); } else if (bitDepth === 16) { @@ -28689,6 +28694,9 @@ const App = () => { offset += bytesPerSample; } } + if (peak < 0.001) { + showToast("Cảnh báo: file export gần như CÂM (không có tín hiệu âm) — kiểm tra nguồn track / MIDI cache preview.", "warning"); + } const blob = new Blob([view], { type: 'audio/wav' }); diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index e9b95c8..011eb22 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -2108,7 +2108,8 @@ const insertSectionAtPlayhead=()=>{const curTracks=activeTracks;const track=curT const insertMidiItemAtPlayhead=()=>{const curTracks=activeTracks;const track=curTracks.find(t=>t.id===selectedTrackId);if(!track){showToast('Chọn track trước','warning');return;}const secondsPerBar=60.0/(parseInt(bpm)||120)*4;const midiItem={id:`midi_${Date.now()}`,name:'MIDI Item',startTime:currentTime,duration:4*secondsPerBar,length_bars:4,notes:[],color:'#a78bfa'};createMidiWithUndo(selectedTrackId,midiItem);updateActiveTracks(prev=>prev.map(t=>t.id===selectedTrackId?{...t,midiItems:[...(t.midiItems||[]),midiItem]}:t));showToast(`Đã thêm MIDI item tại ${currentTime.toFixed(2)}s`,'success');};// ── Insert Sound Clip at Cursor ── const insertSoundClipAtCursor=()=>{const curTracks=activeTracks;const track=curTracks.find(t=>t.id===selectedTrackId);if(!track){showToast('Chọn track trước','warning');return;}const input=document.createElement('input');input.type='file';input.accept='audio/*';input.multiple=true;input.onchange=async e=>{const files=Array.from(e.target.files||[]);if(!files.length)return;let offset=currentTime;let count=0;for(const file of files){try{const uploadResult=await uploadToServer(file,track.id);const fileId=uploadResult?.file_id||null;const{audioBuffer:decodedBuffer,channelInfo}=await window.SonicAudio.decodeAudioFile(file);const clipId=`clip_${Date.now()}_${Math.random().toString(36).slice(2,6)}`;const newClip={id:clipId,buffer:decodedBuffer,startTime:offset,name:file.name,speed:1.0,serverFileId:fileId};createClipWithUndo(selectedTrackId,newClip);updateActiveTracks(prev=>prev.map(t=>t.id===selectedTrackId?{...t,serverFileId:t.serverFileId||fileId,clips:[...(t.clips||[]),newClip]}:t));offset+=decodedBuffer.duration;count++;}catch(err){showToast(`Lỗi nạp ${file.name}`,'error');}}if(count>0)showToast(`Đã chèn ${count} file âm thanh`,'success');};input.click();};// ── Chords Panel insert (Phase 6) ─────────────────────────────────────── const insertChordProgression=prog=>{const curTracks=activeTracks;const pr=prog||{};const chordList=pr.chords||[];if(!chordList.length){showToast('Không có chord để chèn','warning');return;}const beatSec=60.0/(parseInt(bpm)||120);const secondsPerBar=beatSec*4;const activeSub=activeTab&&subTabs.find(s=>s.id===activeTab&&s.type==='PIANO_ROLL');let trackId=null;let item=null;let insertBeat=0;if(activeSub){trackId=activeSub.trackId;const trk=curTracks.find(t=>t.id===trackId);if(trk)item=(trk.midiItems||[]).find(m=>m.id===activeSub.target_id)||null;insertBeat=Math.max(0,(activeSub.currentTime||0)/beatSec);}else{trackId=selectedTrackId;const trk=curTracks.find(t=>t.id===trackId);if(!trk){showToast('Chọn track trước','warning');return;}const items=trk.midiItems||[];if(!items.length){const newItem={id:'midi_'+Date.now(),name:'Chords',startTime:currentTime,duration:secondsPerBar*4,length_bars:4,notes:[],color:'#a78bfa'};createMidiWithUndo(trackId,newItem);updateActiveTracks(prev=>prev.map(t=>t.id===trackId?{...t,midiItems:[...(t.midiItems||[]),newItem]}:t));item=newItem;insertBeat=0;}else{item=items[items.length-1];insertBeat=Math.max(0,(currentTime-(item.startTime||0))/beatSec);}}if(!item){showToast('Không tìm thấy MIDI item','warning');return;}const merged=(item.notes||[]).slice();let beat=insertBeat;chordList.forEach(function(ch,ci){const dur=ch.duration_beats!=null?ch.duration_beats:4;(ch.pitches||[]).forEach(function(p){merged.push({id:'note_chord_'+Date.now()+'_'+ci+'_'+p,pitch:p,start_beat:beat,duration_beats:dur,velocity:0.8,pan:0.0});});beat+=dur;});const finalItem={...item,notes:merged};updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;const has=(t.midiItems||[]).some(m=>m.id===item.id);if(!has)return{...t,midiItems:[...(t.midiItems||[]),finalItem]};return{...t,midiItems:(t.midiItems||[]).map(m=>m.id===item.id?finalItem:m)};}));if(activeSub){setSubTabs(prev=>prev.map(s=>s.id===activeSub.id?{...s,notes:merged,isDirty:true}:s));}setCanvasRedrawCount(n=>n+1);showToast('Đã chèn '+chordList.length+' chord ('+merged.length+' notes)','success');window.__lastChordInsert={trackId:trackId,itemId:item.id,notes:merged};};// ── Server-side Export ── -const triggerWavExport=async()=>{// Realtime UNCHECKED → export OFFLINE thuần (server-side nếu connected, fallback client). +const triggerWavExport=async()=>{// Stop transport ngay khi bắt đầu export offline → tắt âm thanh đang phát. +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);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);});if(!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;cht.buffer&&!t.muted);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.buffer&&!t.muted);}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ừ @@ -2170,7 +2171,7 @@ try{if(masterBus&&masterBus.output){const mv=typeof masterVolume==='number'&&isF // VST chain qua fx_vst_bridge → cộng mix → mastering built-in 1 lần // trên tổng → master VST chain cuối chain. Đúng thứ tự live: track fx // → (sum) → master fx → out. -let mix=null;let vstTi=0;for(const t of activeTracks){setExportProgress(10+Math.round(vstTi/Math.max(1,activeTracks.length)*40));vstTi++;const chain=activeVstChain(t.vstFxChain||[]);const tctx=new OfflineAudioContext(2,Math.ceil(targetRate*Math.max(0.1,durationLimit)),targetRate);const tNodes={};const node=scheduleTrackInto(t,tctx,tNodes,true);if(!node)continue;const tb=await tctx.startRendering();try{if(node.fxStopFn)node.fxStopFn();}catch(e){}let pcm=audioBufferToInterleaved(tb).pcm;if(chain.length)pcm=await applyVstChainToPcm(pcm,targetRate,chain,t.name||'track');mix=mix?mixInterleaved(mix,pcm):pcm;}if(!mix)mix=new Float32Array(Math.ceil(targetRate*Math.max(0.1,durationLimit))*2);const mctx=new OfflineAudioContext(2,Math.floor(mix.length/2),targetRate);masterBus=null;initMasterBus(mctx);try{toggleMasteringOnMaster(!!(masteringSettings&&masteringSettings.masterConnected),!!(masteringSettings&&masteringSettings.isBypassed));}catch(e){console.warn('offline mastering toggle error (vst):',e);}try{if(masterBus&&masterBus.output){const mv=typeof masterVolume==='number'&&isFinite(masterVolume)?masterVolume:0;applyMasterVolumesTo(masterBus,masterVolL,masterLink?masterVolL:masterVolR,!!masterMuted,0);}}catch(e){console.warn('offline main-out volume error (vst):',e);}const src=mctx.createBufferSource();src.buffer=interleavedToAudioBuffer(mix,targetRate,2,mctx);src.connect(masterBus.input);src.start(0);renderedBuffer=await mctx.startRendering();if(masterVstOn){const conv=audioBufferToInterleaved(renderedBuffer);let outPcm=await applyVstChainToPcm(conv.pcm,targetRate,masterVstChain,'master');if(outChannels===1){const n=Math.floor(outPcm.length/2);const mono=new Float32Array(n);for(let i=0;i{scheduleTrackInto(t,offlineCtx,offlineNodes,false);});renderedBuffer=await offlineCtx.startRendering();}setExportProgress(60);const numExportCh=renderedBuffer.numberOfChannels;const exportLength=renderedBuffer.length;const bytesPerSample=bitDepth/8;const headerSize=44;const fileSizeBytes=headerSize+exportLength*bytesPerSample*numExportCh;const fileBuffer=new ArrayBuffer(fileSizeBytes);const view=new DataView(fileBuffer);const writeString=(offset,string)=>{for(let i=0;i>8&0xFF);view.setUint8(offset+2,val24>>16&0xFF);}else if(isFloatWav){view.setFloat32(offset,sample,true);}offset+=bytesPerSample;}}const blob=new Blob([view],{type:'audio/wav'});const wavName=`sonicforge-session-${targetRate}hz-${bitDepth}bit.wav`;setExportProgress(95);if((exportSettings.format==='mp3'||exportSettings.format==='ogg')&&serverStatus==='connected'){setIsExporting(true);setExportProgress(-1);showToast("Đang gửi yêu cầu nén định dạng lên máy chủ...","info");try{const file=new File([blob],`session_export.wav`,{type:'audio/wav'});const formData=new FormData();formData.append('file',file);const uploadResp=await fetch(`${API_AUDIO}/upload`,{method:'POST',body:formData});if(!uploadResp.ok)throw new Error("Upload to transcode server failed");const uploadData=await uploadResp.json();const fileId=uploadData.file_id;const exportResp=await fetch(`${API_AUDIO}/export`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file_id:fileId,format:exportSettings.format,sample_rate:targetRate,bit_depth:bitDepth})});if(!exportResp.ok)throw new Error("Transcode request failed");const exportData=await exportResp.json();const result=await pollTaskResult(exportData.task_id,20);if(result.success){const outId=result.output_file_id;const downloadUrl=`${API_AUDIO}/download/${outId}`;showToast(`Xuất bản âm thanh định dạng ${exportSettings.format.toUpperCase()} thành công!`,"success","Tải về",async()=>{try{const br=await fetch(downloadUrl);if(!br.ok)throw new Error('HTTP '+br.status);const b=await br.blob();const sp=await saveExportViaDialog(`sonicforge-session-${targetRate}hz-${bitDepth}bit.${exportSettings.format}`,b);if(sp)showToast(`Đã lưu → ${sp}`,"success");}catch(e){const a=document.createElement('a');a.href=downloadUrl;a.download=`sonicforge-session-${targetRate}hz-${bitDepth}bit.${exportSettings.format}`;a.click();}});}else{throw new Error(result.error||'Server encoding failed');}}catch(transcodeErr){showToast(`Lỗi chuyển đổi: ${transcodeErr.message}.`,"warning","Tải WAV thay thế",()=>{downloadBlobViaServer(blob,wavName).catch(()=>{});});}}else{if(exportSettings.format!=='wav'){showToast("Đang ngoại tuyến. Tải về định dạng WAV thay thế.","warning");}const dres=await downloadBlobViaServer(blob,wavName).catch(()=>null);if(dres&&dres.saved_path){showToast(`Xuất bản âm thanh hoàn tất → ${dres.saved_path}`,"success");}else{showToast("Xuất bản âm thanh hoàn tất!","success");}}}finally{// Restore the LIVE master bus after offline render: initMasterBus(offlineCtx) +let mix=null;let vstTi=0;for(const t of activeTracks){setExportProgress(10+Math.round(vstTi/Math.max(1,activeTracks.length)*40));vstTi++;const chain=activeVstChain(t.vstFxChain||[]);const tctx=new OfflineAudioContext(2,Math.ceil(targetRate*Math.max(0.1,durationLimit)),targetRate);const tNodes={};const node=scheduleTrackInto(t,tctx,tNodes,true);if(!node)continue;const tb=await tctx.startRendering();try{if(node.fxStopFn)node.fxStopFn();}catch(e){}let pcm=audioBufferToInterleaved(tb).pcm;if(chain.length)pcm=await applyVstChainToPcm(pcm,targetRate,chain,t.name||'track');mix=mix?mixInterleaved(mix,pcm):pcm;}if(!mix)mix=new Float32Array(Math.ceil(targetRate*Math.max(0.1,durationLimit))*2);const mctx=new OfflineAudioContext(2,Math.floor(mix.length/2),targetRate);masterBus=null;initMasterBus(mctx);try{toggleMasteringOnMaster(!!(masteringSettings&&masteringSettings.masterConnected),!!(masteringSettings&&masteringSettings.isBypassed));}catch(e){console.warn('offline mastering toggle error (vst):',e);}try{if(masterBus&&masterBus.output){const mv=typeof masterVolume==='number'&&isFinite(masterVolume)?masterVolume:0;applyMasterVolumesTo(masterBus,masterVolL,masterLink?masterVolL:masterVolR,!!masterMuted,0);}}catch(e){console.warn('offline main-out volume error (vst):',e);}const src=mctx.createBufferSource();src.buffer=interleavedToAudioBuffer(mix,targetRate,2,mctx);src.connect(masterBus.input);src.start(0);renderedBuffer=await mctx.startRendering();if(masterVstOn){const conv=audioBufferToInterleaved(renderedBuffer);let outPcm=await applyVstChainToPcm(conv.pcm,targetRate,masterVstChain,'master');if(outChannels===1){const n=Math.floor(outPcm.length/2);const mono=new Float32Array(n);for(let i=0;i{scheduleTrackInto(t,offlineCtx,offlineNodes,false);});renderedBuffer=await offlineCtx.startRendering();}setExportProgress(60);const numExportCh=renderedBuffer.numberOfChannels;const exportLength=renderedBuffer.length;const bytesPerSample=bitDepth/8;const headerSize=44;const fileSizeBytes=headerSize+exportLength*bytesPerSample*numExportCh;const fileBuffer=new ArrayBuffer(fileSizeBytes);const view=new DataView(fileBuffer);const writeString=(offset,string)=>{for(let i=0;ipeak)peak=Math.abs(sample);if(bitDepth===8){view.setUint8(offset,Math.floor((sample+1.0)*127.5));}else if(bitDepth===16){view.setInt16(offset,Math.floor(sample<0?sample*0x8000:sample*0x7FFF),true);}else if(bitDepth===24){const val24=Math.floor(sample<0?sample*0x800000:sample*0x7FFFFF);view.setUint8(offset,val24&0xFF);view.setUint8(offset+1,val24>>8&0xFF);view.setUint8(offset+2,val24>>16&0xFF);}else if(isFloatWav){view.setFloat32(offset,sample,true);}offset+=bytesPerSample;}}if(peak<0.001){showToast("Cảnh báo: file export gần như CÂM (không có tín hiệu âm) — kiểm tra nguồn track / MIDI cache preview.","warning");}const blob=new Blob([view],{type:'audio/wav'});const wavName=`sonicforge-session-${targetRate}hz-${bitDepth}bit.wav`;setExportProgress(95);if((exportSettings.format==='mp3'||exportSettings.format==='ogg')&&serverStatus==='connected'){setIsExporting(true);setExportProgress(-1);showToast("Đang gửi yêu cầu nén định dạng lên máy chủ...","info");try{const file=new File([blob],`session_export.wav`,{type:'audio/wav'});const formData=new FormData();formData.append('file',file);const uploadResp=await fetch(`${API_AUDIO}/upload`,{method:'POST',body:formData});if(!uploadResp.ok)throw new Error("Upload to transcode server failed");const uploadData=await uploadResp.json();const fileId=uploadData.file_id;const exportResp=await fetch(`${API_AUDIO}/export`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file_id:fileId,format:exportSettings.format,sample_rate:targetRate,bit_depth:bitDepth})});if(!exportResp.ok)throw new Error("Transcode request failed");const exportData=await exportResp.json();const result=await pollTaskResult(exportData.task_id,20);if(result.success){const outId=result.output_file_id;const downloadUrl=`${API_AUDIO}/download/${outId}`;showToast(`Xuất bản âm thanh định dạng ${exportSettings.format.toUpperCase()} thành công!`,"success","Tải về",async()=>{try{const br=await fetch(downloadUrl);if(!br.ok)throw new Error('HTTP '+br.status);const b=await br.blob();const sp=await saveExportViaDialog(`sonicforge-session-${targetRate}hz-${bitDepth}bit.${exportSettings.format}`,b);if(sp)showToast(`Đã lưu → ${sp}`,"success");}catch(e){const a=document.createElement('a');a.href=downloadUrl;a.download=`sonicforge-session-${targetRate}hz-${bitDepth}bit.${exportSettings.format}`;a.click();}});}else{throw new Error(result.error||'Server encoding failed');}}catch(transcodeErr){showToast(`Lỗi chuyển đổi: ${transcodeErr.message}.`,"warning","Tải WAV thay thế",()=>{downloadBlobViaServer(blob,wavName).catch(()=>{});});}}else{if(exportSettings.format!=='wav'){showToast("Đang ngoại tuyến. Tải về định dạng WAV thay thế.","warning");}const dres=await downloadBlobViaServer(blob,wavName).catch(()=>null);if(dres&&dres.saved_path){showToast(`Xuất bản âm thanh hoàn tất → ${dres.saved_path}`,"success");}else{showToast("Xuất bản âm thanh hoàn tất!","success");}}}finally{// Restore the LIVE master bus after offline render: initMasterBus(offlineCtx) // ghi đè cả `masterBus` lẫn `window.masterBus` bằng graph OfflineAudioContext // (state closed) → track route (window.masterBus.input) đổ vào context câm → // play/bounce sau export CÂM. Khôi phục bus live (giữ wiring cũ nếu còn sống, diff --git a/app/templates/index.html b/app/templates/index.html index 9c2b34b..c87723d 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -51,7 +51,7 @@ - +