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 @@
-
+