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);
|
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
|
// Reusable time-domain buffers for the imager vectorscope/correlation meter
|
||||||
// (leftAnalyser/rightAnalyser are fixed at fftSize 2048) — allocated once so
|
// (leftAnalyser/rightAnalyser are fixed at fftSize 2048) — allocated once so
|
||||||
// the 60fps render loop does not churn the GC.
|
// the 60fps render loop does not churn the GC.
|
||||||
@@ -3799,7 +3844,9 @@ const SubTabWaveform = ({
|
|||||||
stretchStartRef.current = {
|
stretchStartRef.current = {
|
||||||
mouseX,
|
mouseX,
|
||||||
originalDuration: bufDuration,
|
originalDuration: bufDuration,
|
||||||
originalSpeed: speed
|
originalSpeed: speed,
|
||||||
|
finalSpeed: speed,
|
||||||
|
clipName: name || 'clip'
|
||||||
};
|
};
|
||||||
canvas.style.cursor = 'ew-resize';
|
canvas.style.cursor = 'ew-resize';
|
||||||
const handleMouseMove = moveEvent => {
|
const handleMouseMove = moveEvent => {
|
||||||
@@ -3808,13 +3855,33 @@ const SubTabWaveform = ({
|
|||||||
const deltaX = currentX - stretchStartRef.current.mouseX;
|
const deltaX = currentX - stretchStartRef.current.mouseX;
|
||||||
const newWClip = Math.max(10, wClipPx + deltaX);
|
const newWClip = Math.max(10, wClipPx + deltaX);
|
||||||
const newSpeed = stretchStartRef.current.originalDuration / (newWClip / zoom);
|
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 = () => {
|
const handleMouseUp = () => {
|
||||||
isStretchingRef.current = false;
|
isStretchingRef.current = false;
|
||||||
canvas.style.cursor = 'default';
|
canvas.style.cursor = 'default';
|
||||||
document.removeEventListener('mousemove', handleMouseMove);
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
document.removeEventListener('mouseup', handleMouseUp);
|
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('mousemove', handleMouseMove);
|
||||||
document.addEventListener('mouseup', handleMouseUp);
|
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 FXRackModal = ({ track, onUpdateTrack, onClose }) => {
|
||||||
const [activeType, setActiveType] = React.useState('eq');
|
const [activeType, setActiveType] = React.useState('eq');
|
||||||
const [addModuleOpen, setAddModuleOpen] = React.useState(false);
|
const [addModuleOpen, setAddModuleOpen] = React.useState(false);
|
||||||
@@ -13403,7 +13565,17 @@ const App = () => {
|
|||||||
const [localSelectionTrackId, setLocalSelectionTrackId] = useState(null);
|
const [localSelectionTrackId, setLocalSelectionTrackId] = useState(null);
|
||||||
const [localSelectionStart, setLocalSelectionStart] = useState(null);
|
const [localSelectionStart, setLocalSelectionStart] = useState(null);
|
||||||
const [localSelectionEnd, setLocalSelectionEnd] = 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 [isLoopingSelection, setIsLoopingSelection] = useState(false);
|
||||||
const [beginBar, setBeginBar] = useState(0);
|
const [beginBar, setBeginBar] = useState(0);
|
||||||
const [endBar, setEndBar] = useState(0);
|
const [endBar, setEndBar] = useState(0);
|
||||||
@@ -17065,38 +17237,60 @@ const App = () => {
|
|||||||
leadInMarginRef.current = 0;
|
leadInMarginRef.current = 0;
|
||||||
const maxDuration = useMemo(() => {
|
const maxDuration = useMemo(() => {
|
||||||
const secPerBar = (60.0 / (parseInt(bpm) || 120)) * 4;
|
const secPerBar = (60.0 / (parseInt(bpm) || 120)) * 4;
|
||||||
const cur = activeTracks;
|
// MAIN SESSION: endtime của items trên track MAIN (mặc kệ SECTION-TAB dài bao nhiêu —
|
||||||
let max = 10;
|
// sub-track items bị clamp trong section bounds khi play trong main session).
|
||||||
cur.forEach(t => {
|
// SECTION-TAB: endtime của items trên track CỦA TAB (tính theo vị trí section trên main).
|
||||||
const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{
|
let max;
|
||||||
id: 'default',
|
if (activeTab === 'main') {
|
||||||
buffer: t.buffer,
|
max = computeMainSessionEndTime(activeTracks);
|
||||||
startTime: t.startTime || 0,
|
} else {
|
||||||
name: t.name,
|
const tab = sessionTabs.find(st => st.id === activeTab);
|
||||||
speed: t.speed || 1.0
|
const tabMax = tab ? computeMainSessionEndTime(tab.tracks || []) : 0;
|
||||||
}] : [];
|
let secStart = 0;
|
||||||
clips.forEach(c => {
|
const secRef = tab ? tab.sectionId : null;
|
||||||
if (c.buffer) {
|
if (secRef) {
|
||||||
const cStart = c.startTime || 0;
|
activeTracks.forEach(t => (t.sections || []).forEach(s => {
|
||||||
const cDur = c.buffer.duration / (c.speed || 1.0);
|
if (s.sectionId === secRef || s.id === secRef) secStart = s.start || 0;
|
||||||
max = Math.max(max, cStart + cDur);
|
}));
|
||||||
}
|
}
|
||||||
});
|
max = secStart + tabMax;
|
||||||
(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));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if (recordingState === 'RECORDING' || recordingState === 'COUNT_IN') {
|
if (recordingState === 'RECORDING' || recordingState === 'COUNT_IN') {
|
||||||
max = Math.max(max, currentTime + 60);
|
max = Math.max(max, currentTime + 60);
|
||||||
}
|
}
|
||||||
max += secPerBar * 12 + scrollBufferExtra;
|
max += secPerBar * 12 + scrollBufferExtra;
|
||||||
return max;
|
return max;
|
||||||
}, [activeTracks, recordingState, currentTime, bpm, scrollBufferExtra]);
|
}, [activeTab, activeTracks, sessionTabs, recordingState, currentTime, bpm, scrollBufferExtra]);
|
||||||
const maxDurationRef = useRef(maxDuration);
|
const maxDurationRef = useRef(maxDuration);
|
||||||
maxDurationRef.current = 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(() => {
|
const minZoom = useMemo(() => {
|
||||||
return viewportWidth / maxDuration;
|
return viewportWidth / maxDuration;
|
||||||
}, [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 = () => {
|
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') {
|
if (recordingStateRef.current === 'RECORDING') {
|
||||||
const audioCtx = getAudioContext();
|
const audioCtx = getAudioContext();
|
||||||
const lookahead = 0.1; // 100ms
|
const lookahead = 0.1; // 100ms
|
||||||
@@ -17532,7 +17783,9 @@ const App = () => {
|
|||||||
return;
|
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') {
|
if (recordingStateRef.current === 'RECORDING') {
|
||||||
setCurrentTime(updatedTime);
|
setCurrentTime(updatedTime);
|
||||||
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
|
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
|
||||||
@@ -17949,6 +18202,10 @@ const App = () => {
|
|||||||
const startTrackPlayback = offsetTime => {
|
const startTrackPlayback = offsetTime => {
|
||||||
const context = getAudioContext();
|
const context = getAudioContext();
|
||||||
const allPlayTracks = activeTracksRef.current && activeTracksRef.current.length ? activeTracksRef.current : activeTracks;
|
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);
|
const hasSolo = allPlayTracks.some(t => t.solo);
|
||||||
allPlayTracks.forEach(track => {
|
allPlayTracks.forEach(track => {
|
||||||
const isPlayable = hasSolo ? track.solo : !track.muted;
|
const isPlayable = hasSolo ? track.solo : !track.muted;
|
||||||
@@ -17956,6 +18213,7 @@ const App = () => {
|
|||||||
|
|
||||||
const gainNode = getOrCreateTrackNode(track, context);
|
const gainNode = getOrCreateTrackNode(track, context);
|
||||||
const pannerNode = activeTrackNodesRef.current[track.id].pannerNode;
|
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 ? [{
|
const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{
|
||||||
id: 'default',
|
id: 'default',
|
||||||
buffer: track.buffer,
|
buffer: track.buffer,
|
||||||
@@ -19396,6 +19654,9 @@ const App = () => {
|
|||||||
pushAction('MOVE_CLIP', drag.trackId, drag.beforeSnap, afterSnap);
|
pushAction('MOVE_CLIP', drag.trackId, drag.beforeSnap, afterSnap);
|
||||||
setDraggedClip(null);
|
setDraggedClip(null);
|
||||||
showToast('Đã di chuyển clip.', 'success');
|
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('mousemove', handleMouseMove);
|
||||||
document.addEventListener('mouseup', handleMouseUp);
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
@@ -19789,6 +20050,8 @@ const App = () => {
|
|||||||
var afterSnap = captureAllTracksSnapshot();
|
var afterSnap = captureAllTracksSnapshot();
|
||||||
pushAction('MOVE_ITEM', 'ALL_TRACKS', drag.beforeSnap || beforeSnap, afterSnap);
|
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') {
|
if (drag.itemType === 'midiItem') {
|
||||||
let finalTrackId = null;
|
let finalTrackId = null;
|
||||||
const curTrks = activeTracksRef.current || activeTracks;
|
const curTrks = activeTracksRef.current || activeTracks;
|
||||||
@@ -21010,15 +21273,9 @@ const App = () => {
|
|||||||
const audioCtx = getAudioContext();
|
const audioCtx = getAudioContext();
|
||||||
try {
|
try {
|
||||||
const allTracks = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : tracks;
|
const allTracks = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : tracks;
|
||||||
let durationLimit = 1.0;
|
// MAIN SESSION end time (items trên track main — sections tính theo bounds,
|
||||||
allTracks.forEach(t => {
|
// không kéo dài theo nội dung SECTION-TAB) — bounce không cắt sớm/cắt thiếu.
|
||||||
const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ startTime: t.startTime || 0, buffer: t.buffer, speed: t.speed || 1.0 }] : [];
|
let durationLimit = Math.max(1.0, computeMainSessionEndTime(allTracks));
|
||||||
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)); });
|
|
||||||
}));
|
|
||||||
durationLimit += 1.2; // FX/mastering tail
|
durationLimit += 1.2; // FX/mastering tail
|
||||||
|
|
||||||
const captureRate = audioCtx.sampleRate || 44100;
|
const captureRate = audioCtx.sampleRate || 44100;
|
||||||
@@ -21244,17 +21501,9 @@ const App = () => {
|
|||||||
try {
|
try {
|
||||||
const targetRate = parseInt(exportSettings.sampleRate);
|
const targetRate = parseInt(exportSettings.sampleRate);
|
||||||
const bitDepth = parseInt(exportSettings.bitDepth);
|
const bitDepth = parseInt(exportSettings.bitDepth);
|
||||||
const durationLimit = Math.max(...activeTracks.map(t => {
|
// MAIN SESSION end time (items trên track main — sections theo bounds) —
|
||||||
const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{
|
// offline render không cắt sớm; MIDI cache (preview) giữ max với cache.
|
||||||
id: 'default',
|
let durationLimit = Math.max(0.1, computeMainSessionEndTime(activeTracks));
|
||||||
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)));
|
|
||||||
}));
|
|
||||||
// Include preview-captured MIDI cache length (soundfont tracks have no clips)
|
// Include preview-captured MIDI cache length (soundfont tracks have no clips)
|
||||||
Object.keys(midiCacheRef.current).forEach(id => {
|
Object.keys(midiCacheRef.current).forEach(id => {
|
||||||
const c = midiCacheRef.current[id];
|
const c = midiCacheRef.current[id];
|
||||||
@@ -24139,23 +24388,14 @@ const App = () => {
|
|||||||
const hasSel = (selectionMode === 'local' && selLeft !== null && selRight !== null && selRight > selLeft)
|
const hasSel = (selectionMode === 'local' && selLeft !== null && selRight !== null && selRight > selLeft)
|
||||||
|| (selectionStart !== null && selectionEnd !== null);
|
|| (selectionStart !== null && selectionEnd !== null);
|
||||||
if (!hasSel) {
|
if (!hasSel) {
|
||||||
const bpmVal = parseInt(bpm) || 120;
|
// Auto-derive loop region = ĐÚNG endtime của session (items trên
|
||||||
const secPerBar = (60.0 / bpmVal) * 4;
|
// track main: clips theo buffer.duration/speed, midiItems, section
|
||||||
let maxEnd = 0;
|
// bounds) — KHÔNG cộng thêm bars buffer (loop không được dài hơn
|
||||||
activeTracks.forEach(t => {
|
// duration hiện có).
|
||||||
(t.clips || []).forEach(c => {
|
const maxEnd = computeMainSessionEndTime(activeTracks);
|
||||||
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;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if (maxEnd > 0) {
|
if (maxEnd > 0) {
|
||||||
const loopEnd = maxEnd + secPerBar * 2;
|
|
||||||
setSelectionStart(0);
|
setSelectionStart(0);
|
||||||
setSelectionEnd(loopEnd);
|
setSelectionEnd(maxEnd);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26054,28 +26294,7 @@ const App = () => {
|
|||||||
})())), /*#__PURE__*/React.createElement("div", {
|
})())), /*#__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",
|
className: "w-1 cursor-ew-resize hover:bg-cyan-500/50 active:bg-cyan-400 transition-colors shrink-0 z-10",
|
||||||
onMouseDown: startColResize
|
onMouseDown: startColResize
|
||||||
}), renderDock('right', 'Right')), renderDock('bottom', 'Bottom'), (showExportPanel && /*#__PURE__*/React.createElement("div", {
|
}), renderDock('right', 'Right')), renderDock('bottom', 'Bottom'), showMixer && /*#__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", {
|
|
||||||
className: "flex flex-col border-t border-zinc-700 bg-slate-900 shrink-0 select-none",
|
className: "flex flex-col border-t border-zinc-700 bg-slate-900 shrink-0 select-none",
|
||||||
style: { height: mixerHeight + 'px' }
|
style: { height: mixerHeight + 'px' }
|
||||||
}, /*#__PURE__*/React.createElement("div", {
|
}, /*#__PURE__*/React.createElement("div", {
|
||||||
@@ -26643,6 +26862,14 @@ const App = () => {
|
|||||||
track: tracks.find(t => t.id === fxRackTarget.trackId) || null,
|
track: tracks.find(t => t.id === fxRackTarget.trackId) || null,
|
||||||
onUpdateTrack: updateTrackProp,
|
onUpdateTrack: updateTrackProp,
|
||||||
onClose: () => setFxRackTarget(null)
|
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", {
|
}), instrumentSelectorTrackId && /*#__PURE__*/React.createElement("div", {
|
||||||
className: "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm",
|
className: "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm",
|
||||||
onClick: closeInstrumentSelector
|
onClick: closeInstrumentSelector
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -24,7 +24,7 @@
|
|||||||
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
|
<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/promptTemplateManager.js?v=202607281039"></script>
|
||||||
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></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">
|
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -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.
|
- **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)
|
- **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.
|
- **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.
|
||||||
|
|||||||
Reference in New Issue
Block a user