diff --git a/app/api/v1/plugins.py b/app/api/v1/plugins.py index c03c2ec..5712e0a 100644 --- a/app/api/v1/plugins.py +++ b/app/api/v1/plugins.py @@ -151,7 +151,11 @@ async def delete_soundfont(sf_id: str, current_user: dict = Depends(get_current_ @router.get("/soundfonts/download/{sf_id}") async def download_soundfont_asset(sf_id: str): clean_id = sf_id.replace("sf_", "") if sf_id.startswith("sf_") else sf_id - for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR]: + # Cũng tìm trong static/soundfonts (font bundled theo deployment) — trước + # đây chỉ UPLOAD + SYSTEM → font bundled 404 → incognito (IndexedDB rỗng) + # không tải được font → instrument CÂM (browser thường dùng cache nên OK). + static_sf_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "soundfonts") + for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR, static_sf_dir]: if not os.path.isdir(base_dir): continue # Prefer SF2: the client FluidSynth WASM cannot decode SF3 (Ogg Vorbis) diff --git a/app/main.py b/app/main.py index 8c392fc..a4a022e 100644 --- a/app/main.py +++ b/app/main.py @@ -81,7 +81,12 @@ async def get_index(): if not os.path.exists(index_path): return HTMLResponse(content=f"

SonicForge Studio: index.html not found at {index_path}

", status_code=404) with open(index_path, "r", encoding="utf-8") as file: - return HTMLResponse(content=file.read(), status_code=200) + resp = HTMLResponse(content=file.read(), status_code=200) + # no-cache: index.html PHẢI luôn mới (các bundle JS dùng ?v= để bust) — + # nếu browser cache HTML cũ → stamp cũ → tải bundle cũ (bug "không load + # được bundle mới" ở incognito — cache heuristic không có Cache-Control). + resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + return resp @app.get("/favicon.svg") diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 52dcba2..de327d6 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -2675,6 +2675,7 @@ const WaveformLane = ({ // Check if hovering near right edge of a clip for time-stretching (Alt key required) const toleranceSec = 8 / zoom; const rightEdgeClip = clips.find(c => { + if (!c.buffer) return false; const duration = c.buffer.duration / (c.speed || 1.0); return Math.abs(time - (c.startTime + duration)) <= toleranceSec; }); @@ -2725,7 +2726,7 @@ const WaveformLane = ({ return; } - const hoveredClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); + const hoveredClip = clips.find(c => c.buffer && time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); const isOverClip = !!hoveredClip; if (activeTool === 'pen') { canvasRef.current.style.cursor = isOverClip ? 'copy' : 'not-allowed'; @@ -2806,6 +2807,7 @@ const WaveformLane = ({ // Check if time-stretching (Alt + Right Edge) const toleranceSec = 8 / zoom; const rightEdgeClip = clips.find(c => { + if (!c.buffer) return false; const duration = c.buffer.duration / (c.speed || 1.0); return Math.abs(time - (c.startTime + duration)) <= toleranceSec; }); @@ -2880,7 +2882,7 @@ const WaveformLane = ({ return; } - const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); + const clickedClip = clips.find(c => c.buffer && time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); // Set selected clip ID if (clickedClip) { @@ -3022,7 +3024,7 @@ const WaveformLane = ({ if (onEditMidiInTab) onEditMidiInTab(track.id, dblMidiHit.id); return; } - const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); + const clickedClip = clips.find(c => c.buffer && time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); if (clickedClip) { e.preventDefault(); e.stopPropagation(); diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index 86000b1..e49179b 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -236,22 +236,22 @@ const midiItems=track.midiItems||[];midiItems.forEach(midi=>{const midiStartLoca if(selectionMode==='local'&&localSelectionTrackId===track.id&&localSelLeft!==null&&localSelRight!==null&&localSelRight>localSelLeft){const hlLeftLocal=localSelLeft*zoom-scrollLeftVal;const hlWidth=(localSelRight-localSelLeft)*zoom;ctx.fillStyle='rgba(245, 158, 11, 0.15)';ctx.fillRect(hlLeftLocal,0,hlWidth,height);ctx.strokeStyle='#f59e0b';ctx.lineWidth=1;ctx.strokeRect(hlLeftLocal,0,hlWidth,height);}},[track,zoom,timelineWidth,viewportWidth,isSelected,markers,selectionMode,localSelectionTrackId,localSelLeft,localSelRight,snapValue,bpm,scrollLeft,recordingState,recTempMidiNotes,recStartTimelineTime,canvasRedrawCount,currentTime,selectedItemIds]);return/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",{key:"virtual-spacer",style:{width:`${timelineWidth}px`,height:'1px',pointerEvents:'none'}}),/*#__PURE__*/React.createElement("canvas",{ref:canvasRef,style:{position:'sticky',left:0,imageRendering:'pixelated'},className:"cursor-crosshair",onMouseMove:e=>{if(!canvasRef.current)return;const rect=canvasRef.current.getBoundingClientRect();const x=e.clientX-rect.left+(scrollLeft||0);const time=Math.max(0,x/zoom-leadInMargin);const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[];// 1. Shift+Drag (TOP PRIORITY): Range selection on track waveform if(e.shiftKey&&e.buttons>0){e.preventDefault();e.stopPropagation();const currentAnchor=getLocalAnchor?getLocalAnchor():null;const anchor=currentAnchor!==null&¤tAnchor!==undefined?currentAnchor:localSelectionStart!==null?localSelectionStart:currentTime;const selS=Math.min(anchor,time);const selE=Math.max(anchor,time);if(onSetSelectionMode)onSetSelectionMode('local');if(onSetLocalSelectionTrackId)onSetLocalSelectionTrackId(track.id);if(onSetLocalSelectionStart)onSetLocalSelectionStart(selS);if(onSetLocalSelectionEnd)onSetLocalSelectionEnd(selE);if(onSetSelectionStart)onSetSelectionStart(selS);if(onSetSelectionEnd)onSetSelectionEnd(selE);if(onSetCurrentTime)onSetCurrentTime(time);if(onSelectTrack)onSelectTrack(track.id);return;}// Check if hovering near local selection boundaries of this track const isLocal=selectionMode==='local'&&localSelectionTrackId===track.id;if(isLocal&&localSelLeft!==null&&localSelRight!==null){const leftPx=localSelLeft*zoom;const rightPx=localSelRight*zoom;const distToLeft=Math.abs(x-leftPx);const distToRight=Math.abs(x-rightPx);if(distToLeft<=5||distToRight<=5){canvasRef.current.style.cursor='ew-resize';return;}}// Check if hovering near right edge of a clip for time-stretching (Alt key required) -const toleranceSec=8/zoom;const rightEdgeClip=clips.find(c=>{const duration=c.buffer.duration/(c.speed||1.0);return Math.abs(time-(c.startTime+duration))<=toleranceSec;});if(rightEdgeClip&&e.altKey){canvasRef.current.style.cursor='ew-resize';return;}// Check section/MIDI item hover for resize or drag +const toleranceSec=8/zoom;const rightEdgeClip=clips.find(c=>{if(!c.buffer)return false;const duration=c.buffer.duration/(c.speed||1.0);return Math.abs(time-(c.startTime+duration))<=toleranceSec;});if(rightEdgeClip&&e.altKey){canvasRef.current.style.cursor='ew-resize';return;}// Check section/MIDI item hover for resize or drag const allSections=track.sections||[];const allMidiItems=track.midiItems||[];const sectionTolerance=8/zoom;let foundSectionItem=null;let sectionItemEdge=null;const checkEdge=(item,startTime,dur)=>{const leftEdge=Math.abs(time-startTime)<=sectionTolerance;const rightEdge=Math.abs(time-(startTime+dur))<=sectionTolerance;if(leftEdge||rightEdge)return leftEdge?'left':'right';return null;};for(const sec of allSections){const edge=checkEdge(sec,sec.start,sec.duration);if(edge){foundSectionItem={type:'section',item:sec};sectionItemEdge=edge;break;}}if(!foundSectionItem){for(const midi of allMidiItems){const edge=checkEdge(midi,midi.startTime,midi.duration);if(edge){foundSectionItem={type:'midiItem',item:midi};sectionItemEdge=edge;break;}}}if(foundSectionItem&§ionItemEdge){canvasRef.current.style.cursor='ew-resize';return;}// Check body hover for drag -if(!foundSectionItem){for(const sec of allSections){if(time>=sec.start&&time=midi.startTime&&timetime>=c.startTime&&time=sec.start&&time=midi.startTime&&timec.buffer&&time>=c.startTime&&time{// Ignore right-click for local selection drag (context menu handles it) if(e.button===2)return;const rect=canvasRef.current.getBoundingClientRect();const x=e.clientX-rect.left+(scrollLeft||0);const time=Math.max(0,x/zoom-leadInMargin);onSelectTrack(track.id);const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[];// Check if Ctrl+Click to exit selection if(e.ctrlKey&&selectionMode){e.preventDefault();e.stopPropagation();if(onClearLocalSelection)onClearLocalSelection();if(onSetSelectionMode)onSetSelectionMode(null);if(onSetSelectionStart)onSetSelectionStart(null);if(onSetSelectionEnd)onSetSelectionEnd(null);return;}// 1. Shift+Click (TOP PRIORITY): Range selection on track waveform if(e.shiftKey){e.preventDefault();e.stopPropagation();const currentAnchor=getLocalAnchor?getLocalAnchor():null;const anchor=currentAnchor!==null&¤tAnchor!==undefined?currentAnchor:localSelectionStart!==null?localSelectionStart:currentTime;const selS=Math.min(anchor,time);const selE=Math.max(anchor,time);if(onSetSelectionMode)onSetSelectionMode('local');if(onSetLocalSelectionTrackId)onSetLocalSelectionTrackId(track.id);if(onSetLocalSelectionStart)onSetLocalSelectionStart(selS);if(onSetLocalSelectionEnd)onSetLocalSelectionEnd(selE);if(onSetSelectionStart)onSetSelectionStart(selS);if(onSetSelectionEnd)onSetSelectionEnd(selE);if(onSetCurrentTime)onSetCurrentTime(time);if(onSelectTrack)onSelectTrack(track.id);return;}// Check if dragging selection boundaries (local mode) const isLocal=selectionMode==='local'&&localSelectionTrackId===track.id;if(isLocal&&localSelLeft!==null&&localSelRight!==null){const leftPx=localSelLeft*zoom;const rightPx=localSelRight*zoom;const distToLeft=Math.abs(x-leftPx);const distToRight=Math.abs(x-rightPx);if(distToLeft<=5){e.preventDefault();e.stopPropagation();if(onSelectionEdgeDragStart)onSelectionEdgeDragStart(e,track.id,'left');return;}else if(distToRight<=5){e.preventDefault();e.stopPropagation();if(onSelectionEdgeDragStart)onSelectionEdgeDragStart(e,track.id,'right');return;}}// Check if time-stretching (Alt + Right Edge) -const toleranceSec=8/zoom;const rightEdgeClip=clips.find(c=>{const duration=c.buffer.duration/(c.speed||1.0);return Math.abs(time-(c.startTime+duration))<=toleranceSec;});if(rightEdgeClip&&e.altKey){e.preventDefault();e.stopPropagation();if(onClipStretchStart){onClipStretchStart(track.id,rightEdgeClip.id,time);}return;}// Check section/MIDI item edge for resize, then body for drag +const toleranceSec=8/zoom;const rightEdgeClip=clips.find(c=>{if(!c.buffer)return false;const duration=c.buffer.duration/(c.speed||1.0);return Math.abs(time-(c.startTime+duration))<=toleranceSec;});if(rightEdgeClip&&e.altKey){e.preventDefault();e.stopPropagation();if(onClipStretchStart){onClipStretchStart(track.id,rightEdgeClip.id,time);}return;}// Check section/MIDI item edge for resize, then body for drag const secItems=track.sections||[];const midiItems=track.midiItems||[];const secTol=8/zoom;let hitItem=null;let hitEdge=null;for(const sec of secItems){if(Math.abs(time-sec.start)<=secTol){hitItem={type:'section',id:sec.id,start:sec.start,dur:sec.duration};hitEdge='left';break;}if(Math.abs(time-(sec.start+sec.duration))<=secTol){hitItem={type:'section',id:sec.id,start:sec.start,dur:sec.duration};hitEdge='right';break;}}if(!hitItem){for(const midi of midiItems){if(Math.abs(time-midi.startTime)<=secTol){hitItem={type:'midiItem',id:midi.id,start:midi.startTime,dur:midi.duration};hitEdge='left';break;}if(Math.abs(time-(midi.startTime+midi.duration))<=secTol){hitItem={type:'midiItem',id:midi.id,start:midi.startTime,dur:midi.duration};hitEdge='right';break;}}}if(hitItem&&hitEdge){e.preventDefault();e.stopPropagation();if(onSectionItemResizeStart)onSectionItemResizeStart(track.id,hitItem.type,hitItem.id,hitEdge,time);return;}if(!hitItem){for(const sec of secItems){if(time>=sec.start&&time=midi.startTime&&time1?selectedItemIds:new Set([hitItem.id]);if(onSetPendingDragMove)onSetPendingDragMove(track.id,hitItem.type,hitItem.id,time-hitItem.start,e.nativeEvent||e,dragIds);}return;}const clickedClip=clips.find(c=>time>=c.startTime&&time1?selectedItemIds:new Set([hitItem.id]);if(onSetPendingDragMove)onSetPendingDragMove(track.id,hitItem.type,hitItem.id,time-hitItem.start,e.nativeEvent||e,dragIds);}return;}const clickedClip=clips.find(c=>c.buffer&&time>=c.startTime&&time{const rect=canvasRef.current.getBoundingClientRect();const x=e.clientX-rect.left+(scrollLeft||0);const time=Math.max(0,x/zoom-leadInMargin);const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[];// Check double-click on section first const dblSecItems=track.sections||[];let dblSecHit=null;for(const sec of dblSecItems){if(time>=sec.start&&time=midi.startTime&&timetime>=c.startTime&&time{e.preventDefault();e.stopPropagation();onSelectTrack(track.id);const rect=canvasRef.current.getBoundingClientRect();const x=e.clientX-rect.left+(scrollLeft||0);const time=Math.max(0,x/zoom-leadInMargin);// Detect section under cursor +const dblMidiItems=track.midiItems||[];let dblMidiHit=null;for(const midi of dblMidiItems){if(time>=midi.startTime&&timec.buffer&&time>=c.startTime&&time{e.preventDefault();e.stopPropagation();onSelectTrack(track.id);const rect=canvasRef.current.getBoundingClientRect();const x=e.clientX-rect.left+(scrollLeft||0);const time=Math.max(0,x/zoom-leadInMargin);// Detect section under cursor const secList=track.sections||[];let hitSectionId=null;for(const sec of secList){if(time>=sec.start&&time{const canvasRef=useRef(null);const RULER_HEIGHT=40;const drawWidth=Math.min(timelineWidth,viewportWidth);useEffect(()=>{const canvas=canvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;let scrollLeftVal=scrollLeft||0;const height=RULER_HEIGHT;canvas.width=Math.min(Math.round(drawWidth*dpr),32768);canvas.height=Math.min(Math.round(height*dpr),32768);ctx.scale(dpr,dpr);ctx.imageSmoothingEnabled=false;canvas.style.width=`${drawWidth}px`;canvas.style.height=`${height}px`;ctx.fillStyle='#242424';ctx.fillRect(0,0,drawWidth,height);ctx.strokeStyle='rgba(255,255,255,0.08)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(0,height-0.5);ctx.lineTo(drawWidth,height-0.5);ctx.stroke();const CLIP_BUFFER=400;const PADDING_LEFT=0;const tStart=scrollLeftVal/zoom-PADDING_LEFT;const tEnd=(scrollLeftVal+drawWidth)/zoom+CLIP_BUFFER/zoom;// Draw bar markers (aligned with TempoTrackLane) const beatDuration=60/bpm;const barDuration=beatDuration*4;const firstBarNum=Math.floor(tStart/barDuration);const lastBarNum=Math.ceil(tEnd/barDuration);for(let bn=firstBarNum;bn<=lastBarNum;bn++){const t=bn*barDuration;const localX=(t-tStart)*zoom;if(localX<-CLIP_BUFFER||localX>drawWidth+CLIP_BUFFER)continue;ctx.strokeStyle='rgba(255, 255, 255, 0.25)';ctx.lineWidth=1.2;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,height);ctx.stroke();ctx.fillStyle='rgba(255, 255, 255, 0.8)';ctx.font='bold 10px Inter, sans-serif';ctx.textAlign='center';ctx.fillText(`${bn}`,localX,32);}// Draw time duration labels with drag-selection markers const minTimePx=60;const rawSecInt=Math.max(1,Math.ceil(minTimePx/zoom));const timePowers=[1,2,5,10,30,60];let secInterval=timePowers.find(p=>p>=rawSecInt)||120;if(secInterval*zoomdrawWidth+CLIP_BUFFER)continue;ctx.strokeStyle='rgba(255, 180, 100, 0.12)';ctx.lineWidth=0.8;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,height);ctx.stroke();ctx.strokeStyle='rgba(255, 180, 100, 0.3)';ctx.lineWidth=1.5;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,10);ctx.stroke();ctx.fillStyle='rgba(255, 180, 100, 0.7)';ctx.font='bold 11px monospace';ctx.textAlign='center';ctx.fillText(formatTimeSimple(t),localX,24);}},[bpm,zoom,timelineWidth,viewportWidth,scrollLeft,canvasRedrawCount]);return React.createElement(React.Fragment,null,React.createElement("div",{key:"virtual-spacer-ruler",style:{width:`${timelineWidth}px`,height:'1px',pointerEvents:'none'}}),React.createElement("canvas",{ref:canvasRef,style:{position:'sticky',left:0,imageRendering:'pixelated'},className:"cursor-crosshair",onMouseDown:e=>{const rect=canvasRef.current.getBoundingClientRect();const x=e.clientX-rect.left+(scrollLeft||0);const time=Math.max(0,x/zoom);if(e.shiftKey){e.preventDefault();e.stopPropagation();}if(onRulerMouseDown)onRulerMouseDown(e);else onPlayheadSet(time,e.shiftKey);}}));};const TempoTrackLane=({bpm,zoom,timelineWidth,viewportWidth,onPlayheadSet,snapValue,onRulerMouseDown,scrollLeft,canvasRedrawCount,leadInMargin:propLeadIn})=>{const canvasRef=useRef(null);const drawWidth=Math.min(timelineWidth,viewportWidth);useEffect(()=>{const canvas=canvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;// Use scrollLeft prop directly (DOM traversal broken by sticky wrapper) diff --git a/app/static/js/services/soundfontPlayer.js b/app/static/js/services/soundfontPlayer.js index 5bfa9f4..49ffec4 100644 --- a/app/static/js/services/soundfontPlayer.js +++ b/app/static/js/services/soundfontPlayer.js @@ -25,6 +25,7 @@ let _pendingOutputDestination = null; let _outputDestination = null; // cache đích route — dedupe swap dư giữa stream let _validPercCache = {}; // { sfId: [bank, prog] | null } — preset percussion hợp lệ + let _sfLoadFailAt = {}; // { sfId: timestamp } — cooldown 10s sau load fail let _scheduledNotes = []; let _loadPromises = {}; let _sfloadSeq = 0; @@ -326,8 +327,15 @@ var url = "/api/v1/plugins/soundfonts/download/" + encodeURIComponent(sfId) + "?t=" + Date.now(); var resp = await fetch(url); if (!resp.ok) { - console.warn("[SonicSF] SoundFont not found:", sfId); - return false; + // Fallback: font bundled theo deployment (static/soundfonts — + // serve qua /soundfonts/{f} — catalog default-soundfonts). + var url2 = "/soundfonts/" + encodeURIComponent(sfId.replace(/^sf_/, '')) + "?t=" + Date.now(); + var resp2 = await fetch(url2); + if (!resp2.ok) { + console.warn("[SonicSF] SoundFont not found:", sfId); + return false; + } + resp = resp2; } buf = await resp.arrayBuffer(); if (cache) await cache.saveBuffer(sfId, buf); @@ -543,10 +551,24 @@ // Quick instrument pick on a track does not pre-load it, so load // lazily here and retry the note once the font is ready. if (finalSfId && !_sfHandleMap.has(finalSfId)) { + // Cooldown lỗi: font 404 → KHÔNG spam fetch mỗi note (10s) + // — note chạy thẳng fallback để CÓ ÂM. + var _lastFail = _sfLoadFailAt[finalSfId] || 0; + if (Date.now() - _lastFail < 10000) { + try { self._playNoteFallback(note, velocity, durationMs, startTime, program, null, channel, synthEngine); } catch (e) {} + return; + } console.log('[SonicSF] soundfont not loaded yet, loading:', finalSfId); self.loadSoundFont(finalSfId).then(function (ok) { console.log('[SonicSF] loadSoundFont result:', ok, 'for:', finalSfId); - if (ok) doNote(); + if (ok) { + doNote(); + } else { + // Font KHÔNG tải được (404/format) → KHÔNG drop note + // câm lặng ("bỏ qua WASM") — fallback oscillator. + _sfLoadFailAt[finalSfId] = Date.now(); + try { self._playNoteFallback(note, velocity, durationMs, startTime, program, null, channel, synthEngine); } catch (e) {} + } }); return; } diff --git a/app/templates/index.html b/app/templates/index.html index f76f344..815c116 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -16,7 +16,7 @@ - + @@ -24,7 +24,7 @@ - +