fix&feat: 12_SUBTAB.md chỉnh sửa audioclip

This commit is contained in:
2026-07-19 08:47:11 +07:00
parent d2e0010d88
commit 615e0e8530
3 changed files with 1465 additions and 138 deletions
+616 -69
View File
@@ -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 ── */}