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:
+313
-86
@@ -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 nằm trong t.clips, phải đưa vào signature nếu không kéo default
|
||||
// clip sẽ không kích hoạt re-schedule (vẫn phát nội 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 gốc, redo về speed cuối (onSpeedChange tự tính lại
|
||||
// volumeNodes/panningNodes/fade/label theo ratio nên khớp 2 chiều).
|
||||
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 cầu 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 của items trên track MAIN (mặc kệ SECTION-TAB dài bao nhiêu —
|
||||
// sub-track items bị clamp trong section bounds khi play trong main session).
|
||||
// SECTION-TAB: endtime của items trên track CỦA 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 cộng 12 bars + scrollBuffer cho vùng SCROLL/ZOOM; nếu
|
||||
// dùng nó làm điểm dừng LOOP thì loop kéo dài quá duration thật. projectEnd
|
||||
// = endtime của session (main: items trên track main; section-tab: secStart +
|
||||
// items của tab) — dùng cho updatePlayhead dừng/loop lại đúng cuối 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 cập nhật 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Ả chuột — 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ả nội dung section tab) thay đổi → dừng + schedule lại từ
|
||||
// playhead hiện tại để item mới phát đúng vị trí mới (không phát nội dung
|
||||
// cũ ở vị trí cũ). Kiểm tra ~10fps (mỗi 6 frame) để kéo item cập nhật gần
|
||||
// như realtime mà không tốn CPU mỗi frame.
|
||||
if (isPlaying && activeTabRef.current === 'main' && recordingStateRef.current !== 'RECORDING') {
|
||||
playheadFrameCountRef.current++;
|
||||
if (playheadFrameCountRef.current % 6 === 0) {
|
||||
// ⚠️ Dùng REFS (không phải state closure) — rAF loop giữ updatePlayhead
|
||||
// của 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ũ dừng ≤300ms, clip mới phát khi playhead tới
|
||||
// vị trí mới — realtime) mà không stop/start mỗi frame (giật). Sau
|
||||
// khi thả chuột 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 hiện tại: loop local / solo chỉ
|
||||
// phát track liên quan (không phát nhầm track khác); ngược lại play
|
||||
// toàn session. Cả 2 hàm đều tính playOffset = pt − clip.startTime
|
||||
// → clip vừa kéo tới đú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: dừng/loop lại tại ENDTIME THẬT của 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 với
|
||||
// signature này để phát hiện items đổi vị trí giữa 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 vừa thả — re-schedule NGAY để phát theo vị trí mới
|
||||
// (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 vừa thả — re-schedule NGAY theo vị trí mới.
|
||||
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 nội dung SECTION-TAB) — bounce không cắt sớm/cắt 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 cắt sớm; MIDI cache (preview) giữ max với 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 của session (items trên
|
||||
// track main: clips theo buffer.duration/speed, midiItems, section
|
||||
// bounds) — KHÔNG cộng thêm bars buffer (loop không được dài hơn
|
||||
// duration hiện 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
Reference in New Issue
Block a user