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