FEAT: Media Explorer preview loop liên tục vùng chọn + margin 2px canvas
This commit is contained in:
+141
-20
@@ -9044,6 +9044,7 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
|
|||||||
const [selEnd, setSelEnd] = React.useState(null);
|
const [selEnd, setSelEnd] = React.useState(null);
|
||||||
const [isDragging, setIsDragging] = React.useState(false);
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
const [previewCtxMenu, setPreviewCtxMenu] = React.useState(null); // { x, y }
|
const [previewCtxMenu, setPreviewCtxMenu] = React.useState(null); // { x, y }
|
||||||
|
const containerRef = React.useRef(null);
|
||||||
// Refs mirror latest state so drawCanvas (also called from rAF clock with a
|
// Refs mirror latest state so drawCanvas (also called from rAF clock with a
|
||||||
// stale closure) always draws the currently selected file, not the old one.
|
// stale closure) always draws the currently selected file, not the old one.
|
||||||
const selectedRef = React.useRef(null);
|
const selectedRef = React.useRef(null);
|
||||||
@@ -9060,6 +9061,9 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
|
|||||||
const folderRef = React.useRef('library');
|
const folderRef = React.useRef('library');
|
||||||
const tempoRef = React.useRef(120);
|
const tempoRef = React.useRef(120);
|
||||||
const currentTimeRef = React.useRef(0);
|
const currentTimeRef = React.useRef(0);
|
||||||
|
const selStartRef = React.useRef(null);
|
||||||
|
const selEndRef = React.useRef(null);
|
||||||
|
const isLoopingRef = React.useRef(false);
|
||||||
selectedRef.current = selected;
|
selectedRef.current = selected;
|
||||||
peaksRef.current = peaks;
|
peaksRef.current = peaks;
|
||||||
audioBufferRef.current = audioBuffer;
|
audioBufferRef.current = audioBuffer;
|
||||||
@@ -9074,13 +9078,26 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
|
|||||||
folderRef.current = folder;
|
folderRef.current = folder;
|
||||||
tempoRef.current = tempo;
|
tempoRef.current = tempo;
|
||||||
currentTimeRef.current = currentTime;
|
currentTimeRef.current = currentTime;
|
||||||
|
selStartRef.current = selStart;
|
||||||
|
selEndRef.current = selEnd;
|
||||||
|
isLoopingRef.current = isLooping;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setScrollOffset(0);
|
setScrollOffset(0);
|
||||||
}, [selected]);
|
}, [selected]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
window.mediaExplorerActive = true;
|
||||||
|
const handleDocumentClick = (e) => {
|
||||||
|
if (containerRef.current && containerRef.current.contains(e.target)) {
|
||||||
|
window.mediaExplorerActive = true;
|
||||||
|
} else {
|
||||||
|
window.mediaExplorerActive = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleDocumentClick, { capture: true });
|
||||||
return () => {
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleDocumentClick, { capture: true });
|
||||||
window.mediaExplorerActive = false;
|
window.mediaExplorerActive = false;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@@ -9324,6 +9341,19 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (e.key === 'c' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
if (window.mediaExplorerActive) {
|
||||||
|
const target = e.target;
|
||||||
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selStartRef.current !== null && selEndRef.current !== null && Math.abs(selStartRef.current - selEndRef.current) > 0.01) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCopySelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||||
return () => {
|
return () => {
|
||||||
@@ -9968,6 +9998,14 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
|
|||||||
const prog = (curInst && curInst.program !== undefined) ? curInst.program : undefined;
|
const prog = (curInst && curInst.program !== undefined) ? curInst.program : undefined;
|
||||||
const eng = curInst ? { soundfont_id: sfId, soundfont_bank: bank, soundfont_program: prog !== undefined ? prog : 0 } : undefined;
|
const eng = curInst ? { soundfont_id: sfId, soundfont_bank: bank, soundfont_program: prog !== undefined ? prog : 0 } : undefined;
|
||||||
const totalSec = midiResult[0].duration || 4;
|
const totalSec = midiResult[0].duration || 4;
|
||||||
|
const totalBeats = midiResult[0].totalBeats || 16;
|
||||||
|
const hasSelection = selStart !== null && selEnd !== null && Math.abs(selStart - selEnd) > 0.01;
|
||||||
|
const loopStartBeats = hasSelection ? Math.min(selStart, selEnd) : 0;
|
||||||
|
const loopEndBeats = hasSelection ? Math.max(selStart, selEnd) : totalBeats;
|
||||||
|
const loopDurationBeats = loopEndBeats - loopStartBeats;
|
||||||
|
const loopDurationSec = loopDurationBeats * secondsPerBeat;
|
||||||
|
const loopStartSec = loopStartBeats * secondsPerBeat;
|
||||||
|
|
||||||
const allNotes = [];
|
const allNotes = [];
|
||||||
midiResult.forEach(track => {
|
midiResult.forEach(track => {
|
||||||
(track.notes || []).forEach(note => {
|
(track.notes || []).forEach(note => {
|
||||||
@@ -9976,25 +10014,32 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
|
|||||||
});
|
});
|
||||||
const schedulePass = (passStartTime) => {
|
const schedulePass = (passStartTime) => {
|
||||||
allNotes.forEach(note => {
|
allNotes.forEach(note => {
|
||||||
const startSec = (note.start_beat || 0) * secondsPerBeat + (note.trackOffset || 0);
|
const noteStartBeat = note.start_beat || 0;
|
||||||
|
if (hasSelection) {
|
||||||
|
if (noteStartBeat < loopStartBeats || noteStartBeat >= loopEndBeats) return;
|
||||||
|
}
|
||||||
|
const shiftedStartBeat = hasSelection ? (noteStartBeat - loopStartBeats) : noteStartBeat;
|
||||||
|
const startSec = shiftedStartBeat * secondsPerBeat + (note.trackOffset || 0);
|
||||||
const durMs = Math.max(80, (note.duration_beats || 1) * secondsPerBeat * 1000);
|
const durMs = Math.max(80, (note.duration_beats || 1) * secondsPerBeat * 1000);
|
||||||
window.SonicSF.playNote(note.pitch || 60, (note.velocity || 0.8), durMs, passStartTime + startSec, prog, null, 0, eng);
|
window.SonicSF.playNote(note.pitch || 60, (note.velocity || 0.8), durMs, passStartTime + startSec, prog, null, 0, eng);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
schedulePass(startWallTime);
|
schedulePass(startWallTime);
|
||||||
// Loop scheduling
|
// Loop scheduling: keep looping continuously until Stop is pressed.
|
||||||
if (loopTimerRef.current) { clearInterval(loopTimerRef.current); loopTimerRef.current = null; }
|
if (loopTimerRef.current) { clearInterval(loopTimerRef.current); loopTimerRef.current = null; }
|
||||||
if (isLooping) {
|
if (isLoopingRef.current) {
|
||||||
loopTimerRef.current = setInterval(() => {
|
loopTimerRef.current = setInterval(() => {
|
||||||
if (selectTokenRef.current !== (token || selectTokenRef.current)) { clearInterval(loopTimerRef.current); loopTimerRef.current = null; return; }
|
if (selectTokenRef.current !== (token || selectTokenRef.current)) { clearInterval(loopTimerRef.current); loopTimerRef.current = null; return; }
|
||||||
if (!isPlayingRef.current || isPausedRef.current) return;
|
if (!isPlayingRef.current || isPausedRef.current) return;
|
||||||
|
if (!isLoopingRef.current) { clearInterval(loopTimerRef.current); loopTimerRef.current = null; return; }
|
||||||
const passStart = ctx.currentTime + 0.05;
|
const passStart = ctx.currentTime + 0.05;
|
||||||
schedulePass(passStart);
|
schedulePass(passStart);
|
||||||
playStateRef.current = Object.assign({}, playStateRef.current, { startedAt: passStart, fakeStart: passStart });
|
playStateRef.current = Object.assign({}, playStateRef.current, { startedAt: passStart, fakeStart: passStart });
|
||||||
}, Math.max(200, totalSec * 1000));
|
}, Math.max(200, loopDurationSec * 1000));
|
||||||
}
|
}
|
||||||
// Keep a fake clock so canvas playhead animates; loop uses playStateRef
|
// Keep a fake clock so canvas playhead animates; loop uses playStateRef
|
||||||
playStateRef.current = { source: null, ctx, startedAt: startWallTime, fakeStart: startWallTime, midiTotal: totalSec };
|
const startedAtTime = startWallTime - loopStartSec;
|
||||||
|
playStateRef.current = { source: null, ctx, startedAt: startedAtTime, fakeStart: startedAtTime, midiTotal: totalSec };
|
||||||
setMidiNotes(allNotes);
|
setMidiNotes(allNotes);
|
||||||
setMidiTotal(totalSec);
|
setMidiTotal(totalSec);
|
||||||
setMidiBars(midiResult[0].bars || 1);
|
setMidiBars(midiResult[0].bars || 1);
|
||||||
@@ -10049,15 +10094,31 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
|
|||||||
setAudioDuration(decoded.duration);
|
setAudioDuration(decoded.duration);
|
||||||
const src = ctx.createBufferSource();
|
const src = ctx.createBufferSource();
|
||||||
src.buffer = decoded;
|
src.buffer = decoded;
|
||||||
src.loop = isLooping;
|
const sStart = selStartRef.current;
|
||||||
|
const sEnd = selEndRef.current;
|
||||||
|
const hasSelection = sStart !== null && sEnd !== null && Math.abs(sStart - sEnd) > 0.01;
|
||||||
|
let startOffset = 0;
|
||||||
|
if (hasSelection) {
|
||||||
|
startOffset = Math.min(sStart, sEnd);
|
||||||
|
}
|
||||||
|
src.loop = isLoopingRef.current;
|
||||||
|
if (isLoopingRef.current) {
|
||||||
|
if (hasSelection) {
|
||||||
|
src.loopStart = Math.min(sStart, sEnd);
|
||||||
|
src.loopEnd = Math.max(sStart, sEnd);
|
||||||
|
} else {
|
||||||
|
src.loopStart = 0;
|
||||||
|
src.loopEnd = decoded.duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
src.playbackRate.value = rate;
|
src.playbackRate.value = rate;
|
||||||
const gain = ctx.createGain();
|
const gain = ctx.createGain();
|
||||||
const linear = volumeDb <= -50 ? 0 : Math.pow(10, volumeDb / 20);
|
const linear = volumeDb <= -50 ? 0 : Math.pow(10, volumeDb / 20);
|
||||||
gain.gain.value = linear;
|
gain.gain.value = linear;
|
||||||
src.connect(gain);
|
src.connect(gain);
|
||||||
gain.connect(ctx.destination);
|
gain.connect(ctx.destination);
|
||||||
src.start();
|
src.start(0, startOffset);
|
||||||
const startedAt = ctx.currentTime;
|
const startedAt = ctx.currentTime - startOffset;
|
||||||
playStateRef.current = { source: src, ctx, startedAt };
|
playStateRef.current = { source: src, ctx, startedAt };
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
setIsPaused(false);
|
setIsPaused(false);
|
||||||
@@ -10084,7 +10145,42 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
|
|||||||
let t = currentTimeRef.current;
|
let t = currentTimeRef.current;
|
||||||
const st = playStateRef.current;
|
const st = playStateRef.current;
|
||||||
if (st && !isPausedRef.current) {
|
if (st && !isPausedRef.current) {
|
||||||
t = st.ctx ? st.ctx.currentTime - st.startedAt : performance.now() / 1000 - st.fakeStart;
|
const rawElapsed = st.ctx ? st.ctx.currentTime - st.startedAt : performance.now() / 1000 - st.fakeStart;
|
||||||
|
const f = selectedRef.current;
|
||||||
|
const dur = fileDuration(f);
|
||||||
|
const isMidi = isMidiFile(f);
|
||||||
|
const bpmVal = tempoRef.current || 120;
|
||||||
|
const secondsPerBeat = 60.0 / bpmVal;
|
||||||
|
const sStart = selStartRef.current;
|
||||||
|
const sEnd = selEndRef.current;
|
||||||
|
const hasSelection = sStart !== null && sEnd !== null && Math.abs(sStart - sEnd) > 0.01;
|
||||||
|
|
||||||
|
// Loop plays continuously (forever) until Stop is pressed.
|
||||||
|
if (isLoopingRef.current) {
|
||||||
|
if (isMidi) {
|
||||||
|
const totalBeats = midiTotalBeatsRef.current || 16;
|
||||||
|
const loopStartBeats = hasSelection ? Math.min(sStart, sEnd) : 0;
|
||||||
|
const loopEndBeats = hasSelection ? Math.max(sStart, sEnd) : totalBeats;
|
||||||
|
const loopDurationBeats = loopEndBeats - loopStartBeats;
|
||||||
|
const loopDurationSec = loopDurationBeats * secondsPerBeat;
|
||||||
|
const loopStartSec = loopStartBeats * secondsPerBeat;
|
||||||
|
|
||||||
|
t = loopStartSec + Math.max(0, (rawElapsed - loopStartSec) % Math.max(0.1, loopDurationSec));
|
||||||
|
} else {
|
||||||
|
const loopStartSec = hasSelection ? Math.min(sStart, sEnd) : 0;
|
||||||
|
const loopEndSec = hasSelection ? Math.max(sStart, sEnd) : dur;
|
||||||
|
const loopDurationSec = loopEndSec - loopStartSec;
|
||||||
|
|
||||||
|
t = loopStartSec + Math.max(0, (rawElapsed - loopStartSec) % Math.max(0.1, loopDurationSec));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t = rawElapsed;
|
||||||
|
const endLimit = hasSelection ? (isMidi ? (Math.max(sStart, sEnd) * secondsPerBeat) : Math.max(sStart, sEnd)) : dur;
|
||||||
|
if (t >= endLimit) {
|
||||||
|
t = endLimit;
|
||||||
|
stopMediaPlayback();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setCurrentTime(t);
|
setCurrentTime(t);
|
||||||
currentTimeRef.current = t;
|
currentTimeRef.current = t;
|
||||||
@@ -10120,6 +10216,11 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
|
|||||||
}
|
}
|
||||||
const dur = fileDuration(f);
|
const dur = fileDuration(f);
|
||||||
|
|
||||||
|
// Read selection from refs so rAF-driven redraws always show current selection
|
||||||
|
const drawSelStart = selStartRef.current;
|
||||||
|
const drawSelEnd = selEndRef.current;
|
||||||
|
const drawHasSel = drawSelStart !== null && drawSelEnd !== null && Math.abs(drawSelStart - drawSelEnd) > 0.01;
|
||||||
|
|
||||||
// Scale layout parameters using zoom
|
// Scale layout parameters using zoom
|
||||||
const pxPerBeat = 42 * zoom;
|
const pxPerBeat = 42 * zoom;
|
||||||
const pxPerSec = pxPerBeat * (curTempo || 120) / 60;
|
const pxPerSec = pxPerBeat * (curTempo || 120) / 60;
|
||||||
@@ -10170,9 +10271,9 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Draw selection overlay
|
// Draw selection overlay
|
||||||
if (selStart !== null && selEnd !== null && Math.abs(selStart - selEnd) > 0.01) {
|
if (drawHasSel) {
|
||||||
const startVal = Math.min(selStart, selEnd);
|
const startVal = Math.min(drawSelStart, drawSelEnd);
|
||||||
const endVal = Math.max(selStart, selEnd);
|
const endVal = Math.max(drawSelStart, drawSelEnd);
|
||||||
const xStart = startVal * pxPerBeat - offset;
|
const xStart = startVal * pxPerBeat - offset;
|
||||||
const xEnd = endVal * pxPerBeat - offset;
|
const xEnd = endVal * pxPerBeat - offset;
|
||||||
ctx.fillStyle = 'rgba(59, 130, 246, 0.25)';
|
ctx.fillStyle = 'rgba(59, 130, 246, 0.25)';
|
||||||
@@ -10212,9 +10313,9 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Draw selection overlay
|
// Draw selection overlay
|
||||||
if (selStart !== null && selEnd !== null && Math.abs(selStart - selEnd) > 0.01) {
|
if (drawHasSel) {
|
||||||
const startVal = Math.min(selStart, selEnd);
|
const startVal = Math.min(drawSelStart, drawSelEnd);
|
||||||
const endVal = Math.max(selStart, selEnd);
|
const endVal = Math.max(drawSelStart, drawSelEnd);
|
||||||
const xStart = startVal * pxPerSec - offset;
|
const xStart = startVal * pxPerSec - offset;
|
||||||
const xEnd = endVal * pxPerSec - offset;
|
const xEnd = endVal * pxPerSec - offset;
|
||||||
ctx.fillStyle = 'rgba(59, 130, 246, 0.25)';
|
ctx.fillStyle = 'rgba(59, 130, 246, 0.25)';
|
||||||
@@ -10336,10 +10437,30 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
|
|||||||
const toggleLoop = () => {
|
const toggleLoop = () => {
|
||||||
setIsLooping(prev => {
|
setIsLooping(prev => {
|
||||||
const next = !prev;
|
const next = !prev;
|
||||||
if (playStateRef.current && playStateRef.current.source) playStateRef.current.source.loop = next;
|
// Sync ref immediately so playMidiPreview (called below) sees the new value
|
||||||
if (next && isMidiFile(selectedRef.current)) {
|
isLoopingRef.current = next;
|
||||||
|
const cur = selectedRef.current;
|
||||||
|
const st = playStateRef.current;
|
||||||
|
const sStart = selStartRef.current;
|
||||||
|
const sEnd = selEndRef.current;
|
||||||
|
const hasSelection = sStart !== null && sEnd !== null && Math.abs(sStart - sEnd) > 0.01;
|
||||||
|
if (st && st.source) {
|
||||||
|
st.source.loop = next;
|
||||||
|
// When enabling loop for a currently playing audio buffer, also update
|
||||||
|
// the loop points to the current selection so it loops continuously
|
||||||
|
// over the selected region until Stop is pressed.
|
||||||
|
if (next && st.source.buffer) {
|
||||||
|
if (hasSelection) {
|
||||||
|
st.source.loopStart = Math.min(sStart, sEnd);
|
||||||
|
st.source.loopEnd = Math.max(sStart, sEnd);
|
||||||
|
} else {
|
||||||
|
st.source.loopStart = 0;
|
||||||
|
st.source.loopEnd = st.source.buffer.duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (next && isMidiFile(cur)) {
|
||||||
// Re-schedule loop for the currently previewing MIDI file
|
// Re-schedule loop for the currently previewing MIDI file
|
||||||
const cur = selectedRef.current;
|
|
||||||
if (cur && (cur.handle || cur.path || cur.file_id || cur.fileId)) {
|
if (cur && (cur.handle || cur.path || cur.file_id || cur.fileId)) {
|
||||||
selectTokenRef.current++;
|
selectTokenRef.current++;
|
||||||
const token = selectTokenRef.current;
|
const token = selectTokenRef.current;
|
||||||
@@ -10359,7 +10480,7 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
|
|||||||
const selBpm = (selected && selected.bpm) || tempo;
|
const selBpm = (selected && selected.bpm) || tempo;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full h-full text-slate-900 overflow-hidden select-none" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<div ref={containerRef} className="flex flex-col w-full h-full text-slate-900 overflow-hidden select-none" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
{/* 1. TOP NAVIGATION TOOLBAR */}
|
{/* 1. TOP NAVIGATION TOOLBAR */}
|
||||||
<div className="h-8 bg-[#d4d0c8] border-b border-[#808080] px-2 flex items-center justify-between text-xs shrink-0">
|
<div className="h-8 bg-[#d4d0c8] border-b border-[#808080] px-2 flex items-center justify-between text-xs shrink-0">
|
||||||
<div className="flex items-center gap-1 flex-1 mr-2">
|
<div className="flex items-center gap-1 flex-1 mr-2">
|
||||||
@@ -10646,7 +10767,7 @@ const MediaExplorerPanel = ({ height, clipboardRef }) => {
|
|||||||
|
|
||||||
{/* CANVAS + METADATA */}
|
{/* CANVAS + METADATA */}
|
||||||
<div className="flex items-stretch gap-2 my-1 flex-1 min-h-0">
|
<div className="flex items-stretch gap-2 my-1 flex-1 min-h-0">
|
||||||
<div className="flex-1 bg-[#181818] border border-[#3a3a3a] relative overflow-hidden">
|
<div className="flex-1 bg-[#181818] border border-[#3a3a3a] relative overflow-hidden p-0.5">
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className="w-full h-full block cursor-pointer"
|
className="w-full h-full block cursor-pointer"
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1189,3 +1189,8 @@
|
|||||||
- **Tóm tắt thay đổi:** Nguyên nhân gốc: FluidSynth WASM (soundfontPlayer) KHÔNG decode được mẫu Ogg Vorbis/SF3 — mọi sample có bit OGG_VORBIS bị từ chối ("unknown flags... unsupported compression") → chọn instrument từ soundfont .sf3 (Sonatina đang ở dạng SF3 do startup convert) → câm hoàn toàn. Ngoài ra converter SF2→SF3 cũ còn 2 bug: (1) thiếu chunk id 'smpl' khi dựng lại file → sfload -1; (2) offset đọc shdr sai (đọc [end,loopstart,loopend,rate] thay vì [start,end,...]) → smpl rỗng. Fix: (1) `sf3_to_sf2()` + `_decode_sample_ogg()` chuyển SF3→SF2 (giải nén OGG về PCM, đúng semantics end-exclusive, loop absolute, xóa bit OGG) — đã verify phát âm thanh cả native lẫn WASM; (2) endpoint download ưu tiên .sf2, nếu chỉ có .sf3 thì chuyển SF3→SF2 on-demand (cache vào upload dir); (3) vô hiệu startup convert SF2→SF3 (`main.py`) — client không chơi được SF3; (4) client `loadSoundFont` nếu cache IndexedDB cũ chứa buffer hỏng thì xóa cache + tải lại; (5) sửa `_sf2_to_sf3_python` (smpl id, shdr offset đúng, end-exclusive, version 3.0, bit OGG, loop relative) + gate `_sf3_plays_audio` xóa SF3 không phát được.
|
- **Tóm tắt thay đổi:** Nguyên nhân gốc: FluidSynth WASM (soundfontPlayer) KHÔNG decode được mẫu Ogg Vorbis/SF3 — mọi sample có bit OGG_VORBIS bị từ chối ("unknown flags... unsupported compression") → chọn instrument từ soundfont .sf3 (Sonatina đang ở dạng SF3 do startup convert) → câm hoàn toàn. Ngoài ra converter SF2→SF3 cũ còn 2 bug: (1) thiếu chunk id 'smpl' khi dựng lại file → sfload -1; (2) offset đọc shdr sai (đọc [end,loopstart,loopend,rate] thay vì [start,end,...]) → smpl rỗng. Fix: (1) `sf3_to_sf2()` + `_decode_sample_ogg()` chuyển SF3→SF2 (giải nén OGG về PCM, đúng semantics end-exclusive, loop absolute, xóa bit OGG) — đã verify phát âm thanh cả native lẫn WASM; (2) endpoint download ưu tiên .sf2, nếu chỉ có .sf3 thì chuyển SF3→SF2 on-demand (cache vào upload dir); (3) vô hiệu startup convert SF2→SF3 (`main.py`) — client không chơi được SF3; (4) client `loadSoundFont` nếu cache IndexedDB cũ chứa buffer hỏng thì xóa cache + tải lại; (5) sửa `_sf2_to_sf3_python` (smpl id, shdr offset đúng, end-exclusive, version 3.0, bit OGG, loop relative) + gate `_sf3_plays_audio` xóa SF3 không phát được.
|
||||||
- **Các file ảnh hưởng:** `app/core/soundfont_converter.py`, `app/api/v1/plugins.py`, `app/main.py`, `app/static/js/services/soundfontPlayer.js`
|
- **Các file ảnh hưởng:** `app/core/soundfont_converter.py`, `app/api/v1/plugins.py`, `app/main.py`, `app/static/js/services/soundfontPlayer.js`
|
||||||
- **Ghi chú/Test (nếu có):** Test Node/WASM + native: SF2 gốc rms 0.007 (OK); SF3 convert mới sfload=1 nhưng câm (WASM từ chối OGG); SF3→SF2 convert ra file 52.37MB phát rms 0.007-0.02 (native + WASM). Test endpoint mô phỏng: upload SF3-only → download trả SF2 phát được. `pytest tests/test_vst_engine.py` 12 passed. Lưu ý: scipy thiếu nên test_plugin_api không import được (môi trường dev).
|
- **Ghi chú/Test (nếu có):** Test Node/WASM + native: SF2 gốc rms 0.007 (OK); SF3 convert mới sfload=1 nhưng câm (WASM từ chối OGG); SF3→SF2 convert ra file 52.37MB phát rms 0.007-0.02 (native + WASM). Test endpoint mô phỏng: upload SF3-only → download trả SF2 phát được. `pytest tests/test_vst_engine.py` 12 passed. Lưu ý: scipy thiếu nên test_plugin_api không import được (môi trường dev).
|
||||||
|
|
||||||
|
### [2026-08-03 07:55] Task: Media Explorer preview - loop liên tục vùng chọn + margin 2px quanh canvas
|
||||||
|
- **Tóm tắt thay đổi:** (1) Loop preview giờ chạy liên tục vô hạn cho đến khi nhấn Stop: `startCanvasClock` đọc refs (`isLoopingRef`/`selStartRef`/`selEndRef`) thay vì closure cũ nên việc bật loop giữa lúc đang play được phản ánh ngay, playhead wrap đúng theo `loopStartSec` (trừ offset gốc), không còn tự `stopMediaPlayback()` khi hết selection; `playMidiPreview` dùng `isLoopingRef.current` khi lập lịch interval (trước đây closure `isLooping` cũ → bật loop không tạo interval) và hủy interval khi tắt loop; `toggleLoop` sync `isLoopingRef` ngay + cập nhật `loopStart`/`loopEnd` cho audio đang phát theo selection hiện tại; `playSelected` dùng refs cho loop points/startOffset. (2) Container render canvas thêm `p-0.5` (2px) để quét chọn vùng không vượt ra ngoài khung preview.
|
||||||
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`
|
||||||
|
- **Ghi chú/Test (nếu có):** `npm run build` (babel) thành công. Smoke: chọn file audio → quét chọn 1 đoạn → bật Loop → phát liên tục vùng chọn đến khi nhấn Stop (playhead wrap đúng, selection overlay vẫn hiển thị khi rAF redraw nhờ `drawSelStart`/`drawSelEnd`). MIDI: bật loop khi đang preview → interval reschedule vùng chọn.
|
||||||
|
|||||||
Reference in New Issue
Block a user