IMPROVE: khi zoom in thì giữ nguyên kích thước sau khi reload page, drag Audioclip item đi vị trí khác thì playhead vẫn play đúng vị trí âm thanh

This commit is contained in:
2026-08-04 12:16:53 +07:00
parent fe5e8cb58e
commit fc663921ff
4 changed files with 445 additions and 105 deletions
+313 -86
View File
@@ -136,6 +136,51 @@ function setTrackNodeGain(node, gainLinear) {
node.gainNode.gain.setTargetAtTime(gainLinear, t, 0.02);
}
// MAIN SESSION end-time (seconds): endtime of the items ON the session's own
// tracks only audio clips, MIDI items, and section-item bounds. Section-TAB
// content is deliberately IGNORED here: when played inside the main session the
// sub-track items are clamped to their section bounds, so a long SECTION-TAB
// must NOT stretch the main project. The SECTION-TAB duration is computed
// separately from ITS OWN tracks (see maxDuration useMemo).
function computeMainSessionEndTime(tracksList) {
let max = 0;
const midiEnd = m => {
if (m && typeof m.endTime === 'number') return m.endTime;
return (m && m.startTime || 0) + (m && typeof m.duration === 'number' ? m.duration : 4);
};
(tracksList || []).forEach(t => {
const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{
id: 'default', buffer: t.buffer, startTime: t.startTime || 0, speed: t.speed || 1.0
}] : [];
clips.forEach(c => {
if (c.buffer) max = Math.max(max, (c.startTime || 0) + c.buffer.duration / (c.speed || 1.0));
});
(t.midiItems || []).forEach(m => { max = Math.max(max, midiEnd(m)); });
(t.sections || []).forEach(s => { max = Math.max(max, (s.start || 0) + (s.duration || 0)); });
});
return max;
}
// Signature of all item positions/speeds/durations on the given tracks (+ the
// content inside section tabs). Compared against the signature captured at
// schedule time: when they differ mid-playback the loop re-schedules so moved
// items play at their NEW position instead of the stale one.
function buildItemsSignature(tracksList, tabsList) {
const tSig = t => {
const clips = (t.clips || []).map(c => c.id + ':' + Math.round((c.startTime || 0) * 100) + ':' + Math.round((c.speed || 1) * 100)).join(',');
const midi = (t.midiItems || []).map(m => m.id + ':' + Math.round((m.startTime || 0) * 100)).join(',');
const secs = (t.sections || []).map(s => s.id + ':' + Math.round((s.start || 0) * 100) + ':' + Math.round((s.duration || 0) * 100)).join(',');
// Track-level default clip (track.buffer): v trí lưu track.startTime
// KHÔNG nm trong t.clips, phi đưa vào signature nếu không kéo default
// clip s không kích hot re-schedule (vn phát ni dung cũ).
const defClip = (t.buffer ? 'def:' + Math.round((t.startTime || 0) * 100) + ':' + Math.round((t.speed || 1) * 100) : '');
return clips + '|' + midi + '|' + secs + '|' + defClip;
};
let s = (tracksList || []).map(tSig).join(';');
s += '##' + (tabsList || []).map(st => (st.tracks || []).map(tSig).join(';')).join('|');
return s;
}
// Reusable time-domain buffers for the imager vectorscope/correlation meter
// (leftAnalyser/rightAnalyser are fixed at fftSize 2048) allocated once so
// the 60fps render loop does not churn the GC.
@@ -3799,7 +3844,9 @@ const SubTabWaveform = ({
stretchStartRef.current = {
mouseX,
originalDuration: bufDuration,
originalSpeed: speed
originalSpeed: speed,
finalSpeed: speed,
clipName: name || 'clip'
};
canvas.style.cursor = 'ew-resize';
const handleMouseMove = moveEvent => {
@@ -3808,13 +3855,33 @@ const SubTabWaveform = ({
const deltaX = currentX - stretchStartRef.current.mouseX;
const newWClip = Math.max(10, wClipPx + deltaX);
const newSpeed = stretchStartRef.current.originalDuration / (newWClip / zoom);
if (onSpeedChange) onSpeedChange(Math.max(0.05, Math.min(10, newSpeed)));
stretchStartRef.current.finalSpeed = Math.max(0.05, Math.min(10, newSpeed));
if (onSpeedChange) onSpeedChange(stretchStartRef.current.finalSpeed);
};
const handleMouseUp = () => {
isStretchingRef.current = false;
canvas.style.cursor = 'default';
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
// UNDO/REDO cho alt-click-drag speed stretch: entry SET_CLIP_SPEED
// undo v speed gc, redo v speed cui (onSpeedChange t tính li
// volumeNodes/panningNodes/fade/label theo ratio nên khp 2 chiu).
const st = stretchStartRef.current;
if (st && typeof st.originalSpeed === 'number' && window.UndoRedoEngine) {
const finalSpeed = st.finalSpeed || st.originalSpeed;
if (Math.abs(finalSpeed - st.originalSpeed) > 0.001) {
window.UndoRedoEngine.execute({
type: 'SET_CLIP_SPEED',
scope: 'section_tab',
label: `Speed ${Math.round(st.originalSpeed * 100)}% → ${Math.round(finalSpeed * 100)}% (${st.clipName})`,
before: st.originalSpeed,
after: finalSpeed,
undo: e => { if (onSpeedChange) onSpeedChange(e.before); },
redo: e => { if (onSpeedChange) onSpeedChange(e.after); }
});
}
}
stretchStartRef.current = null;
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
@@ -9426,6 +9493,101 @@ const InteractiveEqPro = ({ track, params, onChange, getModule, applyTo, spectru
);
};
const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExporting, onExport, onBounce }) => {
if (!open) return null;
return (
<div className="fixed inset-0 z-[300] bg-black/70 backdrop-blur-sm flex items-center justify-center p-6" onClick={onClose}>
<div className="w-full max-w-2xl bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="h-9 bg-slate-800/80 border-b border-slate-700 flex items-center justify-between px-3 select-none">
<span className="text-xs font-bold text-cyan-300 font-mono flex items-center gap-1.5">
<i data-lucide="download-cloud" className="w-3.5 h-3.5" /> EXPORT
</span>
<button onClick={onClose} className="text-zinc-400 hover:text-white"><i data-lucide="x" className="w-4 h-4" /></button>
</div>
<div className="p-4 max-h-[72vh] overflow-y-auto space-y-3">
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[7px] text-zinc-500 font-bold uppercase mb-0.5">Nguồn</label>
<select value={exportSettings.source} onChange={e => setExportSettings(p => ({ ...p, source: e.target.value }))}
className="w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none">
<option value="project">Project (Mix)</option>
<option value="track_mix">Track Selection</option>
<option value="active_clip">Active Clip</option>
<option value="clip_selection">Clip Selection</option>
</select>
</div>
<div>
<label className="block text-[7px] text-zinc-500 font-bold uppercase mb-0.5">Định dạng</label>
<select value={exportSettings.format}
onChange={e => setExportSettings(p => ({ ...p, format: e.target.value, sampleRate: '44100', bitDepth: '16', quality: '44khz' }))}
className="w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none">
<option value="wav">WAV</option>
<option value="mp3">MP3</option>
<option value="ogg">OGG</option>
</select>
</div>
</div>
{exportSettings.format === 'wav' ? (
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[7px] text-zinc-500 font-bold uppercase mb-0.5">SR (Hz)</label>
<select value={exportSettings.sampleRate} onChange={e => setExportSettings(p => ({ ...p, sampleRate: e.target.value }))}
className="w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none">
<option value="22500">22500</option>
<option value="44100">44100</option>
</select>
</div>
<div>
<label className="block text-[7px] text-zinc-500 font-bold uppercase mb-0.5">Bit</label>
<select value={exportSettings.bitDepth} onChange={e => setExportSettings(p => ({ ...p, bitDepth: e.target.value }))}
className="w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none">
<option value="8">8</option>
<option value="16">16</option>
<option value="24">24</option>
</select>
</div>
</div>
) : (
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[7px] text-zinc-500 font-bold uppercase mb-0.5">Chất lượng</label>
<select value={exportSettings.quality} onChange={e => setExportSettings(p => ({ ...p, quality: e.target.value }))}
className="w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none">
<option value="44khz">44kHz</option>
<option value="lossless">Lossless</option>
</select>
</div>
<div></div>
</div>
)}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[7px] text-zinc-500 font-bold uppercase mb-0.5">Kênh</label>
<select value={exportSettings.channels} onChange={e => setExportSettings(p => ({ ...p, channels: e.target.value }))}
className="w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none">
<option value="mono">Mono</option>
<option value="stereo">Stereo</option>
</select>
</div>
<div></div>
</div>
<div className="grid grid-cols-2 gap-2 pt-1">
<button onClick={onBounce} disabled={isExporting}
title="Bounce realtime — file WAV đầy đủ MIDI + FX Rack + Mastering Chain (chạy lại project thật)"
className="w-full py-1 bg-fuchsia-800 hover:bg-fuchsia-700 text-zinc-100 font-bold rounded text-xs flex items-center justify-center gap-1">
<i data-lucide="radio" className="w-3 h-3" /> {isExporting ? '...' : 'Bounce MIDI'}
</button>
<button onClick={onExport} disabled={isExporting}
className="w-full py-1 bg-cyan-700 hover:bg-cyan-600 text-zinc-100 font-bold rounded text-xs flex items-center justify-center gap-1">
<i data-lucide="download-cloud" className="w-3 h-3" /> {isExporting ? '...' : 'Export'}
</button>
</div>
</div>
</div>
</div>
);
};
const FXRackModal = ({ track, onUpdateTrack, onClose }) => {
const [activeType, setActiveType] = React.useState('eq');
const [addModuleOpen, setAddModuleOpen] = React.useState(false);
@@ -13403,7 +13565,17 @@ const App = () => {
const [localSelectionTrackId, setLocalSelectionTrackId] = useState(null);
const [localSelectionStart, setLocalSelectionStart] = useState(null);
const [localSelectionEnd, setLocalSelectionEnd] = useState(null);
const [zoom, setZoom] = useState(100);
const [zoom, setZoom] = useState(() => {
// Persist zoom qua reload (user yêu cu gi kích thưc items sau refresh)
try {
const v = parseFloat(localStorage.getItem('sf_zoom'));
if (isFinite(v) && v > 0) return v;
} catch (e) { }
return 100;
});
React.useEffect(() => {
try { localStorage.setItem('sf_zoom', String(zoom)); } catch (e) { }
}, [zoom]);
const [isLoopingSelection, setIsLoopingSelection] = useState(false);
const [beginBar, setBeginBar] = useState(0);
const [endBar, setEndBar] = useState(0);
@@ -17065,38 +17237,60 @@ const App = () => {
leadInMarginRef.current = 0;
const maxDuration = useMemo(() => {
const secPerBar = (60.0 / (parseInt(bpm) || 120)) * 4;
const cur = activeTracks;
let max = 10;
cur.forEach(t => {
const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{
id: 'default',
buffer: t.buffer,
startTime: t.startTime || 0,
name: t.name,
speed: t.speed || 1.0
}] : [];
clips.forEach(c => {
if (c.buffer) {
const cStart = c.startTime || 0;
const cDur = c.buffer.duration / (c.speed || 1.0);
max = Math.max(max, cStart + cDur);
}
});
(t.midiItems || []).forEach(m => {
max = Math.max(max, (m.startTime || 0) + (m.duration || 4));
});
(t.sections || []).forEach(s => {
max = Math.max(max, (s.start || 0) + (s.duration || 4));
});
});
// MAIN SESSION: endtime ca items trên track MAIN (mc k SECTION-TAB dài bao nhiêu
// sub-track items b clamp trong section bounds khi play trong main session).
// SECTION-TAB: endtime ca items trên track CA TAB (tính theo v trí section trên main).
let max;
if (activeTab === 'main') {
max = computeMainSessionEndTime(activeTracks);
} else {
const tab = sessionTabs.find(st => st.id === activeTab);
const tabMax = tab ? computeMainSessionEndTime(tab.tracks || []) : 0;
let secStart = 0;
const secRef = tab ? tab.sectionId : null;
if (secRef) {
activeTracks.forEach(t => (t.sections || []).forEach(s => {
if (s.sectionId === secRef || s.id === secRef) secStart = s.start || 0;
}));
}
max = secStart + tabMax;
}
if (recordingState === 'RECORDING' || recordingState === 'COUNT_IN') {
max = Math.max(max, currentTime + 60);
}
max += secPerBar * 12 + scrollBufferExtra;
return max;
}, [activeTracks, recordingState, currentTime, bpm, scrollBufferExtra]);
}, [activeTab, activeTracks, sessionTabs, recordingState, currentTime, bpm, scrollBufferExtra]);
const maxDurationRef = useRef(maxDuration);
maxDurationRef.current = maxDuration;
// Project REAL end-time (KHÔNG buffer)
// maxDuration trên cng 12 bars + scrollBuffer cho vùng SCROLL/ZOOM; nếu
// dùng nó làm đim dng LOOP thì loop kéo dài quá duration tht. projectEnd
// = endtime ca session (main: items trên track main; section-tab: secStart +
// items ca tab) dùng cho updatePlayhead dng/loop li đúng cui bài.
const projectEnd = useMemo(() => {
let max;
if (activeTab === 'main') {
max = computeMainSessionEndTime(activeTracks);
} else {
const tab = sessionTabs.find(st => st.id === activeTab);
const tabMax = tab ? computeMainSessionEndTime(tab.tracks || []) : 0;
let secStart = 0;
const secRef = tab ? tab.sectionId : null;
if (secRef) {
activeTracks.forEach(t => (t.sections || []).forEach(s => {
if (s.sectionId === secRef || s.id === secRef) secStart = s.start || 0;
}));
}
max = secStart + tabMax;
}
if (recordingState === 'RECORDING' || recordingState === 'COUNT_IN') {
max = Math.max(max, currentTime + 60);
}
return Math.max(1, max);
}, [activeTab, activeTracks, sessionTabs, recordingState, currentTime]);
const projectEndRef = useRef(projectEnd);
projectEndRef.current = projectEnd;
const minZoom = useMemo(() => {
return viewportWidth / maxDuration;
}, [viewportWidth, maxDuration]);
@@ -17377,7 +17571,64 @@ const App = () => {
}
};
// Realtime re-schedule tracking (loop play cp nht khi items đi v trí)
const scheduledItemsSigRef = React.useRef('');
const playheadFrameCountRef = React.useRef(0);
const pendingRescheduleRef = React.useRef(null);
const lastRescheduleTimeRef = React.useRef(0);
// Re-schedule NGAY (reset cooldown) sau khi TH chut check kế tiếp trong
// updatePlayhead (~100ms) re-schedule luôn.
const triggerRescheduleNow = () => {
try {
lastRescheduleTimeRef.current = 0;
} catch (e) { }
};
const updatePlayhead = () => {
// Realtime re-schedule: khi đang play (loop play) mà items (v trí/speed/
// duration k c ni dung section tab) thay đi dng + schedule li t
// playhead hin ti đ item mi phát đúng v trí mi (không phát ni dung
// cũ v trí cũ). Kim tra ~10fps (mi 6 frame) đ kéo item cp nht gn
// như realtime mà không tn CPU mi frame.
if (isPlaying && activeTabRef.current === 'main' && recordingStateRef.current !== 'RECORDING') {
playheadFrameCountRef.current++;
if (playheadFrameCountRef.current % 6 === 0) {
// Dùng REFS (không phi state closure) rAF loop gi updatePlayhead
// ca render cũ nên activeTracks/sessionTabs trong closure là STALE
// signature không bao gi đi khi kéo item không re-schedule.
const sig = buildItemsSignature(activeTracksRef.current, sessionTabsRef.current);
if (sig !== scheduledItemsSigRef.current) {
// THROTTLE ~300ms: re-schedule đnh k NGAY C TRONG LÚC KÉO (clip
// kéo đi âm thanh cũ dng 300ms, clip mi phát khi playhead ti
// v trí mi realtime) mà không stop/start mi frame (git). Sau
// khi th chut triggerRescheduleNow reset cooldown re-schedule
// check kế tiếp (~100ms).
const nowT = performance.now();
if (nowT - (lastRescheduleTimeRef.current || 0) > 300) {
lastRescheduleTimeRef.current = nowT;
scheduledItemsSigRef.current = sig;
const pt = currentTimeRef.current;
stopAllPlayback();
setIsPlaying(true);
// Re-schedule theo ĐÚNG chế đ play hin ti: loop local / solo ch
// phát track liên quan (không phát nhm track khác); ngưc li play
// toàn session. C 2 hàm đu tính playOffset = pt clip.startTime
// clip va kéo ti đúng playhead phát T ĐU clip (realtime).
const curTracks = activeTracksRef.current || activeTracks;
const soloed = curTracks.some(t => t.solo);
if (soloed) {
curTracks.filter(t => t.solo).forEach(t => startLocalTrackPlayback(t.id, pt));
} else if (selectionMode === 'local' && localSelectionTrackId) {
startLocalTrackPlayback(localSelectionTrackId, pt);
} else {
startTrackPlayback(pt);
}
}
} else {
pendingRescheduleRef.current = null;
}
}
}
if (recordingStateRef.current === 'RECORDING') {
const audioCtx = getAudioContext();
const lookahead = 0.1; // 100ms
@@ -17532,7 +17783,9 @@ const App = () => {
return;
}
}
if (updatedTime >= maxDurationRef.current) {
// Hết bài: dng/loop li ti ENDTIME THT ca session (projectEnd không
// buffer 12 bars như maxDuration dùng cho scroll/zoom).
if (updatedTime >= projectEndRef.current) {
if (recordingStateRef.current === 'RECORDING') {
setCurrentTime(updatedTime);
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
@@ -17949,6 +18202,10 @@ const App = () => {
const startTrackPlayback = offsetTime => {
const context = getAudioContext();
const allPlayTracks = activeTracksRef.current && activeTracksRef.current.length ? activeTracksRef.current : activeTracks;
// Capture items signature at schedule time updatePlayhead so sánh vi
// signature này đ phát hin items đi v trí gia 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'));
const hasSolo = allPlayTracks.some(t => t.solo);
allPlayTracks.forEach(track => {
const isPlayable = hasSolo ? track.solo : !track.muted;
@@ -17956,6 +18213,7 @@ const App = () => {
const gainNode = getOrCreateTrackNode(track, context);
const pannerNode = activeTrackNodesRef.current[track.id].pannerNode;
console.log('[Play] track', track.id, track.name, 'node ok:', !!(gainNode && pannerNode), 'muted:', track.muted);
const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{
id: 'default',
buffer: track.buffer,
@@ -19396,6 +19654,9 @@ const App = () => {
pushAction('MOVE_CLIP', drag.trackId, drag.beforeSnap, afterSnap);
setDraggedClip(null);
showToast('Đã di chuyển clip.', 'success');
// Realtime: clip va th re-schedule NGAY đ phát theo v trí mi
// (kéo clip v đúng playhead phát ngay, không ch debounce).
triggerRescheduleNow();
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
@@ -19789,6 +20050,8 @@ const App = () => {
var afterSnap = captureAllTracksSnapshot();
pushAction('MOVE_ITEM', 'ALL_TRACKS', drag.beforeSnap || beforeSnap, afterSnap);
}
// Realtime: items va th re-schedule NGAY theo v trí mi.
triggerRescheduleNow();
if (drag.itemType === 'midiItem') {
let finalTrackId = null;
const curTrks = activeTracksRef.current || activeTracks;
@@ -21010,15 +21273,9 @@ const App = () => {
const audioCtx = getAudioContext();
try {
const allTracks = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : tracks;
let durationLimit = 1.0;
allTracks.forEach(t => {
const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ startTime: t.startTime || 0, buffer: t.buffer, speed: t.speed || 1.0 }] : [];
clips.forEach(c => { if (c.buffer) durationLimit = Math.max(durationLimit, (c.startTime || 0) + c.buffer.duration / (c.speed || 1.0)); });
(t.midiItems || []).forEach(m => { if (m.endTime) durationLimit = Math.max(durationLimit, m.endTime); else if (m.startTime) durationLimit = Math.max(durationLimit, m.startTime + (m.duration || 8)); });
});
(sessionTabs || []).forEach(s => (s.tracks || []).forEach(t => {
(t.midiItems || []).forEach(m => { if (m.endTime) durationLimit = Math.max(durationLimit, m.endTime); else if (m.startTime) durationLimit = Math.max(durationLimit, m.startTime + (m.duration || 8)); });
}));
// MAIN SESSION end time (items trên track main sections tính theo bounds,
// không kéo dài theo ni dung SECTION-TAB) bounce không ct sm/ct thiếu.
let durationLimit = Math.max(1.0, computeMainSessionEndTime(allTracks));
durationLimit += 1.2; // FX/mastering tail
const captureRate = audioCtx.sampleRate || 44100;
@@ -21244,17 +21501,9 @@ const App = () => {
try {
const targetRate = parseInt(exportSettings.sampleRate);
const bitDepth = parseInt(exportSettings.bitDepth);
const durationLimit = Math.max(...activeTracks.map(t => {
const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{
id: 'default',
buffer: t.buffer,
startTime: t.startTime || 0,
name: t.name,
speed: t.speed || 1.0
}] : [];
if (clips.length === 0) return 0;
return Math.max(...clips.map(c => (c.startTime || 0) + c.buffer.duration / (c.speed || 1.0)));
}));
// MAIN SESSION end time (items trên track main sections theo bounds)
// offline render không ct sm; MIDI cache (preview) gi max vi cache.
let durationLimit = Math.max(0.1, computeMainSessionEndTime(activeTracks));
// Include preview-captured MIDI cache length (soundfont tracks have no clips)
Object.keys(midiCacheRef.current).forEach(id => {
const c = midiCacheRef.current[id];
@@ -24139,23 +24388,14 @@ const App = () => {
const hasSel = (selectionMode === 'local' && selLeft !== null && selRight !== null && selRight > selLeft)
|| (selectionStart !== null && selectionEnd !== null);
if (!hasSel) {
const bpmVal = parseInt(bpm) || 120;
const secPerBar = (60.0 / bpmVal) * 4;
let maxEnd = 0;
activeTracks.forEach(t => {
(t.clips || []).forEach(c => {
const end = (c.startTime || 0) + (c.duration || 0);
if (end > maxEnd) maxEnd = end;
});
(t.items || []).forEach(it => {
const end = (it.start || 0) + (it.duration || 4);
if (end > maxEnd) maxEnd = end;
});
});
// Auto-derive loop region = ĐÚNG endtime ca session (items trên
// track main: clips theo buffer.duration/speed, midiItems, section
// bounds) KHÔNG cng thêm bars buffer (loop không đưc dài hơn
// duration hin có).
const maxEnd = computeMainSessionEndTime(activeTracks);
if (maxEnd > 0) {
const loopEnd = maxEnd + secPerBar * 2;
setSelectionStart(0);
setSelectionEnd(loopEnd);
setSelectionEnd(maxEnd);
}
}
}
@@ -26054,28 +26294,7 @@ const App = () => {
})())), /*#__PURE__*/React.createElement("div", {
className: "w-1 cursor-ew-resize hover:bg-cyan-500/50 active:bg-cyan-400 transition-colors shrink-0 z-10",
onMouseDown: startColResize
}), renderDock('right', 'Right')), renderDock('bottom', 'Bottom'), (showExportPanel && /*#__PURE__*/React.createElement("div", {
className: "fixed inset-0 z-[300] bg-black/70 backdrop-blur-sm flex items-center justify-center p-6",
onClick: () => setShowExportPanel(false)
}, /*#__PURE__*/React.createElement("div", {
className: "w-full max-w-2xl bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl overflow-hidden",
onClick: e => e.stopPropagation()
}, /*#__PURE__*/React.createElement("div", {
className: "h-9 bg-slate-800/80 border-b border-slate-700 flex items-center justify-between px-3 select-none"
}, /*#__PURE__*/React.createElement("span", {
className: "text-xs font-bold text-cyan-300 font-mono flex items-center gap-1.5"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "download-cloud",
className: "w-3.5 h-3.5"
}), " EXPORT"), /*#__PURE__*/React.createElement("button", {
onClick: () => setShowExportPanel(false),
className: "text-zinc-400 hover:text-white"
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "x",
className: "w-4 h-4"
}))), /*#__PURE__*/React.createElement("div", {
className: "p-4 max-h-[72vh] overflow-y-auto"
}, renderPanelContent('export'))))), showMixer && /*#__PURE__*/React.createElement("div", {
}), renderDock('right', 'Right')), renderDock('bottom', 'Bottom'), showMixer && /*#__PURE__*/React.createElement("div", {
className: "flex flex-col border-t border-zinc-700 bg-slate-900 shrink-0 select-none",
style: { height: mixerHeight + 'px' }
}, /*#__PURE__*/React.createElement("div", {
@@ -26643,6 +26862,14 @@ const App = () => {
track: tracks.find(t => t.id === fxRackTarget.trackId) || null,
onUpdateTrack: updateTrackProp,
onClose: () => setFxRackTarget(null)
}), /*#__PURE__*/React.createElement(ExportModal, {
open: showExportPanel,
onClose: () => setShowExportPanel(false),
exportSettings: exportSettings,
setExportSettings: setExportSettings,
isExporting: isExporting,
onExport: triggerWavExport,
onBounce: triggerBounceExport
}), instrumentSelectorTrackId && /*#__PURE__*/React.createElement("div", {
className: "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm",
onClick: closeInstrumentSelector
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -24,7 +24,7 @@
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
<script src="/static/js/app.precompiled.js?v=202608035800" defer></script>
<script src="/static/js/app.precompiled.js?v=202608037000" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style>
:root {
+54 -1
View File
@@ -1,4 +1,57 @@
### [2026-08-03] Task: Remove FX Rack / MIDI Events / Selection panels khỏi bottom dock
### [2026-08-03] Task: Fix re-schedule STALE CLOSURE (dùng refs) + persist zoom qua reload
- **Tóm tắt thay đổi:**
1. **Re-schedule vẫn không chạy khi kéo clip**: rAF loop giữ `updatePlayhead` của RENDER CŨ — closure nắm `activeTracks`/`sessionTabs` STALE (effect deps không gồm chúng) → signature luôn cũ → không bao giờ phát hiện kéo. **Fix**: re-schedule dùng `activeTracksRef.current` + `sessionTabsRef.current` (sync mỗi render); solo check tính lại từ ref (`curTracks.some(t => t.solo)`).
2. **Zoom persist**: `zoom` (App) khởi tạo từ `localStorage.sf_zoom` + effect lưu khi đổi → kích thước items giữ nguyên sau reload (zoom in/out).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608037000)
- **Ghi chú/Test (nếu có):** BUILD OK 989793 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Debounce 250ms cũ chờ sig "ổn định" — trong lúc kéo clip liên tục, sig đổi mỗi frame → KHÔNG BAO GIỜ re-schedule → clip kéo đi vẫn phát âm thanh cũ (không cập nhật realtime). Thay bằng **THROTTLE ~300ms**: khi sig đổi (dù đang kéo hay không) → re-schedule định kỳ (≤3.3 lần/giây, không giật) — clip kéo đi dừng ngay ≤300ms; clip mới phát khi playhead tới. `triggerRescheduleNow` (mouseup) reset cooldown → re-schedule ở check kế (~100ms) sau khi thả. Giữ mode-aware (solo/loop local/toàn session) + signature đầy đủ.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036900)
- **Ghi chú/Test (nếu có):** BUILD OK 989194 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** `triggerRescheduleNow()` — set `pendingRescheduleRef = { sig hiện tại, time: 0 }` → check kế tiếp trong updatePlayhead (~100ms) re-schedule LUÔN (time 0 → điều kiện >250ms thoả) — gọi ở mouseup của drag clip (`draggedClipRef`) + drag section/midi item (`draggedSectionItemRef`). Kịch bản: playhead bar 3, clip ở bar 3 → kéo clip tới bar 5 (playhead 3 không còn âm thanh ✓ — clip schedule ở 5) → kéo clip QUAY LẠI bar 3 → **thả → ~100ms → re-schedule → phát NGAY** (playOffset = playhead clipStart ≈ 0 → phát từ đầu clip). Debounce 250ms chỉ còn dành cho thao tác kéo liên tục (chống giật).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036800)
- **Ghi chú/Test (nếu có):** BUILD OK 989510 bytes, node --check OK, `pytest` 86 passed. triggerRescheduleNow ×3.
---
- **Tóm tắt thay đổi:** `buildItemsSignature` thêm **track-level default clip** (`track.buffer``def:startTime:speed`) — vị trí clip default lưu ở `track.startTime`, KHÔNG nằm trong `t.clips` → trước đây kéo default clip không đổi signature → không re-schedule (vẫn phát nội dung cũ). Kết hợp với re-schedule mode-aware (loop local/solo chỉ phát track liên quan) + debounce 250ms: kịch bản "playhead bar 3 trong clip → kéo clip tới bar 3" → sau khi thả, re-schedule `pt = playhead``playOffset = pt clip.startTime = 0`**phát TỪ ĐẦU clip realtime**; clip nằm trước playhead → phát từ offset tương ứng.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036700)
- **Ghi chú/Test (nếu có):** BUILD OK 988860 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Re-schedule (khi items đổi vị trí lúc play) trước đây luôn gọi `startTrackPlayback(pt)` → LOOP LOCAL bị phá (phát nhầm các track khác + mất hành vi loop). Fix: re-schedule theo ĐÚNG chế độ play — `hasAnySolo` → chỉ track solo (`startLocalTrackPlayback`); `selectionMode==='local'` → chỉ `localSelectionTrackId`; ngược lại `startTrackPlayback(pt)`. Cả 2 hàm tính `playOffset = pt clip.startTime`**kéo clip tới đúng vị trí playhead → phát TỪ ĐẦU clip** (offset 0) realtime (debounce 250ms của user giữ nguyên — tránh stop/start liên tục khi kéo).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036600)
- **Ghi chú/Test (nếu có):** BUILD OK 988538 bytes, node --check OK, `pytest` 86 passed.
---
- **Tóm tắt thay đổi:** Khi đang play/loop mà user kéo/thay đổi vị trí item → vẫn phát nội dung cũ (source đã schedule với startTime cũ). Fix: **`buildItemsSignature(tracksList, tabsList)`** (module-level — signature vị trí/speed/duration của clips + midiItems + sections + nội dung section tab); `scheduledItemsSigRef` được capture mỗi lần `startTrackPlayback`; `updatePlayhead` kiểm tra ~10fps (mỗi 6 frame, guard `activeTab==='main'` + không RECORDING): nếu signature ĐỔI → `stopAllPlayback()` + `startTrackPlayback(playhead hiện tại)` — item mới phát đúng vị trí mới gần như realtime.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036500)
- **Ghi chú/Test (nếu có):** BUILD OK 987414 bytes, node --check OK, `pytest` 86 passed. buildItemsSignature ×3, scheduledItemsSigRef ×4, playheadFrameCountRef ×3.
---
- **Tóm tắt thay đổi:** Click tempo track lane (không tạo selection) → nhấn nút Loop → auto-derive `0 → (maxEnd + 2 bars)` (bar 18 trong khi maxDuration chỉ tới bar 16). Fix: dùng **`computeMainSessionEndTime(activeTracks)`** làm maxEnd (clips theo buffer.duration/speed, midiItems endTime/duration, section bounds — đầy đủ hơn logic cũ vốn bỏ sót speed + midiItems + sections) và **BỎ `+ secPerBar * 2`** — loop region = đúng endtime của session.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036400)
- **Ghi chú/Test (nếu có):** BUILD OK 985447 bytes, node --check OK, `pytest` 86 passed. Không còn `maxEnd + secPerBar * 2` trong bundle.
---
- **Tóm tắt thay đổi:** `maxDuration` = endtime + **12 bars buffer + scrollBufferExtra** (dành cho scroll/zoom) — nhưng updatePlayhead dùng nó làm điểm dừng LOOP → loop kéo dài quá duration thật. Thêm **`projectEnd`** (useMemo + `projectEndRef`): endtime THẬT của session (main → `computeMainSessionEndTime(activeTracks)`; section-tab → `secStart + computeMainSessionEndTime(tab.tracks)`; RECORDING → +60s; tối thiểu 1s) — KHÔNG buffer. `updatePlayhead` (nhánh hết bài) đổi `maxDurationRef.current`**`projectEndRef.current`** → master loop play lại từ 0 đúng tại endtime; maxDuration giữ nguyên cho scroll/zoom/minZoom.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036300)
- **Ghi chú/Test (nếu có):** BUILD OK 985502 bytes, node --check OK, `pytest` 86 passed. projectEndRef ×3, loop stop dùng projectEnd ✓, secPerBar*12 còn (scroll).
---
- **Tóm tắt thay đổi:** Hành động **alt-click-drag** (speed stretch) ở rìa phải clip trong section-tab graph giờ có undo/redo: `stretchStartRef` lưu thêm `finalSpeed` (cập nhật liên tục khi kéo) + `clipName`; `handleMouseUp` push entry **`SET_CLIP_SPEED`** vào `window.UndoRedoEngine` (chỉ khi speed thực sự đổi > 0.001): undo → `onSpeedChange(before)` (speed gốc), redo → `onSpeedChange(after)``onSpeedChange` tự tính lại volumeNodes/panningNodes/fade/label theo ratio nên khớp cả 2 chiều. Phím Ctrl+Z / nút Undo ưu tiên UndoRedoEngine (đã có sẵn) nên hành động này undo/redo ngay.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036200)
- **Ghi chú/Test (nếu có):** BUILD OK 984340 bytes, node --check OK, `pytest` 86 passed. SET_CLIP_SPEED ×2, finalSpeed ×8 trong bundle.
---
- **Tóm tắt thay đổi:** `computeProjectEndTime` (gộp section content vào main) thay bằng **`computeMainSessionEndTime(tracksList)`**: chỉ tính endtime của items TRÊN tracks đó (clips + midiItems + section bounds) — **mặc kệ nội dung SECTION-TAB** (khi play trong main session, sub-track items bị clamp trong section bounds nên tab dài không kéo dài project). `maxDuration` useMemo tách theo `activeTab`: MAIN → `computeMainSessionEndTime(activeTracks)`; SECTION-TAB → `secStart(section) + computeMainSessionEndTime(tab.tracks)` (tab.sectionId → tìm section start trên main). Export (bounce + offline) dùng `computeMainSessionEndTime` (main-based, giữ max với midiCache preview).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036100)
- **Ghi chú/Test (nếu có):** BUILD OK 983477 bytes, node --check OK, `pytest` 86 passed. computeMainSessionEndTime ×5, computeProjectEndTime = 0.
---
- **Tóm tắt thay đổi:** `maxDuration` (loop stop) + `triggerBounceExport` + `clientSideExport` tính end time THIẾU nội dung SECTION-TAB (sub-track clips/MIDI bên trong section + session tabs độc lập) → loop dừng sớm / export cắt cuối bài. Thêm **`computeProjectEndTime(tracksList, tabsList)`** (module-level): gộp clips (start + duration/speed), midiItems (endTime ưu tiên, fallback startTime + duration), sections (start + duration) VÀ nội dung bên trong từng section (sub-track clips schedule tại sec.start + local, sub MIDI tại sec.start + item.startTime) + session tabs độc lập. Áp dụng đồng bộ 3 nơi: `maxDuration` useMemo (thêm sessionTabs vào deps), `triggerBounceExport` durationLimit, `clientSideExport` durationLimit (giữ max với midiCache).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608036000)
- **Ghi chú/Test (nếu có):** BUILD OK 983950 bytes, node --check OK, `pytest` 86 passed. computeProjectEndTime ×4 trong bundle.
---
- **Tóm tắt thay đổi:**
1. **Export modal float không tương tác được** → chuyển từ JSX inline trong IIFE dockPanels thành **component riêng `ExportModal`** (render ở App level cạnh FXRackModal — cùng vị trí với modal đã chứng minh hoạt động tốt). Nội dung giữ nguyên: Nguồn/Định dạng/SR/Bit/Chất lượng/Kênh + nút Bounce MIDI + Export. Loại bỏ mọi nghi vấn stacking-context/pointer-events từ IIFE.
2. **Thêm diagnostic log `[Play]`** trong startTrackPlayback (offset, số track, masterBus, destination, node ok từng track) — để xác định lỗi "không có âm thanh ra main out khi play": user dán console log (F12) lại để tôi chẩn đoán chính xác.
3. Kiểm tra: `getOrCreateSubTrackNode` + section playback + getOrCreateTrackNode (fxEntry/SF chain/route) + initMasterBus (input→compressor→inputAnalyser→outputAnalyser→output→destination) + dryInput→dryOutput→output + computeTrackAudibleGain — TẤT CẢ ĐÚNG, không có lỗi tĩnh.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608035900)
- **Ghi chú/Test (nếu có):** BUILD OK 982816 bytes, node --check OK, `pytest` 86 passed. ExportModal ×2 (định nghĩa + render), [Play] log có trong bundle.
---
- **Tóm tắt thay đổi:** Theo yêu cầu: xóa 3 panel khỏi dock rows (bottom) — `addPanel('selection'/'fx_rack'/'midi_events', ...)` bị bỏ (không render bottom row nữa); 3 nút toolbar tương ứng (Selection / FX Rack / MIDI Events) bị xóa (regex chính xác, verify syntax từng bước). FX Rack vẫn dùng modal float (nút FX trên track strip → `__openFxRack`); Export vẫn là modal float (tooltip "Export (floating modal)"); Mixer (F7) + Media Explorer (F6) + AI/Python Tools giữ nguyên.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608035800)
- **Ghi chú/Test (nếu có):** BUILD OK 977199 bytes, node --check OK, `pytest` 86 passed. Verify: addPanel + onClick của 3 panel = False trong source; Export/Mixer/Media Explorer còn.