debug: diag logs cho bounce silent — router/native path

This commit is contained in:
2026-08-24 21:42:49 +07:00
parent bf98038572
commit 9cd6113086
3 changed files with 38 additions and 8 deletions
+8 -7
View File
@@ -1,5 +1,6 @@
const{useState,useRef,useEffect,useMemo,useCallback}=React;// ── FastAPI Backend Configuration ──
const API_BASE_URL=(window.API_BASE_URL||window.location.origin).replace(/\/+$/,'');const API_AUDIO=`${API_BASE_URL}/api/v1/audio`;const API_MULTITRACK=`${API_BASE_URL}/api/v1/multitrack`;const API_TASKS=`${API_BASE_URL}/api/v1/audio/tasks`;// ── Dedicated per-track MIDI channel allocation ──
const API_BASE_URL=(window.API_BASE_URL||window.location.origin).replace(/\/+$/,'');const API_AUDIO=`${API_BASE_URL}/api/v1/audio`;const API_MULTITRACK=`${API_BASE_URL}/api/v1/multitrack`;const API_TASKS=`${API_BASE_URL}/api/v1/audio/tasks`;// ── DEBUG diag (tạm thời) ──
window.__sfDiag=function(){var arr=[];try{var s=localStorage.getItem('__sf_diag');if(s)arr=JSON.parse(s)||[];}catch(e){}return function(msg){try{arr.push(new Date().toISOString().slice(11,23)+' '+msg);if(arr.length>2000)arr=arr.slice(-2000);localStorage.setItem('__sf_diag',JSON.stringify(arr));}catch(e){}};}();window.__sfDiagDump=function(){try{var s=localStorage.getItem('__sf_diag');var arr=s?JSON.parse(s)||[]:[];var idx=-1;for(var i=arr.length-1;i>=0;i--){if(arr[i].indexOf('BOUNCE_START')!==-1){idx=i;break;}}return(idx>=0?arr.slice(idx):arr.slice(-50)).join('\n');}catch(e){return String(e);}};// ── Dedicated per-track MIDI channel allocation ──
// FluidSynth has 16 channels; if two tracks share a channel, arming one track
// re-selects the other track's program and its instrument changes. Every track
// gets its own stable, unique channel (0-15, skipping 9 which is the classic
@@ -64,7 +65,7 @@ let _pendingFadeSession=null;const restorePendingFadeSession=()=>{if(!_pendingFa
const _cleanNativeNotes=notes=>{const out=[];notes.forEach(function(n){const pitch=n.pitch||60;const start=n.start_beat||0;const dup=out.find(function(o){return o.pitch===pitch&&o.start_beat===start;});if(dup){dup.velocity=Math.max(dup.velocity,n.velocity!=null?n.velocity:0.8);dup.duration_beats=Math.max(dup.duration_beats,n.duration_beats||1);return;}out.push({pitch:pitch,start_beat:start,duration_beats:n.duration_beats||1,velocity:n.velocity!=null?n.velocity:0.8});});out.forEach(function(n){let nextStart=Infinity;out.forEach(function(m){if(m===n||m.pitch!==n.pitch||m.start_beat<=n.start_beat)return;if(m.start_beat<nextStart)nextStart=m.start_beat;});if(nextStart<n.start_beat+n.duration_beats){n.duration_beats=Math.max(0.01,nextStart-n.start_beat-0.01);}});return out;};// Polyphony gain compensation (spec §II.1): scale velocity theo số voice đồng
// thời tại thời điểm note bắt đầu — Equal Power G = 1/sqrt(N). Solo N=1 → giữ
// nguyên; chord 8 note → -9dB mỗi voice, tổng peak không vượt trần.
const _scaleNativePolyphony=notes=>{notes.forEach(function(n){const nStart=n.start_beat||0;let active=0;notes.forEach(function(m){const mEnd=(m.start_beat||0)+(m.duration_beats||1);if((m.start_beat||0)<=nStart&&nStart<mEnd)active++;});if(active>1){const g=1/Math.sqrt(active);n.velocity=(n.velocity!=null?n.velocity:0.8)*g;}});return notes;};const _buildNativeTrackNotes=(track,bpm,baseOffsetSec,notesOverride)=>{const secPerBeat=60.0/(parseInt(bpm)||120);const items=notesOverride?[{startTime:0,notes:notesOverride}]:track&&track.midiItems||[];let trackStartAbs=Infinity;const absNotes=[];items.forEach(function(item){const itemStartAbs=(baseOffsetSec||0)+(item.startTime||0);if(itemStartAbs<trackStartAbs)trackStartAbs=itemStartAbs;(item.notes||[]).forEach(function(n){absNotes.push({pitch:n.pitch||60,start_beat:itemStartAbs/secPerBeat+(n.start_beat||0),duration_beats:n.duration_beats||1,velocity:n.velocity!=null?n.velocity:0.8});});});return{absNotes:_scaleNativePolyphony(_cleanNativeNotes(absNotes)),trackStartAbs:trackStartAbs};};const _nativeTrackKey=(track,bpm,baseOffsetSec,notesOverride)=>{try{const eng=track&&track.synth_engine;const instrumentId=eng&&eng.plugin_id||track&&track.instrumentId||'';const sfId=eng&&eng.soundfont_id||'';const data=_buildNativeTrackNotes(track,bpm,baseOffsetSec,notesOverride);const nk=data.absNotes.map(function(n){return n.pitch+':'+n.start_beat+':'+n.duration_beats+':'+n.velocity;}).join(',');return[track&&track.id?track.id:'',instrumentId,sfId,eng&&eng.preset_id||'',eng&&eng.preset_path||'',parseFloat(bpm)||120,baseOffsetSec||0,nk].join('|');}catch(e){return'';}};const _renderNativeTrack=(track,bpm,baseOffsetSec,notesOverride)=>{try{const eng=track&&track.synth_engine;const data=_buildNativeTrackNotes(track,bpm,baseOffsetSec,notesOverride);if(!data.absNotes.length)return Promise.resolve(null);if(isSfTrackEngine(eng)){const sfId=eng&&eng.soundfont_id;if(!sfId)return Promise.resolve(null);return window.SonicAPI.soundfontRender({soundfont_id:sfId,bank:eng&&eng.soundfont_bank||0,program:eng&&eng.soundfont_program||0,bpm:parseFloat(bpm)||120,notes:data.absNotes}).then(function(res){if(!res||!res.success||!res.url)return null;return API_BASE_URL+res.url;}).catch(function(){return null;});}const instrumentId=eng&&eng.plugin_id||track&&track.instrumentId;if(!instrumentId||!isVstTrackEngine(eng))return Promise.resolve(null);return window.SonicAPI.midiRender({instrument_id:instrumentId,notes:data.absNotes,bpm:parseFloat(bpm)||120,preset_id:eng&&eng.preset_id||undefined,preset_path:eng&&eng.preset_path||undefined,preset_data:eng&&eng.preset_data||undefined}).then(function(res){if(!res||!res.success||!res.url)return null;return API_BASE_URL+res.url;}).catch(function(){return null;});}catch(e){return Promise.resolve(null);}};// Dedupe in-flight render + cache WAV: play lặp/re-schedule trong lúc render
const _scaleNativePolyphony=notes=>{notes.forEach(function(n){const nStart=n.start_beat||0;let active=0;notes.forEach(function(m){const mEnd=(m.start_beat||0)+(m.duration_beats||1);if((m.start_beat||0)<=nStart&&nStart<mEnd)active++;});if(active>1){const g=1/Math.sqrt(active);n.velocity=(n.velocity!=null?n.velocity:0.8)*g;}});return notes;};const _buildNativeTrackNotes=(track,bpm,baseOffsetSec,notesOverride)=>{const secPerBeat=60.0/(parseInt(bpm)||120);const items=notesOverride?[{startTime:0,notes:notesOverride}]:track&&track.midiItems||[];let trackStartAbs=Infinity;const absNotes=[];items.forEach(function(item){const itemStartAbs=(baseOffsetSec||0)+(item.startTime||0);if(itemStartAbs<trackStartAbs)trackStartAbs=itemStartAbs;(item.notes||[]).forEach(function(n){absNotes.push({pitch:n.pitch||60,start_beat:itemStartAbs/secPerBeat+(n.start_beat||0),duration_beats:n.duration_beats||1,velocity:n.velocity!=null?n.velocity:0.8});});});return{absNotes:_scaleNativePolyphony(_cleanNativeNotes(absNotes)),trackStartAbs:trackStartAbs};};const _nativeTrackKey=(track,bpm,baseOffsetSec,notesOverride)=>{try{const eng=track&&track.synth_engine;const instrumentId=eng&&eng.plugin_id||track&&track.instrumentId||'';const sfId=eng&&eng.soundfont_id||'';const data=_buildNativeTrackNotes(track,bpm,baseOffsetSec,notesOverride);const nk=data.absNotes.map(function(n){return n.pitch+':'+n.start_beat+':'+n.duration_beats+':'+n.velocity;}).join(',');return[track&&track.id?track.id:'',instrumentId,sfId,eng&&eng.preset_id||'',eng&&eng.preset_path||'',parseFloat(bpm)||120,baseOffsetSec||0,nk].join('|');}catch(e){return'';}};const _renderNativeTrack=(track,bpm,baseOffsetSec,notesOverride)=>{try{const eng=track&&track.synth_engine;const data=_buildNativeTrackNotes(track,bpm,baseOffsetSec,notesOverride);if(window.__sfDiag)window.__sfDiag('renderNative track='+(track&&track.id)+' sfId='+(eng&&eng.soundfont_id)+' engType='+(eng&&eng.type)+' notes='+data.absNotes.length);if(!data.absNotes.length)return Promise.resolve(null);if(isSfTrackEngine(eng)){const sfId=eng&&eng.soundfont_id;if(!sfId)return Promise.resolve(null);return window.SonicAPI.soundfontRender({soundfont_id:sfId,bank:eng&&eng.soundfont_bank||0,program:eng&&eng.soundfont_program||0,bpm:parseFloat(bpm)||120,notes:data.absNotes}).then(function(res){if(!res||!res.success||!res.url)return null;return API_BASE_URL+res.url;}).catch(function(){return null;});}const instrumentId=eng&&eng.plugin_id||track&&track.instrumentId;if(!instrumentId||!isVstTrackEngine(eng))return Promise.resolve(null);return window.SonicAPI.midiRender({instrument_id:instrumentId,notes:data.absNotes,bpm:parseFloat(bpm)||120,preset_id:eng&&eng.preset_id||undefined,preset_path:eng&&eng.preset_path||undefined,preset_data:eng&&eng.preset_data||undefined}).then(function(res){if(!res||!res.success||!res.url)return null;return API_BASE_URL+res.url;}).catch(function(){return null;});}catch(e){return Promise.resolve(null);}};// Dedupe in-flight render + cache WAV: play lặp/re-schedule trong lúc render
// đang chạy → chờ cùng promise, không bắn duplicate request (tránh queue sau
// RENDER_LOCK + render 2 lần cùng nội dung).
const ensureRenderNativeTrack=(track,bpm,baseOffsetSec,notesOverride)=>{const key=_nativeTrackKey(track,bpm,baseOffsetSec,notesOverride);if(!key)return _renderNativeTrack(track,bpm,baseOffsetSec,notesOverride);if(_nativeTrackUrls[key])return Promise.resolve(_nativeTrackUrls[key]);if(_nativeTrackPending[key])return _nativeTrackPending[key];const p=_renderNativeTrack(track,bpm,baseOffsetSec,notesOverride).then(function(url){if(url)_nativeTrackUrls[key]=url;return url;}).then(function(url){delete _nativeTrackPending[key];return url;});_nativeTrackPending[key]=p;return p;};// Giới hạn decodeAudioData song song (max 3) — nhiều WAV fetch về cùng lúc →
@@ -1875,7 +1876,7 @@ if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){const isPerc
// SonicSF (FluidSynth WASM). Giữ setTimeout scheduling (như SonicSF cũ);
// bridge tự render note-off sau duration. A11 (sample-accurate) sẽ thay
// setTimeout bằng sampleOffset đẩy thẳng vào SHM.
const scheduleMidiNoteDispatch=(track,pitch,velocity,durMs,startTime,program,destNode,ch,synthEngine)=>{if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){const ctx=getAudioContext();const startAt=startTime||ctx.currentTime;// undefined/null = phát ngay
const scheduleMidiNoteDispatch=(track,pitch,velocity,durMs,startTime,program,destNode,ch,synthEngine)=>{if(window.__sfDiag)window.__sfDiag('schedMidi track='+(track&&track.id)+' p='+pitch+' ch='+ch+' prog='+program+' bridgeActive='+!!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()));if(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()){const ctx=getAudioContext();const startAt=startTime||ctx.currentTime;// undefined/null = phát ngay
const delayMs=Math.max(0,(startAt-ctx.currentTime)*1000);// Guard chống note-on trễ sau Stop: CHỈ khi là timeline (startTime thật).
const guardPlay=startTime!=null?function(){return isPlayingRef.current||subTabsRef.current&&subTabsRef.current.some(s=>s.isPlaying);}:function(){return true;};const trkId=track?track.id:0;// Percussion (bank 128) → router allocateChannel ch 9; melodic → round-robin.
const isPerc=!!(synthEngine&&synthEngine.soundfont_bank===128);// D8: track đổi instrument → gửi CC0/CC32 (bank THẬT từ synthEngine) +
@@ -1883,7 +1884,7 @@ const isPerc=!!(synthEngine&&synthEngine.soundfont_bank===128);// D8: track đ
if(program!==undefined&&program!==null){try{if(window.__ensureBridgeProgram)window.__ensureBridgeProgram(trkId,program,synthEngine);}catch(e){}}// V11: epoch guard — hủy timer cũ khi stop/loop restart (xem ref).
const epoch=bridgeNoteEpochRef.current;setTimeout(function(){if(epoch!==bridgeNoteEpochRef.current)return;if(!guardPlay())return;try{window.SonicMidiRouter.pushEvent({cmd:'NOTE_ON',channel:trkId,pitch:pitch||60,velocity:velocity||0.8,percussion:isPerc});}catch(e){}},delayMs);scheduleNoteOff('B:'+trkId+':'+(pitch||60),function(){if(epoch!==bridgeNoteEpochRef.current)return;if(!guardPlay())return;try{window.SonicMidiRouter.pushEvent({cmd:'NOTE_OFF',channel:trkId,pitch:pitch||60,velocity:0,percussion:isPerc});}catch(e){}},delayMs,durMs||1000);return;}// Native VSTi (standalone browser, bridge inactive) → midi-render thay vì
// FluidSynth GM sai âm — safety net cho mọi caller (ghost notes, per-note).
if(isStandaloneSf()&&isVstTrackEngine(synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeVstNote(track,pitch,velocity,durMs,startTime,track?track.id:'global');return;}window.SonicSF.playNote(pitch,velocity,durMs,startTime,program,destNode,ch,synthEngine);};const startTrackPlayback=offsetTime=>{const context=getAudioContext();_nativePlayEpoch++;// new play — vô hiệu doHardStop đang fade (play trong cửa sổ 80ms)
if(isStandaloneSf()&&isVstTrackEngine(synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeVstNote(track,pitch,velocity,durMs,startTime,track?track.id:'global');return;}window.SonicSF.playNote(pitch,velocity,durMs,startTime,program,destNode,ch,synthEngine);};const startTrackPlayback=offsetTime=>{const context=getAudioContext();if(window.__sfDiag)window.__sfDiag('startPlay offset='+offsetTime+' isBridgeConn='+!!(window.NativeBridgeService&&window.NativeBridgeService.isBridgeConnected)+' routerActive='+!!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())+' sonicSF='+!!window.SonicSF+' standalone='+isStandaloneSf());_nativePlayEpoch++;// new play — vô hiệu doHardStop đang fade (play trong cửa sổ 80ms)
restorePendingFadeSession();// V17: huy fade dang treo — replay khong bi dip gain
// Realtime VST FX (master chain qua SHM bridge): bật khi play + chain active.
try{fxRtStart();}catch(e){console.warn('[FX RT] start error:',e);}// D2: bridge active -> báo transport PLAY (bridge flush note-off cũ + đồng
@@ -1900,7 +1901,7 @@ try{applyMasterAutomationSchedule(offsetTime||0);}catch(e){console.warn('[Auto]
// FX (khi bật).
try{if(window.__ensureMasteringRouting)window.__ensureMasteringRouting();}catch(e){}const allPlayTracks=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:activeTracks;// Capture items signature at schedule time — updatePlayhead so sánh với
// signature này để phát hiện items đổi vị trí giữa lúc play (re-schedule).
scheduledItemsSigRef.current=buildItemsSignature(allPlayTracks,sessionTabs);console.log('[Play] offset='+offsetTime+' tracks='+allPlayTracks.length+' masterBus='+!!masterBus+' dest='+(context.destination?'ok':'MISSING')+' projectEnd='+(projectEndRef.current||0).toFixed(2));const hasSolo=allPlayTracks.some(t=>t.solo);allPlayTracks.forEach(track=>{const isPlayable=hasSolo?track.solo:!track.muted;if(!isPlayable)return;const gainNode=getOrCreateTrackNode(track,context);// Realtime VST FX per-track (SHM bridge): mọi luồng track qua bridge —
scheduledItemsSigRef.current=buildItemsSignature(allPlayTracks,sessionTabs);console.log('[Play] offset='+offsetTime+' tracks='+allPlayTracks.length+' masterBus='+!!masterBus+' dest='+(context.destination?'ok':'MISSING')+' projectEnd='+(projectEndRef.current||0).toFixed(2));const hasSolo=allPlayTracks.some(t=>t.solo);allPlayTracks.forEach(track=>{const isPlayable=hasSolo?track.solo:!track.muted;if(!isPlayable)return;if(window.__sfDiag)window.__sfDiag('playTrk id='+track.id+' name='+track.name+' type='+track.type+' midiItems='+(track.midiItems||[]).length+' items='+(track.items||[]).length+' sfEng='+isSfTrackEngine(track.synth_engine)+' vstEng='+isVstTrackEngine(track.synth_engine)+' routerAct='+!!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive()));const gainNode=getOrCreateTrackNode(track,context);// Realtime VST FX per-track (SHM bridge): mọi luồng track qua bridge —
// session/worklet cho TỪNG path có âm (audio-clips + MIDI/SF); splice
// vào graph khi WS ready.
try{const rtPaths=fxRtTrackPaths(track);if(rtPaths.indexOf('audio')>=0)fxRtTrackStart(track.id,track,'audio');if(rtPaths.indexOf('sf')>=0)fxRtTrackStart(track.id,track,'sf');}catch(e){console.warn('[FX RT track] start error:',e);}const pannerNode=activeTrackNodesRef.current[track.id].pannerNode;// Re-apply route gains theo content-type NGAY tại thời điểm play — node
@@ -2148,7 +2149,7 @@ try{const br=await fetch(res.url);if(br.ok){const b=await br.blob();const sp=awa
// trong OfflineAudioContext. Vì vậy bounce REALTIME: chạy lại toàn bộ project
// (audio + MIDI + sections qua FX racks + mastering chain, đúng như nghe) và
// capture PCM từ master bus, sau đó encode WAV trung thực.
const triggerBounceExport=async()=>{if(!masterBus){showToast("Hãy phát thử một lần để khởi tạo audio engine trước.","warning");return;}if(isExporting)return;setIsExporting(true);setShowExportPanel(false);setShowExportProgress(true);const audioCtx=getAudioContext();try{const allTracks=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:tracks;// MAIN SESSION end time (items trên track main — sections tính theo bounds,
const triggerBounceExport=async()=>{if(!masterBus){showToast("Hãy phát thử một lần để khởi tạo audio engine trước.","warning");return;}if(isExporting)return;if(window.__sfDiag)window.__sfDiag('BOUNCE_START');setIsExporting(true);setShowExportPanel(false);setShowExportProgress(true);const audioCtx=getAudioContext();try{const allTracks=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:tracks;// MAIN SESSION end time (items trên track main — sections tính theo bounds,
// không kéo dài theo nội dung SECTION-TAB) — bounce không cắt sớm/cắt thiếu.
// LOOP export: độ dài = loopBars*secondsPerBar, bắt đầu từ selection/playhead,
// cắt cứng biên — không fade, không kéo tail.
@@ -2164,7 +2165,7 @@ const totalFrames=chunks.reduce((a,c)=>a+c.length/2,0);let stereoBuf=new Float32
if(loopDur>0){const sf=Math.max(0,Math.floor(loopStart*captureRate))*2;const ef=Math.min(stereoBuf.length,Math.floor((loopStart+loopDur)*captureRate)*2);if(ef>sf)stereoBuf=stereoBuf.subarray(sf,ef);}const frames=stereoBuf.length/2;const bytesPerSample=isFloatBit?4:bitDepth/8;const headerSize=44;const fileSizeBytes=headerSize+frames*bytesPerSample*outChannels;const view=new DataView(new ArrayBuffer(fileSizeBytes));const writeString=(o,s)=>{for(let i=0;i<s.length;i++)view.setUint8(o+i,s.charCodeAt(i));};writeString(0,'RIFF');view.setUint32(4,fileSizeBytes-8,true);writeString(8,'WAVE');writeString(12,'fmt ');view.setUint32(16,16,true);view.setUint16(20,fmtTag,true);view.setUint16(22,outChannels,true);view.setUint32(24,captureRate,true);view.setUint32(28,captureRate*bytesPerSample*outChannels,true);view.setUint16(32,bytesPerSample,true);view.setUint16(34,bitDepth,true);writeString(36,'data');view.setUint32(40,frames*bytesPerSample*outChannels,true);let o=44;const lsb=bitDepth===24?1/0x800000:bitDepth===8?1/128:1/0x8000;// 1 LSB in float
for(let i=0;i<frames;i++){for(let ch=0;ch<outChannels;ch++){const raw=stereoBuf[i*2+ch];if(isFloatBit){view.setFloat32(o,raw,true);// bit-exact, no clamp/quantize
}else{// Single-stage TPDF dither, then clamp, then quantize (final export only)
const s=Math.max(-1,Math.min(1,tpdfDither(raw,lsb)));if(bitDepth===24){const v=Math.floor(s<0?s*0x800000:s*0x7FFFFF)&0xFFFFFF;view.setUint8(o,v&0xFF);view.setUint8(o+1,v>>8&0xFF);view.setUint8(o+2,v>>16&0xFF);}else if(bitDepth===16)view.setInt16(o,Math.floor(s<0?s*0x8000:s*0x7FFF),true);else view.setUint8(o,Math.floor((s+1)*127.5),true);}o+=bytesPerSample;}}const blob=new Blob([view],{type:'audio/wav'});await downloadBlobViaServer(blob,`sonicforge-bounce-${captureRate}hz-${bitDepth}bit.wav`).catch(()=>{});showToast("Bounce hoàn tất — file gồm MIDI + FX Rack + Mastering Chain!","success");}catch(err){showToast("Lỗi bounce: "+err.message,"error");}finally{setIsExporting(false);setShowExportProgress(false);}};const handleSaveLocalProject=name=>{const finalName=name||projectName||'Dự án mới';const localId=currentProjectId&&currentProjectId.startsWith('local_')?currentProjectId:'local_'+Date.now();const projectSchemaObj=serializeProjectToSchema(localId,finalName,bpm,tracks,subTabs,sessionTabs,masteringSettings,masterVolume,{volL:masterVolL,volR:masterVolR,link:masterLink,muted:masterMuted});const dataStr=JSON.stringify(projectSchemaObj);localStorage.setItem('sonic_local_project_data',dataStr);localStorage.setItem('sonic_project_id',localId);localStorage.setItem('sonic_project_name',finalName);setCurrentProjectId(localId);setProjectName(finalName);window.SonicStorage.exportProjectToSFS(projectSchemaObj);showToast(`Đã lưu dự án local "${finalName}" thành công!`,"success");// Đã đăng nhập → đồng bộ lên Cloud (fire-and-forget): project mở được từ
const s=Math.max(-1,Math.min(1,tpdfDither(raw,lsb)));if(bitDepth===24){const v=Math.floor(s<0?s*0x800000:s*0x7FFFFF)&0xFFFFFF;view.setUint8(o,v&0xFF);view.setUint8(o+1,v>>8&0xFF);view.setUint8(o+2,v>>16&0xFF);}else if(bitDepth===16)view.setInt16(o,Math.floor(s<0?s*0x8000:s*0x7FFF),true);else view.setUint8(o,Math.floor((s+1)*127.5),true);}o+=bytesPerSample;}}const blob=new Blob([view],{type:'audio/wav'});await downloadBlobViaServer(blob,`sonicforge-bounce-${captureRate}hz-${bitDepth}bit.wav`).catch(()=>{});if(window.__sfDiag)window.__sfDiag('BOUNCE_END');try{window.alert('DIAG:\n'+window.__sfDiagDump());}catch(e){}showToast("Bounce hoàn tất — file gồm MIDI + FX Rack + Mastering Chain!","success");}catch(err){showToast("Lỗi bounce: "+err.message,"error");}finally{setIsExporting(false);setShowExportProgress(false);}};const handleSaveLocalProject=name=>{const finalName=name||projectName||'Dự án mới';const localId=currentProjectId&&currentProjectId.startsWith('local_')?currentProjectId:'local_'+Date.now();const projectSchemaObj=serializeProjectToSchema(localId,finalName,bpm,tracks,subTabs,sessionTabs,masteringSettings,masterVolume,{volL:masterVolL,volR:masterVolR,link:masterLink,muted:masterMuted});const dataStr=JSON.stringify(projectSchemaObj);localStorage.setItem('sonic_local_project_data',dataStr);localStorage.setItem('sonic_project_id',localId);localStorage.setItem('sonic_project_name',finalName);setCurrentProjectId(localId);setProjectName(finalName);window.SonicStorage.exportProjectToSFS(projectSchemaObj);showToast(`Đã lưu dự án local "${finalName}" thành công!`,"success");// Đã đăng nhập → đồng bộ lên Cloud (fire-and-forget): project mở được từ
// máy khác / browser ẩn danh khi đăng nhập cùng tài khoản.
if(currentUser&&window.SonicAPI&&window.SonicAPI.saveCloudProject){window.SonicAPI.saveCloudProject(finalName,dataStr).then(function(res){if(res&&res.project_id){console.log('[Cloud] local project synced:',res.project_id);}}).catch(function(e){console.warn('Cloud sync local project failed:',e);});}};const handleSaveCloudProject=async(name,existingProjectId)=>{const finalName=name||projectName||'Dự án mới';var useProjectId=existingProjectId||currentProjectId;const projectSchemaObj=serializeProjectToSchema(useProjectId||'project_'+Date.now(),finalName,bpm,tracks,subTabs,sessionTabs,masteringSettings,masterVolume,{volL:masterVolL,volR:masterVolR,link:masterLink,muted:masterMuted});// Diagnostic: count source vs serialized items
var srcMidi=0,srcClip=0,srcSec=0;tracks.forEach(function(t){srcMidi+=(t.midiItems||[]).length;srcClip+=(t.clips||[]).length;srcSec+=(t.sections||[]).length;});var serItems=0;if(projectSchemaObj.main_session&&projectSchemaObj.main_session.tracks){projectSchemaObj.main_session.tracks.forEach(function(st){if(st.items)serItems+=st.items.length;});}Object.keys(projectSchemaObj.section_store||{}).forEach(function(sk){(projectSchemaObj.section_store[sk].tracks||[]).forEach(function(st){if(st.items)serItems+=st.items.length;});});const dataJson=JSON.stringify(projectSchemaObj);try{if(existingProjectId){await window.SonicAPI.updateCloudProject(existingProjectId,finalName,dataJson);setProjectName(finalName);setCurrentProjectId(existingProjectId);localStorage.setItem('sonic_project_name',finalName);localStorage.setItem('sonic_project_id',existingProjectId);showToast('Đã ghi đè Cloud "'+finalName+'" (src midi='+srcMidi+' clip='+srcClip+' sec='+srcSec+' | ser='+serItems+').','success');}else if(currentProjectId&&!currentProjectId.startsWith('local_')){await window.SonicAPI.updateCloudProject(currentProjectId,finalName,dataJson);setProjectName(finalName);localStorage.setItem('sonic_project_name',finalName);localStorage.setItem('sonic_project_id',currentProjectId);showToast('Đã lưu Cloud "'+finalName+'" (src midi='+srcMidi+' clip='+srcClip+' sec='+srcSec+' | ser='+serItems+' id='+currentProjectId+').',serItems===0?'warning':'success');}else{const res=await window.SonicAPI.saveCloudProject(finalName,dataJson);const newProjId=res.project_id;setProjectName(finalName);localStorage.setItem('sonic_project_name',finalName);if(newProjId){setCurrentProjectId(newProjId);localStorage.setItem('sonic_project_id',newProjId);}showToast('Đã lưu Cloud mới "'+finalName+'" (src midi='+srcMidi+' clip='+srcClip+' sec='+srcSec+' | ser='+serItems+').',serItems===0?'warning':'success');}}catch(err){showToast(err.message||"Lỗi lưu dự án lên Cloud","error");}};const handleSaveProject=async()=>{if(!currentProjectId){setSaveProjectModalOpen(true);return;}if(currentProjectId.startsWith('local_')){handleSaveLocalProject(projectName);}else{if(!currentUser){showToast('Cần đăng nhập để lưu Cloud. currentUser='+(currentUser?'OK':'NULL')+' token='+(localStorage.getItem('sonic_token')?'exists':'missing'),'warning');return;}handleSaveCloudProject(projectName);}};const handleSaveProjectRef=useRef(handleSaveProject);handleSaveProjectRef.current=handleSaveProject;// ── Ctrl-S (Save) toàn cục ──