fix: sửa lỗi audio clip dán từ clipboard không đúng duration
This commit is contained in:
+258
-67
@@ -174,6 +174,7 @@
|
||||
localSelRight,
|
||||
onTrackLaneMouseDown,
|
||||
onContextMenu,
|
||||
onClipDragStart,
|
||||
}) => {
|
||||
const canvasRef = useRef(null);
|
||||
|
||||
@@ -212,12 +213,10 @@
|
||||
const sampleRate = track.buffer.sampleRate;
|
||||
const totalSamples = data.length;
|
||||
const duration = totalSamples / sampleRate;
|
||||
const visibleStart = 0;
|
||||
const visibleEnd = Math.max(1, width / zoom);
|
||||
const startSample = Math.floor(visibleStart * sampleRate);
|
||||
const endSample = Math.min(totalSamples, Math.ceil(visibleEnd * sampleRate));
|
||||
const samplesToDraw = endSample - startSample;
|
||||
const pixelsPerSample = samplesToDraw > 0 ? width / samplesToDraw : 1;
|
||||
|
||||
const xStart = (track.startTime || 0) * zoom;
|
||||
const wClip = duration * zoom;
|
||||
const xEnd = xStart + wClip;
|
||||
|
||||
// Draw markers
|
||||
if (markers && markers.length > 0) {
|
||||
@@ -230,13 +229,18 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Peak waveform drawing
|
||||
// Peak waveform drawing only within clip bounds
|
||||
ctx.strokeStyle = isSelected ? '#06b6d4' : '#6ee7b7';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
for (let px = 0; px < width; px++) {
|
||||
const sampleIdx = startSample + Math.floor(px / pixelsPerSample);
|
||||
const chunkSize = Math.max(1, Math.floor(1 / pixelsPerSample));
|
||||
const drawXStart = Math.max(0, Math.floor(xStart));
|
||||
const drawXEnd = Math.min(width, Math.ceil(xEnd));
|
||||
const samplesPerPixel = sampleRate / zoom;
|
||||
|
||||
for (let px = drawXStart; px < drawXEnd; px++) {
|
||||
const timeInClip = (px - xStart) / zoom;
|
||||
const sampleIdx = Math.floor(timeInClip * sampleRate);
|
||||
const chunkSize = Math.max(1, Math.floor(samplesPerPixel));
|
||||
const chunkStart = Math.max(0, sampleIdx - Math.floor(chunkSize / 2));
|
||||
const chunkEnd = Math.min(totalSamples, chunkStart + chunkSize);
|
||||
|
||||
@@ -288,6 +292,18 @@
|
||||
const x = e.clientX - rect.left + scrollLeft;
|
||||
const time = Math.max(0, x / zoom);
|
||||
onSelectTrack(track.id);
|
||||
|
||||
// Check for Alt+Click drag clip
|
||||
const isOverClip = track.buffer && time >= (track.startTime || 0) && time < (track.startTime || 0) + track.buffer.duration;
|
||||
if (isOverClip && e.altKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onClipDragStart) {
|
||||
onClipDragStart(track.id, time - (track.startTime || 0));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
onPlayheadSet(time);
|
||||
if (onTrackLaneMouseDown) {
|
||||
onTrackLaneMouseDown(track.id, time, e);
|
||||
@@ -429,9 +445,11 @@
|
||||
const App = () => {
|
||||
// ── State Definitions ──
|
||||
const [tracks, setTracks] = useState([
|
||||
{ id: '1', name: 'Track 01', buffer: null, volume: 0.8, muted: false, solo: false, color: '#0f766e', markers: [], serverFileId: null },
|
||||
{ id: '2', name: 'Track 02', buffer: null, volume: 0.8, muted: false, solo: false, color: '#1d4ed8', markers: [], serverFileId: null },
|
||||
{ id: '1', name: 'Track 01', buffer: null, startTime: 0, volume: 0.8, muted: false, solo: false, color: '#0f766e', markers: [], serverFileId: null },
|
||||
{ id: '2', name: 'Track 02', buffer: null, startTime: 0, volume: 0.8, muted: false, solo: false, color: '#1d4ed8', markers: [], serverFileId: null },
|
||||
]);
|
||||
const [draggedClip, setDraggedClip] = useState(null); // { trackId, clickOffset, buffer, name, volume, color }
|
||||
const [hoveredTrackId, setHoveredTrackId] = useState(null);
|
||||
// BMP for Tempo Track - LOOP_EDITOR_2.md §6
|
||||
const [bpm, setBpm] = useState(localStorage.getItem('studio_bpm') || '120');
|
||||
const [selectedTrackId, setSelectedTrackId] = useState('1');
|
||||
@@ -527,6 +545,7 @@
|
||||
markers: JSON.parse(JSON.stringify(track.markers || [])),
|
||||
// buffer is captured via reference copy for undo; we store a clone for redo
|
||||
buffer: track.buffer,
|
||||
startTime: track.startTime || 0,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -576,7 +595,7 @@
|
||||
if (ctrl && e.key === 'z' && !e.shiftKey) { e.preventDefault(); handleUndoRef.current(); return; }
|
||||
if (ctrl && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); handleRedoRef.current(); return; }
|
||||
if (ctrl && !alt && e.key === 'o') { e.preventDefault(); showToast('Open Project dialog','info'); return; }
|
||||
if (ctrl && !alt && e.key === 'n') { e.preventDefault(); setTracks([{ id:'1', name:'Track 01', buffer:null, volume:0.8, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, volume:0.8, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','info'); return; }
|
||||
if (ctrl && !alt && e.key === 'n') { e.preventDefault(); setTracks([{ id:'1', name:'Track 01', buffer:null, startTime:0, volume:0.8, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, startTime:0, volume:0.8, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','info'); return; }
|
||||
if (ctrl && !alt && e.key === 's') { e.preventDefault(); showToast('Project saved','success'); return; }
|
||||
if ((ctrl && alt && e.key === 's') || (ctrl && e.shiftKey && e.key === 's')) { e.preventDefault(); showToast('Save As dialog','info'); return; }
|
||||
if (ctrl && !alt && e.key === 'i') { e.preventDefault(); addNewTrack(); return; }
|
||||
@@ -648,8 +667,11 @@
|
||||
return;
|
||||
}
|
||||
const sr = t.buffer.sampleRate;
|
||||
const startSample = Math.max(0, Math.floor(selLeft * sr));
|
||||
const endSample = Math.min(t.buffer.length, Math.floor(selRight * sr));
|
||||
const trackStart = t.startTime || 0;
|
||||
const relSelLeft = Math.max(0, selLeft - trackStart);
|
||||
const relSelRight = Math.max(0, selRight - trackStart);
|
||||
const startSample = Math.max(0, Math.floor(relSelLeft * sr));
|
||||
const endSample = Math.min(t.buffer.length, Math.floor(relSelRight * sr));
|
||||
const len = endSample - startSample;
|
||||
if (len < 100) {
|
||||
showToast('Khoảng chọn quá ngắn.', 'warning');
|
||||
@@ -723,8 +745,9 @@
|
||||
// Crossfade merge into original track (§2.2)
|
||||
const sr = track.buffer.sampleRate;
|
||||
const origData = track.buffer.getChannelData(0);
|
||||
const startSample = Math.floor(subTab.startTime * sr);
|
||||
const endSample = Math.floor(subTab.endTime * sr);
|
||||
const trackStart = track.startTime || 0;
|
||||
const startSample = Math.floor((subTab.startTime - trackStart) * sr);
|
||||
const endSample = Math.floor((subTab.endTime - trackStart) * sr);
|
||||
const crossfadeLen = Math.min(100, Math.floor(0.01 * sr)); // 10ms
|
||||
|
||||
const mergedBuffer = ctx.createBuffer(1, track.buffer.length, sr);
|
||||
@@ -831,8 +854,11 @@
|
||||
const sr = t.buffer.sampleRate;
|
||||
const data = t.buffer.getChannelData(0);
|
||||
if (selLeft !== null && selRight !== null && selRight > selLeft) {
|
||||
const startSample = Math.floor(selLeft * sr);
|
||||
const endSample = Math.min(data.length, Math.floor(selRight * sr));
|
||||
const trackStart = t.startTime || 0;
|
||||
const relSelLeft = Math.max(0, selLeft - trackStart);
|
||||
const relSelRight = Math.max(0, selRight - trackStart);
|
||||
const startSample = Math.floor(relSelLeft * sr);
|
||||
const endSample = Math.min(data.length, Math.floor(relSelRight * sr));
|
||||
const len = endSample - startSample;
|
||||
if (len > 0) {
|
||||
const ctx = getAudioContext();
|
||||
@@ -857,7 +883,7 @@
|
||||
contextMenuDelete();
|
||||
};
|
||||
|
||||
const doPaste = (targetTrackId, pasteTime) => {
|
||||
const doPaste = (targetTrackId, pasteTime) => {
|
||||
if (!clipboardRef.current || !clipboardRef.current.buffer) {
|
||||
showToast('Clipboard trống.', 'warning');
|
||||
return null;
|
||||
@@ -894,15 +920,32 @@
|
||||
pasteData = out;
|
||||
}
|
||||
|
||||
const insertSample = Math.floor(pasteTime * targetSr);
|
||||
const targetStart = targetTrack.startTime || 0;
|
||||
const relativePasteTime = pasteTime - targetStart;
|
||||
|
||||
let newStartTime = targetStart;
|
||||
let combined;
|
||||
const origLen = targetTrack.buffer.length;
|
||||
const newLen = Math.max(origLen, insertSample + pasteSamples);
|
||||
const combined = ctx.createBuffer(1, newLen, targetSr);
|
||||
const combinedData = combined.getChannelData(0);
|
||||
const origData = targetTrack.buffer.getChannelData(0);
|
||||
for (let i = 0; i < origLen; i++) combinedData[i] = origData[i];
|
||||
for (let i = 0; i < pasteSamples; i++) combinedData[insertSample + i] = pasteData[i];
|
||||
setTracks(p => p.map(t => t.id === targetTrackId ? { ...t, buffer: combined } : t));
|
||||
|
||||
if (relativePasteTime >= 0) {
|
||||
const insertSample = Math.floor(relativePasteTime * targetSr);
|
||||
const newLen = Math.max(origLen, insertSample + pasteSamples);
|
||||
combined = ctx.createBuffer(1, newLen, targetSr);
|
||||
const combinedData = combined.getChannelData(0);
|
||||
for (let i = 0; i < origLen; i++) combinedData[i] = origData[i];
|
||||
for (let i = 0; i < pasteSamples; i++) combinedData[insertSample + i] = pasteData[i];
|
||||
} else {
|
||||
newStartTime = pasteTime;
|
||||
const prependSamples = Math.floor((targetStart - pasteTime) * targetSr);
|
||||
const newLen = Math.max(prependSamples + origLen, pasteSamples);
|
||||
combined = ctx.createBuffer(1, newLen, targetSr);
|
||||
const combinedData = combined.getChannelData(0);
|
||||
for (let i = 0; i < origLen; i++) combinedData[prependSamples + i] = origData[i];
|
||||
for (let i = 0; i < pasteSamples; i++) combinedData[i] = pasteData[i];
|
||||
}
|
||||
|
||||
setTracks(p => p.map(t => t.id === targetTrackId ? { ...t, buffer: combined, startTime: newStartTime } : t));
|
||||
setCurrentTime(pasteTime);
|
||||
showToast('Đã dán vào track.', 'success');
|
||||
return targetTrackId;
|
||||
@@ -911,8 +954,8 @@
|
||||
// Track exists but no buffer — use clipboard sample rate
|
||||
const newClipBuffer = ctx.createBuffer(1, clipBuffer.length, clipBuffer.sampleRate);
|
||||
newClipBuffer.copyToChannel(clipBuffer.getChannelData(0), 0);
|
||||
setTracks(p => p.map(t => t.id === targetTrackId ? { ...t, buffer: newClipBuffer, name: `Pasted_${name || t.name}`, volume: volume || t.volume, color: color || t.color } : t));
|
||||
setCurrentTime(0);
|
||||
setTracks(p => p.map(t => t.id === targetTrackId ? { ...t, buffer: newClipBuffer, startTime: pasteTime, name: `Pasted_${name || t.name}`, volume: volume || t.volume, color: color || t.color } : t));
|
||||
setCurrentTime(pasteTime);
|
||||
showToast('Đã dán vào track.', 'success');
|
||||
return targetTrackId;
|
||||
}
|
||||
@@ -923,12 +966,13 @@
|
||||
newClipBuffer.copyToChannel(clipBuffer.getChannelData(0), 0);
|
||||
setTracks(prev => [...prev, {
|
||||
id: newId, name: `Pasted_${name || 'track'}`, buffer: newClipBuffer,
|
||||
startTime: pasteTime,
|
||||
volume: volume || 0.8, muted: false, solo: false,
|
||||
color: color || colors[prev.length % colors.length],
|
||||
markers: [], serverFileId: null,
|
||||
}]);
|
||||
setSelectedTrackId(newId);
|
||||
setCurrentTime(0);
|
||||
setCurrentTime(pasteTime);
|
||||
showToast('Đã dán track mới từ clipboard.', 'success');
|
||||
return newId;
|
||||
};
|
||||
@@ -948,14 +992,17 @@
|
||||
return;
|
||||
}
|
||||
const ctx = getAudioContext();
|
||||
const maxDur = Math.max(...activeTracks.map(t => t.buffer.duration));
|
||||
const maxDur = Math.max(...activeTracks.map(t => (t.startTime || 0) + t.buffer.duration));
|
||||
const sr = activeTracks[0].buffer.sampleRate;
|
||||
const merged = ctx.createBuffer(1, Math.ceil(maxDur * sr), sr);
|
||||
const mergedData = merged.getChannelData(0);
|
||||
activeTracks.forEach(t => {
|
||||
const data = t.buffer.getChannelData(0);
|
||||
const startSample = Math.floor((t.startTime || 0) * sr);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
mergedData[i] += data[i] * t.volume;
|
||||
if (startSample + i < mergedData.length) {
|
||||
mergedData[startSample + i] += data[i] * t.volume;
|
||||
}
|
||||
}
|
||||
});
|
||||
let maxPeak = 0;
|
||||
@@ -970,7 +1017,7 @@
|
||||
const newId = 'track_merged_' + Date.now();
|
||||
const names = activeTracks.map(t => t.name).join('+').slice(0, 30);
|
||||
setTracks(prev => [...prev, {
|
||||
id: newId, name: `Merged_${names}.wav`, buffer: merged,
|
||||
id: newId, name: `Merged_${names}.wav`, buffer: merged, startTime: 0,
|
||||
volume: 0.8, muted: false, solo: false,
|
||||
color: colors[prev.length % colors.length], markers: [], serverFileId: null,
|
||||
}]);
|
||||
@@ -984,14 +1031,22 @@
|
||||
const at = tracks.filter(t => t.buffer && !t.muted);
|
||||
if (at.length < 2) { showToast('Cần 2+ tracks để merge.','warning'); return; }
|
||||
const actx = getAudioContext();
|
||||
const maxDur = Math.max(...at.map(t => t.buffer.duration));
|
||||
const maxDur = Math.max(...at.map(t => (t.startTime || 0) + t.buffer.duration));
|
||||
const sr = at[0].buffer.sampleRate;
|
||||
const mb = actx.createBuffer(1, Math.ceil(maxDur * sr), sr);
|
||||
const mdata = mb.getChannelData(0);
|
||||
at.forEach(t => { const d = t.buffer.getChannelData(0); for (let i=0;i<d.length;i++) mdata[i] += d[i] * t.volume; });
|
||||
at.forEach(t => {
|
||||
const d = t.buffer.getChannelData(0);
|
||||
const startSample = Math.floor((t.startTime || 0) * sr);
|
||||
for (let i = 0; i < d.length; i++) {
|
||||
if (startSample + i < mdata.length) {
|
||||
mdata[startSample + i] += d[i] * t.volume;
|
||||
}
|
||||
}
|
||||
});
|
||||
let mp = 0; for (let i=0;i<mdata.length;i++) { const a=Math.abs(mdata[i]); if (a>mp) mp=a; }
|
||||
if (mp > 1.0) for (let i=0;i<mdata.length;i++) mdata[i] /= mp;
|
||||
setTracks(p => [...p, { id:'merged_'+Date.now(), name:'Merged_mix.wav', buffer:mb, volume:0.8, muted:false, solo:false, color:['#0f766e','#1d4ed8'][p.length%2], markers:[], serverFileId:null }]);
|
||||
setTracks(p => [...p, { id:'merged_'+Date.now(), name:'Merged_mix.wav', buffer:mb, startTime: 0, volume:0.8, muted:false, solo:false, color:['#0f766e','#1d4ed8'][p.length%2], markers:[], serverFileId:null }]);
|
||||
showToast('Merged all unmuted tracks.','success');
|
||||
};
|
||||
const handleCopyTrack = () => {
|
||||
@@ -1001,8 +1056,11 @@
|
||||
const data = t.buffer.getChannelData(0);
|
||||
// If selection exists, copy only the selected region
|
||||
if (selLeft !== null && selRight !== null && selRight > selLeft) {
|
||||
const startSample = Math.floor(selLeft * sr);
|
||||
const endSample = Math.min(data.length, Math.floor(selRight * sr));
|
||||
const trackStart = t.startTime || 0;
|
||||
const relSelLeft = Math.max(0, selLeft - trackStart);
|
||||
const relSelRight = Math.max(0, selRight - trackStart);
|
||||
const startSample = Math.floor(relSelLeft * sr);
|
||||
const endSample = Math.min(data.length, Math.floor(relSelRight * sr));
|
||||
const len = endSample - startSample;
|
||||
if (len > 0) {
|
||||
const ctx = getAudioContext();
|
||||
@@ -1025,8 +1083,11 @@
|
||||
const data = t.buffer.getChannelData(0);
|
||||
// If selection exists, cut only the selected region
|
||||
if (selLeft !== null && selRight !== null && selRight > selLeft) {
|
||||
const startSample = Math.floor(selLeft * sr);
|
||||
const endSample = Math.min(data.length, Math.floor(selRight * sr));
|
||||
const trackStart = t.startTime || 0;
|
||||
const relSelLeft = Math.max(0, selLeft - trackStart);
|
||||
const relSelRight = Math.max(0, selRight - trackStart);
|
||||
const startSample = Math.floor(relSelLeft * sr);
|
||||
const endSample = Math.min(data.length, Math.floor(relSelRight * sr));
|
||||
const len = endSample - startSample;
|
||||
if (len > 0) {
|
||||
// Copy selection to clipboard
|
||||
@@ -1060,6 +1121,20 @@
|
||||
};
|
||||
|
||||
// ── Server Health Check ──
|
||||
const [viewportWidth, setViewportWidth] = useState(1200);
|
||||
|
||||
useEffect(() => {
|
||||
const wrapper = timelineWrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
const observer = new ResizeObserver(entries => {
|
||||
for (let entry of entries) {
|
||||
setViewportWidth(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
observer.observe(wrapper);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(API_BASE_URL)
|
||||
.then(r => { if (r.ok) setServerStatus('connected'); else setServerStatus('error'); })
|
||||
@@ -1070,7 +1145,10 @@
|
||||
const maxDuration = useMemo(() => {
|
||||
let max = 10;
|
||||
tracks.forEach(t => {
|
||||
if (t.buffer) max = Math.max(max, t.buffer.duration);
|
||||
if (t.buffer) {
|
||||
const trackStart = t.startTime || 0;
|
||||
max = Math.max(max, trackStart + t.buffer.duration);
|
||||
}
|
||||
});
|
||||
return max;
|
||||
}, [tracks]);
|
||||
@@ -1078,9 +1156,19 @@
|
||||
const maxDurationRef = useRef(maxDuration);
|
||||
maxDurationRef.current = maxDuration;
|
||||
|
||||
const timelineWidth = useMemo(() => Math.max(zoom * maxDuration, 1200), [zoom, maxDuration]);
|
||||
const minZoom = useMemo(() => {
|
||||
return Math.max(20, viewportWidth / maxDuration);
|
||||
}, [viewportWidth, maxDuration]);
|
||||
|
||||
const minZoom = useMemo(() => Math.max(20, 1200 / maxDuration), [maxDuration]);
|
||||
const timelineWidth = useMemo(() => {
|
||||
return Math.max(zoom * maxDuration, viewportWidth);
|
||||
}, [zoom, maxDuration, viewportWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (zoom < minZoom) {
|
||||
setZoom(minZoom);
|
||||
}
|
||||
}, [minZoom]);
|
||||
|
||||
const playheadLeftPos = useMemo(() => currentTime * zoom, [currentTime, zoom]);
|
||||
|
||||
@@ -1268,8 +1356,17 @@
|
||||
source.connect(gainNode);
|
||||
gainNode.connect(context.destination);
|
||||
|
||||
if (offsetTime < track.buffer.duration) {
|
||||
source.start(0, offsetTime);
|
||||
const trackStart = track.startTime || 0;
|
||||
const trackDuration = track.buffer.duration;
|
||||
const trackEnd = trackStart + trackDuration;
|
||||
|
||||
if (offsetTime < trackStart) {
|
||||
const delay = trackStart - offsetTime;
|
||||
source.start(context.currentTime + delay, 0);
|
||||
activeSourcesRef.current.push(source);
|
||||
} else if (offsetTime < trackEnd) {
|
||||
const playOffset = offsetTime - trackStart;
|
||||
source.start(context.currentTime, playOffset);
|
||||
activeSourcesRef.current.push(source);
|
||||
}
|
||||
});
|
||||
@@ -1290,8 +1387,17 @@
|
||||
source.connect(gainNode);
|
||||
gainNode.connect(context.destination);
|
||||
|
||||
if (offsetTime < track.buffer.duration) {
|
||||
source.start(0, offsetTime);
|
||||
const trackStart = track.startTime || 0;
|
||||
const trackDuration = track.buffer.duration;
|
||||
const trackEnd = trackStart + trackDuration;
|
||||
|
||||
if (offsetTime < trackStart) {
|
||||
const delay = trackStart - offsetTime;
|
||||
source.start(context.currentTime + delay, 0);
|
||||
activeSourcesRef.current.push(source);
|
||||
} else if (offsetTime < trackEnd) {
|
||||
const playOffset = offsetTime - trackStart;
|
||||
source.start(context.currentTime, playOffset);
|
||||
activeSourcesRef.current.push(source);
|
||||
}
|
||||
};
|
||||
@@ -1424,6 +1530,85 @@
|
||||
};
|
||||
}, [zoom, maxDuration]);
|
||||
|
||||
const draggedClipRef = useRef(null);
|
||||
draggedClipRef.current = draggedClip;
|
||||
const hoveredTrackIdRef = useRef(null);
|
||||
hoveredTrackIdRef.current = hoveredTrackId;
|
||||
|
||||
const handleClipDragStart = (trackId, clickOffset) => {
|
||||
const track = tracks.find(t => t.id === trackId);
|
||||
if (!track || !track.buffer) return;
|
||||
|
||||
const beforeSnap = captureTrackSnapshot(trackId);
|
||||
|
||||
setDraggedClip({
|
||||
trackId: trackId,
|
||||
clickOffset: clickOffset,
|
||||
buffer: track.buffer,
|
||||
name: track.name,
|
||||
volume: track.volume,
|
||||
color: track.color,
|
||||
beforeSnap: beforeSnap
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e) => {
|
||||
const drag = draggedClipRef.current;
|
||||
if (!drag) return;
|
||||
|
||||
const wrapper = timelineWrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const scrollLeft = wrapper.scrollLeft;
|
||||
const mouseX = e.clientX - rect.left + scrollLeft;
|
||||
const time = mouseX / zoom;
|
||||
const newStart = Math.max(0, time - drag.clickOffset);
|
||||
|
||||
const targetTrackId = hoveredTrackIdRef.current || drag.trackId;
|
||||
|
||||
setTracks(prev => prev.map(t => {
|
||||
if (t.id === drag.trackId && drag.trackId !== targetTrackId) {
|
||||
return { ...t, buffer: null, startTime: 0 };
|
||||
}
|
||||
if (t.id === targetTrackId) {
|
||||
return {
|
||||
...t,
|
||||
buffer: drag.buffer,
|
||||
startTime: newStart,
|
||||
name: drag.name,
|
||||
volume: drag.volume,
|
||||
color: drag.color
|
||||
};
|
||||
}
|
||||
return t;
|
||||
}));
|
||||
|
||||
if (drag.trackId !== targetTrackId) {
|
||||
setDraggedClip(prev => ({ ...prev, trackId: targetTrackId }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
const drag = draggedClipRef.current;
|
||||
if (!drag) return;
|
||||
|
||||
const afterSnap = captureTrackSnapshot(drag.trackId);
|
||||
pushAction('MOVE_CLIP', drag.trackId, drag.beforeSnap, afterSnap);
|
||||
|
||||
setDraggedClip(null);
|
||||
showToast('Đã di chuyển clip.', 'success');
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [zoom]);
|
||||
|
||||
const handleSelectRange = (start, end, reset) => {
|
||||
const maxLen = maxDuration;
|
||||
const cleanStart = Math.max(0, Math.min(maxLen, start));
|
||||
@@ -1665,20 +1850,20 @@
|
||||
showToast(`Đã nạp sóng âm tổng hợp: ${type.toUpperCase()}`, 'success');
|
||||
};
|
||||
|
||||
// ── Add Track ──
|
||||
const addNewTrack = () => {
|
||||
const newId = (tracks.length + 1).toString();
|
||||
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
|
||||
const selectColor = colors[tracks.length % colors.length];
|
||||
|
||||
setTracks(prev => [...prev, {
|
||||
id: newId, name: `Track ${newId}`, buffer: null,
|
||||
volume: 0.8, muted: false, solo: false,
|
||||
color: selectColor, markers: [], serverFileId: null
|
||||
}]);
|
||||
showToast(`Đã thêm Track ${newId}.`, 'info');
|
||||
setTimeout(() => lucide.createIcons(), 200);
|
||||
};
|
||||
// ── Add Track ──
|
||||
const addNewTrack = () => {
|
||||
const newId = (tracks.length + 1).toString();
|
||||
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
|
||||
const selectColor = colors[tracks.length % colors.length];
|
||||
|
||||
setTracks(prev => [...prev, {
|
||||
id: newId, name: `Track ${newId}`, buffer: null, startTime: 0,
|
||||
volume: 0.8, muted: false, solo: false,
|
||||
color: selectColor, markers: [], serverFileId: null
|
||||
}]);
|
||||
showToast(`Đã thêm Track ${newId}.`, 'info');
|
||||
setTimeout(() => lucide.createIcons(), 200);
|
||||
};
|
||||
|
||||
// ── Server-side Export ──
|
||||
const triggerWavExport = async () => {
|
||||
@@ -1705,8 +1890,8 @@
|
||||
muted: false,
|
||||
clips: [{
|
||||
clip_id: `clip_${t.id}`,
|
||||
start_time_seconds: 0,
|
||||
end_time_seconds: t.buffer.duration,
|
||||
start_time_seconds: t.startTime || 0,
|
||||
end_time_seconds: (t.startTime || 0) + t.buffer.duration,
|
||||
loop_count: 1,
|
||||
apply_zero_crossing: true,
|
||||
fade_in_ms: 0,
|
||||
@@ -1770,9 +1955,9 @@
|
||||
try {
|
||||
const targetRate = parseInt(exportSettings.sampleRate);
|
||||
const bitDepth = parseInt(exportSettings.bitDepth);
|
||||
const durationLimit = Math.max(...activeTracks.map(t => t.buffer.duration));
|
||||
const durationLimit = Math.max(...activeTracks.map(t => (t.startTime || 0) + t.buffer.duration));
|
||||
|
||||
const offlineCtx = new OfflineAudioContext(1, targetRate * durationLimit, targetRate);
|
||||
const offlineCtx = new OfflineAudioContext(1, Math.ceil(targetRate * durationLimit), targetRate);
|
||||
|
||||
activeTracks.forEach(t => {
|
||||
const source = offlineCtx.createBufferSource();
|
||||
@@ -1781,7 +1966,8 @@
|
||||
gain.gain.setValueAtTime(t.volume, 0);
|
||||
source.connect(gain);
|
||||
gain.connect(offlineCtx.destination);
|
||||
source.start(0);
|
||||
const trackStart = t.startTime || 0;
|
||||
source.start(trackStart);
|
||||
});
|
||||
|
||||
const renderedBuffer = await offlineCtx.startRendering();
|
||||
@@ -2044,7 +2230,9 @@
|
||||
const track = tracks.find(t => t.id === trackId);
|
||||
if (!track || !track.buffer) return;
|
||||
|
||||
const cutTime = findZeroCrossing(track.buffer, currentTime);
|
||||
const trackStart = track.startTime || 0;
|
||||
const relCurrentTime = Math.max(0, currentTime - trackStart);
|
||||
const cutTime = findZeroCrossing(track.buffer, relCurrentTime);
|
||||
const sr = track.buffer.sampleRate;
|
||||
const cutSample = Math.floor(cutTime * sr);
|
||||
const originalData = track.buffer.getChannelData(0);
|
||||
@@ -2071,6 +2259,7 @@
|
||||
id: 'track_split_' + Date.now(),
|
||||
name: `${track.name} (Part 2)`,
|
||||
buffer: b2,
|
||||
startTime: trackStart + (cutSample / sr),
|
||||
markers: [],
|
||||
serverFileId: null,
|
||||
};
|
||||
@@ -2405,12 +2594,14 @@
|
||||
loadFileOnTrack(track.id, e.dataTransfer.files[0]);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => setHoveredTrackId(track.id)}
|
||||
>
|
||||
<WaveformLane track={track} zoom={zoom} timelineWidth={timelineWidth}
|
||||
onSelectRange={handleSelectRange} onPlayheadSet={setCurrentTime}
|
||||
isSelected={isSelected} onSelectTrack={setSelectedTrackId} markers={track.markers}
|
||||
onTrackLaneMouseDown={handleTrackLaneMouseDown}
|
||||
onContextMenu={handleContextMenu}
|
||||
onClipDragStart={handleClipDragStart}
|
||||
selectionMode={selectionMode}
|
||||
localSelectionTrackId={localSelectionTrackId}
|
||||
localSelLeft={localSelectionStart !== null && localSelectionEnd !== null ? Math.min(localSelectionStart, localSelectionEnd) : null}
|
||||
|
||||
Reference in New Issue
Block a user