fix&feat: 12_SUBTAB.md chỉnh sửa audioclip
This commit is contained in:
+616
-69
@@ -667,8 +667,8 @@
|
||||
);
|
||||
};
|
||||
|
||||
// ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2) ──
|
||||
const SubTabWaveform = ({ buffer }) => {
|
||||
// ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2 & SUB_EDITOR.md) ──
|
||||
const SubTabWaveform = ({ buffer, subTabId, activeTab, currentTime, selectionStart, selectionEnd, onSelectRange, onPlayheadSet, onContextMenu }) => {
|
||||
const canvasRef = useRef(null);
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
@@ -689,25 +689,94 @@
|
||||
const len = data.length;
|
||||
if (len === 0) return;
|
||||
|
||||
ctx.strokeStyle = '#6ee7b7';
|
||||
ctx.lineWidth = 1;
|
||||
// Draw waveform
|
||||
ctx.fillStyle = '#6ee7b7';
|
||||
const mid = h / 2;
|
||||
for (let px = 0; px < w; px++) {
|
||||
const start = Math.floor((px / w) * len);
|
||||
const end = Math.floor(((px + 1) / w) * len);
|
||||
let maxVal = 0;
|
||||
let minVal = 0;
|
||||
for (let i = start; i < end && i < len; i++) {
|
||||
const abs = Math.abs(data[i]);
|
||||
if (abs > maxVal) maxVal = abs;
|
||||
const val = data[i];
|
||||
if (val > maxVal) maxVal = val;
|
||||
if (val < minVal) minVal = val;
|
||||
}
|
||||
const mid = h / 2;
|
||||
const peakHeight = maxVal * (h * 0.4);
|
||||
const yTop = mid + (minVal * (h * 0.45));
|
||||
const yBottom = mid + (maxVal * (h * 0.45));
|
||||
ctx.fillRect(px, yTop, 1, Math.max(1, yBottom - yTop));
|
||||
}
|
||||
|
||||
// Highlight selection if active
|
||||
if (selectionStart !== null && selectionEnd !== null && selectionStart !== selectionEnd) {
|
||||
const left = Math.min(selectionStart, selectionEnd);
|
||||
const right = Math.max(selectionStart, selectionEnd);
|
||||
const leftPx = (left / buffer.duration) * w;
|
||||
const rightPx = (right / buffer.duration) * w;
|
||||
ctx.fillStyle = 'rgba(245, 158, 11, 0.15)';
|
||||
ctx.fillRect(leftPx, 0, rightPx - leftPx, h);
|
||||
ctx.strokeStyle = '#f59e0b';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px, mid - peakHeight);
|
||||
ctx.lineTo(px, mid + peakHeight);
|
||||
ctx.moveTo(leftPx, 0); ctx.lineTo(leftPx, h);
|
||||
ctx.moveTo(rightPx, 0); ctx.lineTo(rightPx, h);
|
||||
ctx.stroke();
|
||||
}
|
||||
}, [buffer]);
|
||||
return <canvas ref={canvasRef} className="w-full h-full rounded border border-zinc-800"></canvas>;
|
||||
|
||||
// Draw local playhead
|
||||
if (currentTime !== null && currentTime >= 0 && currentTime <= buffer.duration) {
|
||||
const playheadPx = (currentTime / buffer.duration) * w;
|
||||
ctx.strokeStyle = '#ef4444';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(playheadPx, 0);
|
||||
ctx.lineTo(playheadPx, h);
|
||||
ctx.stroke();
|
||||
}
|
||||
}, [buffer, currentTime, selectionStart, selectionEnd]);
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
if (e.button === 2) return; // ignore right click
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const startX = e.clientX - rect.left;
|
||||
const duration = buffer.duration;
|
||||
const startTime = (startX / rect.width) * duration;
|
||||
|
||||
onSelectRange(startTime, startTime);
|
||||
onPlayheadSet(startTime);
|
||||
|
||||
const handleMouseMove = (moveEvent) => {
|
||||
const currentX = moveEvent.clientX - rect.left;
|
||||
const currentTime = Math.max(0, Math.min(duration, (currentX / rect.width) * duration));
|
||||
onSelectRange(startTime, currentTime);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
const handleContextMenuInternal = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const clickTime = (x / rect.width) * buffer.duration;
|
||||
onContextMenu(e, clickTime);
|
||||
};
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-48 rounded border border-zinc-800 cursor-crosshair"
|
||||
onMouseDown={handleMouseDown}
|
||||
onContextMenu={handleContextMenuInternal}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
@@ -857,7 +926,7 @@
|
||||
|
||||
// ── Tab System (LOOP_EDITOR_2.md §1) ──
|
||||
const [activeTab, setActiveTab] = useState('main');
|
||||
const [subTabs, setSubTabs] = useState([]); // [{id, label, trackId, startTime, endTime, buffer}, ...]
|
||||
const [subTabs, setSubTabs] = useState([]); // [{id, label, trackId, startTime, endTime, buffer, effects, currentTime, selectionStart, selectionEnd, isPlaying}, ...]
|
||||
const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2
|
||||
|
||||
// ── Temp Edit Tab (LOOP_EDITOR_2.md §1.2 Sub Tab) ──
|
||||
@@ -902,12 +971,276 @@
|
||||
|
||||
const selectedClipIdRef = useRef(null);
|
||||
selectedClipIdRef.current = selectedClipId;
|
||||
const activeTabRef = useRef(activeTab);
|
||||
activeTabRef.current = activeTab;
|
||||
const subTabsRef = useRef(subTabs);
|
||||
subTabsRef.current = subTabs;
|
||||
|
||||
const handleSubTabNormalize = (tabId) => {
|
||||
setSubTabs(prev => prev.map(st => {
|
||||
if (st.id !== tabId || !st.buffer) return st;
|
||||
const ctx = getAudioContext();
|
||||
const data = st.buffer.getChannelData(0);
|
||||
const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : 0;
|
||||
const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : st.buffer.duration;
|
||||
const startSample = Math.floor(left * st.buffer.sampleRate);
|
||||
const endSample = Math.floor(right * st.buffer.sampleRate);
|
||||
|
||||
let maxVal = 0;
|
||||
for (let i = startSample; i < endSample; i++) {
|
||||
const abs = Math.abs(data[i]);
|
||||
if (abs > maxVal) maxVal = abs;
|
||||
}
|
||||
if (maxVal === 0) return st;
|
||||
|
||||
const scale = 1.0 / maxVal;
|
||||
const clonedBuffer = ctx.createBuffer(1, data.length, st.buffer.sampleRate);
|
||||
const clonedData = clonedBuffer.getChannelData(0);
|
||||
clonedData.set(data);
|
||||
for (let i = startSample; i < endSample; i++) {
|
||||
clonedData[i] = Math.max(-1, Math.min(1, clonedData[i] * scale));
|
||||
}
|
||||
showToast('Đã Normalize vùng chọn.', 'success');
|
||||
return { ...st, buffer: clonedBuffer };
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubTabGain = (tabId, gainDb) => {
|
||||
setSubTabs(prev => prev.map(st => {
|
||||
if (st.id !== tabId || !st.buffer) return st;
|
||||
const ctx = getAudioContext();
|
||||
const data = st.buffer.getChannelData(0);
|
||||
const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : 0;
|
||||
const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : st.buffer.duration;
|
||||
const startSample = Math.floor(left * st.buffer.sampleRate);
|
||||
const endSample = Math.floor(right * st.buffer.sampleRate);
|
||||
|
||||
const scale = Math.pow(10, gainDb / 20);
|
||||
const clonedBuffer = ctx.createBuffer(1, data.length, st.buffer.sampleRate);
|
||||
const clonedData = clonedBuffer.getChannelData(0);
|
||||
clonedData.set(data);
|
||||
for (let i = startSample; i < endSample; i++) {
|
||||
clonedData[i] = Math.max(-1, Math.min(1, clonedData[i] * scale));
|
||||
}
|
||||
showToast(`Đã điều chỉnh Gain: ${gainDb} dB.`, 'success');
|
||||
return { ...st, buffer: clonedBuffer };
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubTabFade = (tabId, type) => {
|
||||
setSubTabs(prev => prev.map(st => {
|
||||
if (st.id !== tabId || !st.buffer) return st;
|
||||
const ctx = getAudioContext();
|
||||
const data = st.buffer.getChannelData(0);
|
||||
const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : 0;
|
||||
const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : st.buffer.duration;
|
||||
const startSample = Math.floor(left * st.buffer.sampleRate);
|
||||
const endSample = Math.floor(right * st.buffer.sampleRate);
|
||||
const durationSamples = endSample - startSample;
|
||||
if (durationSamples <= 0) return st;
|
||||
|
||||
const clonedBuffer = ctx.createBuffer(1, data.length, st.buffer.sampleRate);
|
||||
const clonedData = clonedBuffer.getChannelData(0);
|
||||
clonedData.set(data);
|
||||
|
||||
if (type === 'in') {
|
||||
for (let i = 0; i < durationSamples; i++) {
|
||||
const alpha = i / durationSamples;
|
||||
clonedData[startSample + i] *= alpha;
|
||||
}
|
||||
showToast('Đã áp dụng Fade In.', 'success');
|
||||
} else if (type === 'out') {
|
||||
for (let i = 0; i < durationSamples; i++) {
|
||||
const alpha = (durationSamples - i) / durationSamples;
|
||||
clonedData[startSample + i] *= alpha;
|
||||
}
|
||||
showToast('Đã áp dụng Fade Out.', 'success');
|
||||
}
|
||||
return { ...st, buffer: clonedBuffer };
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubTabCut = (tabId) => {
|
||||
const st = subTabsRef.current.find(s => s.id === tabId);
|
||||
if (!st || !st.buffer) return;
|
||||
const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : null;
|
||||
const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : null;
|
||||
if (left === null || right === null || left === right) {
|
||||
showToast('Vui lòng chọn vùng để Cut.', 'warning');
|
||||
return;
|
||||
}
|
||||
const ctx = getAudioContext();
|
||||
const sr = st.buffer.sampleRate;
|
||||
const data = st.buffer.getChannelData(0);
|
||||
const startSample = Math.floor(left * sr);
|
||||
const endSample = Math.floor(right * sr);
|
||||
const len = endSample - startSample;
|
||||
|
||||
const cutBuffer = ctx.createBuffer(1, len, sr);
|
||||
cutBuffer.copyToChannel(data.subarray(startSample, endSample), 0);
|
||||
clipboardRef.current = { buffer: cutBuffer, name: 'Subtab Clip' };
|
||||
|
||||
const newBuffer = ctx.createBuffer(1, data.length - len, sr);
|
||||
const newData = newBuffer.getChannelData(0);
|
||||
let idx = 0;
|
||||
for (let i = 0; i < startSample; i++) newData[idx++] = data[i];
|
||||
for (let i = endSample; i < data.length; i++) newData[idx++] = data[i];
|
||||
|
||||
setSubTabs(prev => prev.map(s => s.id === tabId ? {
|
||||
...s,
|
||||
buffer: newBuffer,
|
||||
currentTime: left,
|
||||
selectionStart: null,
|
||||
selectionEnd: null
|
||||
} : s));
|
||||
showToast('Đã Cut vùng chọn.', 'success');
|
||||
};
|
||||
|
||||
const handleSubTabCopy = (tabId) => {
|
||||
const st = subTabsRef.current.find(s => s.id === tabId);
|
||||
if (!st || !st.buffer) return;
|
||||
const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : null;
|
||||
const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : null;
|
||||
if (left === null || right === null || left === right) {
|
||||
showToast('Vui lòng chọn vùng để Copy.', 'warning');
|
||||
return;
|
||||
}
|
||||
const ctx = getAudioContext();
|
||||
const sr = st.buffer.sampleRate;
|
||||
const data = st.buffer.getChannelData(0);
|
||||
const startSample = Math.floor(left * sr);
|
||||
const endSample = Math.floor(right * sr);
|
||||
const len = endSample - startSample;
|
||||
|
||||
const copyBuffer = ctx.createBuffer(1, len, sr);
|
||||
copyBuffer.copyToChannel(data.subarray(startSample, endSample), 0);
|
||||
clipboardRef.current = { buffer: copyBuffer, name: 'Subtab Clip' };
|
||||
showToast('Đã Copy vùng chọn.', 'success');
|
||||
};
|
||||
|
||||
const handleSubTabPaste = (tabId) => {
|
||||
const st = subTabsRef.current.find(s => s.id === tabId);
|
||||
if (!st || !st.buffer) return;
|
||||
if (!clipboardRef.current || !clipboardRef.current.buffer) {
|
||||
showToast('Clipboard trống.', 'warning');
|
||||
return;
|
||||
}
|
||||
const ctx = getAudioContext();
|
||||
const clipBuf = clipboardRef.current.buffer;
|
||||
const sr = st.buffer.sampleRate;
|
||||
const data = st.buffer.getChannelData(0);
|
||||
const insertTime = st.currentTime || 0;
|
||||
const insertSample = Math.floor(insertTime * sr);
|
||||
|
||||
const newBuffer = ctx.createBuffer(1, data.length + clipBuf.length, sr);
|
||||
const newData = newBuffer.getChannelData(0);
|
||||
|
||||
let idx = 0;
|
||||
for (let i = 0; i < insertSample; i++) newData[idx++] = data[i];
|
||||
const clipData = clipBuf.getChannelData(0);
|
||||
for (let i = 0; i < clipBuf.length; i++) newData[idx++] = clipData[i];
|
||||
for (let i = insertSample; i < data.length; i++) newData[idx++] = data[i];
|
||||
|
||||
setSubTabs(prev => prev.map(s => s.id === tabId ? {
|
||||
...s,
|
||||
buffer: newBuffer,
|
||||
currentTime: insertTime + clipBuf.duration,
|
||||
selectionStart: null,
|
||||
selectionEnd: null
|
||||
} : s));
|
||||
showToast('Đã dán dữ liệu âm thanh.', 'success');
|
||||
};
|
||||
|
||||
const handleSubTabDelete = (tabId) => {
|
||||
const st = subTabsRef.current.find(s => s.id === tabId);
|
||||
if (!st || !st.buffer) return;
|
||||
const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : null;
|
||||
const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : null;
|
||||
if (left === null || right === null || left === right) {
|
||||
showToast('Vui lòng chọn vùng để Delete.', 'warning');
|
||||
return;
|
||||
}
|
||||
const ctx = getAudioContext();
|
||||
const sr = st.buffer.sampleRate;
|
||||
const data = st.buffer.getChannelData(0);
|
||||
const startSample = Math.floor(left * sr);
|
||||
const endSample = Math.floor(right * sr);
|
||||
const len = endSample - startSample;
|
||||
|
||||
const newBuffer = ctx.createBuffer(1, data.length - len, sr);
|
||||
const newData = newBuffer.getChannelData(0);
|
||||
let idx = 0;
|
||||
for (let i = 0; i < startSample; i++) newData[idx++] = data[i];
|
||||
for (let i = endSample; i < data.length; i++) newData[idx++] = data[i];
|
||||
|
||||
setSubTabs(prev => prev.map(s => s.id === tabId ? {
|
||||
...s,
|
||||
buffer: newBuffer,
|
||||
currentTime: left,
|
||||
selectionStart: null,
|
||||
selectionEnd: null
|
||||
} : s));
|
||||
showToast('Đã xóa vùng chọn.', 'success');
|
||||
};
|
||||
|
||||
const handleSubTabLoop = (tabId, loopCount) => {
|
||||
const st = subTabsRef.current.find(s => s.id === tabId);
|
||||
if (!st || !st.buffer) return;
|
||||
const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : null;
|
||||
const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : null;
|
||||
if (left === null || right === null || left === right) {
|
||||
showToast('Vui lòng chọn vùng để Loop.', 'warning');
|
||||
return;
|
||||
}
|
||||
const ctx = getAudioContext();
|
||||
const sr = st.buffer.sampleRate;
|
||||
const data = st.buffer.getChannelData(0);
|
||||
const startSample = Math.floor(left * sr);
|
||||
const endSample = Math.floor(right * sr);
|
||||
const len = endSample - startSample;
|
||||
|
||||
// Loop payload N times
|
||||
const segmentData = data.subarray(startSample, endSample);
|
||||
const addedSamples = len * (loopCount - 1);
|
||||
const newBuffer = ctx.createBuffer(1, data.length + addedSamples, sr);
|
||||
const newData = newBuffer.getChannelData(0);
|
||||
|
||||
let idx = 0;
|
||||
for (let i = 0; i < endSample; i++) newData[idx++] = data[i];
|
||||
for (let n = 0; n < loopCount - 1; n++) {
|
||||
for (let i = 0; i < len; i++) newData[idx++] = segmentData[i];
|
||||
}
|
||||
for (let i = endSample; i < data.length; i++) newData[idx++] = data[i];
|
||||
|
||||
setSubTabs(prev => prev.map(s => s.id === tabId ? {
|
||||
...s,
|
||||
buffer: newBuffer,
|
||||
selectionStart: null,
|
||||
selectionEnd: null
|
||||
} : s));
|
||||
showToast(`Đã lặp vùng chọn ${loopCount} lần.`, 'success');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
const alt = e.altKey;
|
||||
if (e.key === ' ' || e.code === 'Space') { e.preventDefault(); if (handlePlayPauseRef.current) handlePlayPauseRef.current(); return; }
|
||||
|
||||
if (activeTabRef.current !== 'main') {
|
||||
// Sub-Tab keyboard shortcuts mapping
|
||||
const curTabId = activeTabRef.current;
|
||||
if (e.key === ' ' || e.code === 'Space') { e.preventDefault(); if (handlePlayPauseRef.current) handlePlayPauseRef.current(); return; }
|
||||
if (ctrl && alt && e.key === 'n') { e.preventDefault(); handleSubTabNormalize(curTabId); return; }
|
||||
if (e.key === 'f' || e.key === 'F') { e.preventDefault(); handleSubTabFade(curTabId, 'in'); return; }
|
||||
if (e.key === 'g' || e.key === 'G') { e.preventDefault(); handleSubTabFade(curTabId, 'out'); return; }
|
||||
if (ctrl && e.key === 'l') { e.preventDefault(); handleSubTabLoop(curTabId, 4); return; }
|
||||
if (e.key === 'v' || e.key === 'V') { e.preventDefault(); const val = prompt("Nhập Gain điều chỉnh (dB):", "0"); if(val) handleSubTabGain(curTabId, parseFloat(val) || 0); return; }
|
||||
if (ctrl && e.key === 'x') { e.preventDefault(); handleSubTabCut(curTabId); return; }
|
||||
if (ctrl && e.key === 'c') { e.preventDefault(); handleSubTabCopy(curTabId); return; }
|
||||
if (ctrl && e.key === 'v') { e.preventDefault(); handleSubTabPaste(curTabId); return; }
|
||||
if (e.key === 'Delete' || e.key === 'Backspace' || e.key === 'Del') { e.preventDefault(); handleSubTabDelete(curTabId); return; }
|
||||
return;
|
||||
}
|
||||
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; }
|
||||
@@ -1048,6 +1381,10 @@
|
||||
endTime: selRight,
|
||||
buffer: subBuffer,
|
||||
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0 },
|
||||
currentTime: 0,
|
||||
selectionStart: null,
|
||||
selectionEnd: null,
|
||||
isPlaying: false
|
||||
}]);
|
||||
setActiveTab(tabId);
|
||||
showToast(`Đã tạo Sub Tab: ${tabLabel}. Chỉnh sửa và Apply để gộp lại.`, 'info');
|
||||
@@ -1076,16 +1413,20 @@
|
||||
const tabId = 'subtab_' + Date.now();
|
||||
const tabLabel = `Edit_${clip.name.replace('.wav','').slice(0,10)}`;
|
||||
|
||||
setSubTabs(prev => [...prev, {
|
||||
id: tabId,
|
||||
label: tabLabel,
|
||||
trackId: trackId,
|
||||
clipId: clip.id === 'default' ? 'default_' + trackId : clip.id,
|
||||
startTime: clip.startTime,
|
||||
endTime: clip.startTime + clip.buffer.duration,
|
||||
buffer: subBuffer,
|
||||
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0 },
|
||||
}]);
|
||||
setSubTabs(prev => [...prev, {
|
||||
id: tabId,
|
||||
label: tabLabel,
|
||||
trackId: trackId,
|
||||
clipId: clip.id === 'default' ? 'default_' + trackId : clip.id,
|
||||
startTime: clip.startTime,
|
||||
endTime: clip.startTime + clip.buffer.duration,
|
||||
buffer: subBuffer,
|
||||
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0 },
|
||||
currentTime: 0,
|
||||
selectionStart: null,
|
||||
selectionEnd: null,
|
||||
isPlaying: false
|
||||
}]);
|
||||
setActiveTab(tabId);
|
||||
showToast(`Đã tạo Sub Tab: ${tabLabel}. Chỉnh sửa và Apply để gộp lại.`, 'info');
|
||||
};
|
||||
@@ -1692,6 +2033,73 @@
|
||||
|
||||
// ── Update Playhead ──
|
||||
const updatePlayhead = () => {
|
||||
if (activeTab !== 'main') {
|
||||
const st = subTabs.find(s => s.id === activeTab);
|
||||
if (!st || !st.isPlaying || !st.buffer) return;
|
||||
const context = getAudioContext();
|
||||
const elapsed = context.currentTime - startAudioTimeRef.current;
|
||||
const updatedTime = startOffsetTimeRef.current + elapsed;
|
||||
|
||||
// Loop sub-tab selection
|
||||
if (st.selectionStart !== null && st.selectionEnd !== null && st.selectionStart !== st.selectionEnd) {
|
||||
const start = Math.min(st.selectionStart, st.selectionEnd);
|
||||
const end = Math.max(st.selectionStart, st.selectionEnd);
|
||||
if (updatedTime >= end) {
|
||||
stopAllPlayback();
|
||||
setSubTabs(prev => prev.map(s => s.id === activeTab ? {
|
||||
...s,
|
||||
currentTime: start,
|
||||
isPlaying: true
|
||||
} : s));
|
||||
// Restart subtab buffer segment
|
||||
const eff = st.buffer.getChannelData(0);
|
||||
const edBuffer = context.createBuffer(1, eff.length, st.buffer.sampleRate);
|
||||
const edData = edBuffer.getChannelData(0);
|
||||
edData.set(eff);
|
||||
const fx = st.effects || {};
|
||||
if (fx.reverse) {
|
||||
const reversed = new Float32Array(edData);
|
||||
for (let i = 0; i < edData.length; i++) reversed[i] = edData[edData.length - 1 - i];
|
||||
edBuffer.copyToChannel(reversed, 0);
|
||||
}
|
||||
if (fx.gainDb !== 0) {
|
||||
const gain = Math.pow(10, fx.gainDb / 20);
|
||||
for (let i = 0; i < edData.length; i++) edData[i] = Math.max(-1, Math.min(1, edData[i] * gain));
|
||||
}
|
||||
if (fx.fadeInMs > 0) {
|
||||
const sr = edBuffer.sampleRate;
|
||||
const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeInMs / 1000 * sr));
|
||||
for (let i = 0; i < fadeSamples; i++) edData[i] = edData[i] * (i / fadeSamples);
|
||||
}
|
||||
if (fx.fadeOutMs > 0) {
|
||||
const sr = edBuffer.sampleRate;
|
||||
const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeOutMs / 1000 * sr));
|
||||
for (let i = edData.length - fadeSamples; i < edData.length; i++) {
|
||||
edData[i] = edData[i] * ((edData.length - 1 - i) / fadeSamples);
|
||||
}
|
||||
}
|
||||
const source = context.createBufferSource();
|
||||
source.buffer = edBuffer;
|
||||
source.connect(context.destination);
|
||||
source.start(context.currentTime, start);
|
||||
activeSourcesRef.current = [source];
|
||||
startOffsetTimeRef.current = start;
|
||||
startAudioTimeRef.current = context.currentTime;
|
||||
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedTime >= st.buffer.duration) {
|
||||
stopAllPlayback();
|
||||
setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, currentTime: 0, isPlaying: false } : s));
|
||||
return;
|
||||
}
|
||||
|
||||
setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, currentTime: updatedTime } : s));
|
||||
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
|
||||
return;
|
||||
}
|
||||
if (!isPlaying) return;
|
||||
const context = getAudioContext();
|
||||
const elapsed = context.currentTime - startAudioTimeRef.current;
|
||||
@@ -1734,13 +2142,13 @@
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlaying) {
|
||||
if (isPlaying || subTabs.some(s => s.isPlaying)) {
|
||||
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
|
||||
} else {
|
||||
cancelAnimationFrame(animationFrameIdRef.current);
|
||||
}
|
||||
return () => cancelAnimationFrame(animationFrameIdRef.current);
|
||||
}, [isPlaying, isLoopingSelection, selLeft, selRight, selectionMode, localSelectionTrackId, selectionCleared]);
|
||||
}, [isPlaying, subTabs, isLoopingSelection, selLeft, selRight, selectionMode, localSelectionTrackId, selectionCleared, activeTab]);
|
||||
|
||||
// ── Playback ──
|
||||
const startTrackPlayback = (offsetTime) => {
|
||||
@@ -1836,6 +2244,66 @@
|
||||
};
|
||||
|
||||
const handlePlayPause = () => {
|
||||
if (activeTab !== 'main') {
|
||||
// Sub-tab playback transport
|
||||
const st = subTabs.find(s => s.id === activeTab);
|
||||
if (!st || !st.buffer) return;
|
||||
const context = getAudioContext();
|
||||
|
||||
if (st.isPlaying) {
|
||||
stopAllPlayback();
|
||||
setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, isPlaying: false } : s));
|
||||
} else {
|
||||
stopAllPlayback();
|
||||
// Clone buffer and apply effects for listening
|
||||
const eff = st.buffer.getChannelData(0);
|
||||
const edBuffer = context.createBuffer(1, eff.length, st.buffer.sampleRate);
|
||||
const edData = edBuffer.getChannelData(0);
|
||||
edData.set(eff);
|
||||
|
||||
const fx = st.effects || {};
|
||||
if (fx.reverse) {
|
||||
const reversed = new Float32Array(edData);
|
||||
for (let i = 0; i < edData.length; i++) reversed[i] = edData[edData.length - 1 - i];
|
||||
edBuffer.copyToChannel(reversed, 0);
|
||||
}
|
||||
if (fx.gainDb !== 0) {
|
||||
const gain = Math.pow(10, fx.gainDb / 20);
|
||||
for (let i = 0; i < edData.length; i++) edData[i] = Math.max(-1, Math.min(1, edData[i] * gain));
|
||||
}
|
||||
if (fx.fadeInMs > 0) {
|
||||
const sr = edBuffer.sampleRate;
|
||||
const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeInMs / 1000 * sr));
|
||||
for (let i = 0; i < fadeSamples; i++) edData[i] = edData[i] * (i / fadeSamples);
|
||||
}
|
||||
if (fx.fadeOutMs > 0) {
|
||||
const sr = edBuffer.sampleRate;
|
||||
const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeOutMs / 1000 * sr));
|
||||
for (let i = edData.length - fadeSamples; i < edData.length; i++) {
|
||||
edData[i] = edData[i] * ((edData.length - 1 - i) / fadeSamples);
|
||||
}
|
||||
}
|
||||
|
||||
const source = context.createBufferSource();
|
||||
source.buffer = edBuffer;
|
||||
|
||||
const gainNode = context.createGain();
|
||||
gainNode.gain.setValueAtTime(1.0, context.currentTime);
|
||||
|
||||
source.connect(gainNode);
|
||||
gainNode.connect(context.destination);
|
||||
|
||||
const startOffset = st.currentTime || 0;
|
||||
source.start(context.currentTime, startOffset);
|
||||
|
||||
activeSourcesRef.current = [source];
|
||||
startOffsetTimeRef.current = startOffset;
|
||||
startAudioTimeRef.current = context.currentTime;
|
||||
|
||||
setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, isPlaying: true } : s));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const context = getAudioContext();
|
||||
if (isPlaying) {
|
||||
stopAllPlayback();
|
||||
@@ -1849,7 +2317,7 @@
|
||||
handlePlayPauseRef.current = handlePlayPause;
|
||||
|
||||
const handlePause = () => {
|
||||
if (isPlaying) stopAllPlayback();
|
||||
if (isPlaying || subTabs.some(s => s.isPlaying)) stopAllPlayback();
|
||||
};
|
||||
|
||||
const stopAllPlayback = () => {
|
||||
@@ -1858,11 +2326,16 @@
|
||||
});
|
||||
activeSourcesRef.current = [];
|
||||
setIsPlaying(false);
|
||||
setSubTabs(prev => prev.map(s => ({ ...s, isPlaying: false })));
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
stopAllPlayback();
|
||||
setCurrentTime(0);
|
||||
if (activeTab !== 'main') {
|
||||
setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, currentTime: 0 } : s));
|
||||
} else {
|
||||
setCurrentTime(0);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Selection ──
|
||||
@@ -3845,7 +4318,29 @@
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col p-3 gap-3 overflow-y-auto">
|
||||
<SubTabWaveform buffer={st.buffer} />
|
||||
<SubTabWaveform
|
||||
buffer={st.buffer}
|
||||
subTabId={st.id}
|
||||
activeTab={activeTab}
|
||||
currentTime={st.currentTime}
|
||||
selectionStart={st.selectionStart}
|
||||
selectionEnd={st.selectionEnd}
|
||||
onSelectRange={(start, end) => {
|
||||
setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, selectionStart: start, selectionEnd: end } : s));
|
||||
}}
|
||||
onPlayheadSet={(time) => {
|
||||
setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: time } : s));
|
||||
}}
|
||||
onContextMenu={(e, clickTime) => {
|
||||
setContextMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
isSubTab: true,
|
||||
subTabId: st.id,
|
||||
time: clickTime
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div className="grid grid-cols-4 gap-3 max-w-2xl">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[9px] text-zinc-500 font-bold uppercase">Reverse</span>
|
||||
@@ -3894,46 +4389,98 @@
|
||||
|
||||
{/* ── Context Menu ── */}
|
||||
{contextMenu && (
|
||||
<div className="fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64" style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={contextMenuEdit} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="file-edit" className="w-3.5 h-3.5 text-amber-400 shrink-0"></i>
|
||||
<span className="flex-1">Edit</span>
|
||||
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">Ctrl+E</span>
|
||||
</button>
|
||||
<button onClick={contextMenuSplit} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="scissors" className="w-3.5 h-3.5 text-cyan-400 shrink-0"></i>
|
||||
<span className="flex-1">Split</span>
|
||||
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">S</span>
|
||||
</button>
|
||||
<button onClick={contextMenuMerge} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="combine" className="w-3.5 h-3.5 text-purple-400 shrink-0"></i>
|
||||
<span className="flex-1">Merge</span>
|
||||
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">Ctrl+M</span>
|
||||
</button>
|
||||
<div className="h-px bg-zinc-700 my-1"></div>
|
||||
<button onClick={contextMenuCopy} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="copy" className="w-3.5 h-3.5 text-zinc-400 shrink-0"></i>
|
||||
<span className="flex-1">Copy</span>
|
||||
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">Ctrl+C</span>
|
||||
</button>
|
||||
<button onClick={contextMenuCut} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="scissors" className="w-3.5 h-3.5 text-rose-400 shrink-0"></i>
|
||||
<span className="flex-1">Cut</span>
|
||||
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">Ctrl+X</span>
|
||||
</button>
|
||||
<button onClick={contextMenuPaste} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="clipboard" className="w-3.5 h-3.5 text-emerald-400 shrink-0"></i>
|
||||
<span className="flex-1">Paste</span>
|
||||
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">Ctrl+V</span>
|
||||
</button>
|
||||
<div className="h-px bg-zinc-700 my-1"></div>
|
||||
<button onClick={contextMenuDelete} className="w-full px-3 py-1.5 text-xs text-red-300 hover:bg-red-950 text-left flex items-center gap-2">
|
||||
<i data-lucide="trash-2" className="w-3.5 h-3.5 text-red-400 shrink-0"></i>
|
||||
<span className="flex-1">Delete</span>
|
||||
<span className="text-amber-400 text-[12px] font-semibold font-mono ml-auto pl-8">Del</span>
|
||||
</button>
|
||||
</div>
|
||||
contextMenu.isSubTab ? (
|
||||
<div className="fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64" style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<div className="px-3 py-1 text-[10px] text-zinc-500 font-bold uppercase border-b border-zinc-800 pb-1 mb-1">
|
||||
Selection: {formatTime(subTabs.find(s => s.id === contextMenu.subTabId)?.selectionStart || 0)} - {formatTime(subTabs.find(s => s.id === contextMenu.subTabId)?.selectionEnd || 0)}
|
||||
</div>
|
||||
<button onClick={() => { handleSubTabNormalize(contextMenu.subTabId); closeContextMenu(); }} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="maximize" className="w-3.5 h-3.5 text-amber-400 shrink-0"></i>
|
||||
<span className="flex-1">Normalize Selection To Peak</span>
|
||||
</button>
|
||||
<button onClick={() => { const val = prompt("Nhập Gain điều chỉnh (dB):", "0"); if(val) handleSubTabGain(contextMenu.subTabId, parseFloat(val) || 0); closeContextMenu(); }} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="volume-2" className="w-3.5 h-3.5 text-amber-400 shrink-0"></i>
|
||||
<span className="flex-1">Adjust Gain/Volume...</span>
|
||||
</button>
|
||||
<button onClick={() => { handleSubTabFade(contextMenu.subTabId, 'in'); closeContextMenu(); }} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="chevrons-right" className="w-3.5 h-3.5 text-amber-400 shrink-0"></i>
|
||||
<span className="flex-1">Fade In (Linear)</span>
|
||||
</button>
|
||||
<button onClick={() => { handleSubTabFade(contextMenu.subTabId, 'out'); closeContextMenu(); }} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="chevrons-left" className="w-3.5 h-3.5 text-amber-400 shrink-0"></i>
|
||||
<span className="flex-1">Fade Out (Linear)</span>
|
||||
</button>
|
||||
<div className="h-px bg-zinc-700 my-1"></div>
|
||||
<button onClick={() => { handleSubTabCut(contextMenu.subTabId); closeContextMenu(); }} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="scissors" className="w-3.5 h-3.5 text-rose-400 shrink-0"></i>
|
||||
<span className="flex-1">Cut</span>
|
||||
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto">Ctrl+X</span>
|
||||
</button>
|
||||
<button onClick={() => { handleSubTabCopy(contextMenu.subTabId); closeContextMenu(); }} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="copy" className="w-3.5 h-3.5 text-zinc-400 shrink-0"></i>
|
||||
<span className="flex-1">Copy</span>
|
||||
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto">Ctrl+C</span>
|
||||
</button>
|
||||
<button onClick={() => { handleSubTabPaste(contextMenu.subTabId); closeContextMenu(); }} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="clipboard" className="w-3.5 h-3.5 text-emerald-400 shrink-0"></i>
|
||||
<span className="flex-1">Paste</span>
|
||||
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto">Ctrl+V</span>
|
||||
</button>
|
||||
<button onClick={() => { handleSubTabDelete(contextMenu.subTabId); closeContextMenu(); }} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="trash-2" className="w-3.5 h-3.5 text-red-400 shrink-0"></i>
|
||||
<span className="flex-1">Delete Selected Segment</span>
|
||||
<span className="text-amber-400 text-[12px] font-semibold font-mono ml-auto">Del</span>
|
||||
</button>
|
||||
<div className="h-px bg-zinc-700 my-1"></div>
|
||||
<button onClick={() => { handleSubTabLoop(contextMenu.subTabId, 4); closeContextMenu(); }} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="repeat" className="w-3.5 h-3.5 text-cyan-400 shrink-0"></i>
|
||||
<span className="flex-1">Loop Selection 4 times</span>
|
||||
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto">Ctrl+L</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64" style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<button onClick={contextMenuEdit} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="file-edit" className="w-3.5 h-3.5 text-amber-400 shrink-0"></i>
|
||||
<span className="flex-1">Edit</span>
|
||||
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">Ctrl+E</span>
|
||||
</button>
|
||||
<button onClick={contextMenuSplit} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="scissors" className="w-3.5 h-3.5 text-cyan-400 shrink-0"></i>
|
||||
<span className="flex-1">Split</span>
|
||||
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">S</span>
|
||||
</button>
|
||||
<button onClick={contextMenuMerge} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="combine" className="w-3.5 h-3.5 text-purple-400 shrink-0"></i>
|
||||
<span className="flex-1">Merge</span>
|
||||
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">Ctrl+M</span>
|
||||
</button>
|
||||
<div className="h-px bg-zinc-700 my-1"></div>
|
||||
<button onClick={contextMenuCopy} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="copy" className="w-3.5 h-3.5 text-zinc-400 shrink-0"></i>
|
||||
<span className="flex-1">Copy</span>
|
||||
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">Ctrl+C</span>
|
||||
</button>
|
||||
<button onClick={contextMenuCut} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="scissors" className="w-3.5 h-3.5 text-rose-400 shrink-0"></i>
|
||||
<span className="flex-1">Cut</span>
|
||||
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">Ctrl+X</span>
|
||||
</button>
|
||||
<button onClick={contextMenuPaste} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="clipboard" className="w-3.5 h-3.5 text-emerald-400 shrink-0"></i>
|
||||
<span className="flex-1">Paste</span>
|
||||
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">Ctrl+V</span>
|
||||
</button>
|
||||
<div className="h-px bg-zinc-700 my-1"></div>
|
||||
<button onClick={contextMenuDelete} className="w-full px-3 py-1.5 text-xs text-red-300 hover:bg-red-950 text-left flex items-center gap-2">
|
||||
<i data-lucide="trash-2" className="w-3.5 h-3.5 text-red-400 shrink-0"></i>
|
||||
<span className="flex-1">Delete</span>
|
||||
<span className="text-amber-400 text-[12px] font-semibold font-mono ml-auto pl-8">Del</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* ── Toast ── */}
|
||||
|
||||
Reference in New Issue
Block a user