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
+233
View File
@@ -0,0 +1,233 @@
# Technical Specification: Advanced Editing Toolset & Volume Automation Envelope on Sub-Tab
This document defines the interactive layout design and signal processing algorithms for the advanced localized editing toolset contained within the isolated temporary document workspace (Sub-tab).
---
## 1. Target Selection Scope
The toolset within the Sub-tab environment supports two target operational boundaries:
* **Global Clip:** When no specific timeline selection highlighted mask is present, all active DSP effects apply uniformly across the entire length of the extracted Audio Clip.
* **Selected Range:** When an explicit timeline segment $[T_{\text{start}}, T_{\text{end}}]$ is highlighted by the user, DSP routines calculate changes exclusively inside those boundaries. Splice junctions automatically compute crossfades to mitigate transient click/pop anomalies.
---
## 2. Ruler-Based Tools
These utilities display as intuitive, linear slider scales (Sliders/Rulers) embedded in the top toolbar row:
```text
[ Normalize: |======o======| 0 dB ] [ Gain: |====o====| +3 dB ] [ Pitch: |==o==| -2 Semi ]
```
### 2.1. Peak Normalization
* **UI Layout:** A slide scale control allowing users to configure target amplitude thresholds variable from $-12\text{ dBFS}$ down to $0\text{ dBFS}$.
* **DSP Math Algorithm:** Locate the maximum absolute peak amplitude value $A_{\text{max}}$ within the targeted area, then multiply all active samples by a static scalar gain multiplier $G$:
$$G = \frac{10^{\frac{\text{Target\_dB}}{20}}}{A_{\text{max}}}$$
### 2.2. Volume Up / Down (Quick Gain)
* **UI Layout:** A linear sliding ruler modulating the overall absolute gain structure of the focused segment.
* **Operational Range:** Adjustable from $-\infty\text{ dB}$ (complete mute attenuation) up to $+12\text{ dB}$ of linear amplification.
### 2.3. Pitch Shifting
* **UI Layout:** A calibrated slider modifying the project's fundamental frequencies discrete in semitones or cents.
* **Operational Range:** Boundaries map from $-12\text{ semitones}$ (one octave down) to $+12\text{ semitones}$ (one octave up).
* **DSP Engine Routine:** Employs a spectral Phase Vocoder to shift frequencies without affecting the physical, real-time duration layout of the segment.
---
## 3. Graph-Based Fades
Fading curves overlay graphically directly onto the highlighted waveform canvas region, enabling precise boundary attenuation adjustments:
```text
Linear Fade-In Exponential Fade-Out
+───────────────────────────+ +───────────────────────────+
| /███████████████| |███████████\ |
| / ███████████████| |███████████ \ |
| / ███████████████| |███████████ \___ |
| / ███████████████| |███████████ \______|
+───────────────────────────+ +───────────────────────────+
|<──────── Fade-In ────────>| |<─────── Fade-Out ────────>|
```
* **Fade-In:** Multiplies an ascending amplitude ramp from $0.0$ to $1.0$ at the starting index profile of the selection region. Users can toggle between **Linear** or **Exponential** curves to achieve a smoother, more psychoacoustically natural volume build-up.
* **Fade-Out:** Multiplies a descending amplitude decay ramp from $1.0$ down to $0.0$ at the trailing boundary edge of the selection range.
---
## 4. Ruler Percentage Stretch Tool
A dedicated percentage metric scale control (`Ruler %`) sitting on the control toolbar dictates time-stretching and playback velocity parameters:
```text
[ Speed Stretch %: |========o========| 100% (Native) ] -> Range: 50% (Half Speed) - 200% (Double Speed)
```
* **Interaction Mapping:** Users drag the percentage slider node or hold down the `Alt` key and drag the rightmost boundary edge of the clip along the horizontal axis to change this scale metric.
* **Sync Formula:** Let $D$ map to the unscaled native duration value, and $D'$ map to the target modified duration footprint. The resulting structural playback speed ratio percentage ($S$) is given by:
$$S = \frac{D}{D'} \times 100\%$$
* **UI Representation:** A bright yellow text metadata indicator (e.g., `Speed: 85.3%`) is rendered at the top-left section of the audio clip bounding boundary.
---
## 5. Ultra-Zoom & Zero-Crossing Alignment
To facilitate precision structural slicing at sample-level resolutions, the sub-tab canvas allows microscopic viewport expansion:
```text
MICRO VIEWPORT ZOOM (ULTRA ZOOM-IN)
+─────────────────────────────────────────────────────────────────+
| Waveform renders discrete contiguous sample nodes explicitly |
| ○ (Sample i) |
| / \ |
| ─────────────────/───\─────────────────────────────► 0V Axis |
| \ ○ (Sample i+2) |
| \ / |
| \_○ (Sample i+1 - Zero-Crossing Point)|
+─────────────────────────────────────────────────────────────────+
```
* **Upper Viewport Scaling Limit:** Allows zooming in up to an extreme lower threshold of $2000\text{ pixels/second}$. At this zoom metric, layout compilation transitions away from downsampled peak profiles (Peak Waveform) to render actual discrete **sample nodes** interconnected by fine lines.
* **Zero-Line Snapping Logic:** When establishing selection boundaries, the tracking loop automatically snaps the horizontal selection cursor coordinate to the nearest available sample address exhibiting an algebraic phase inversion (sign change):
$$x[i] \cdot x[i+1] \le 0$$
---
## 6. Top Duration Timeline
Directly above the isolated sub-tab waveform canvas lane, a dedicated horizontal measuring ruler tracks clip timing data:
```text
| 0:00.000 | 0:01.000 | 0:02.000 | 0:03.000 | 0:04.000 (Duration: 4.152s)
+───────────────────────────────────────────────────────────────────────────────────────+
| [==================== VÙNG QUÉT CHỌN (RANGE SELECTION) ====================] |
+───────────────────────────────────────────────────────────────────────────────────────+
```
* **Total Duration Monitoring:** Renders the absolute, precise time extent of the isolated audio block in the right-hand corner of the timeline ruler layout (e.g., `Duration: 12.450s`).
* **Duration Selection Drag:** Left-clicking and dragging horizontally inside this top duration bar defines a highlighted selection overlay window. This range indicator automatically projects down into the waveform lane underneath.
---
## 7. Bottom Transport Panel
A prominent master transport toolbar occupies the bottom row layout of the sub-tab layout to manage audio playback monitoring:
```text
+───────────────────────────────────────────────────────────────────────────+
| [Back to Start] [Play] [Pause] [Stop] | Loop Sequence: [X] |
+───────────────────────────────────────────────────────────────────────────+
```
* **Back to Start:** Instantly updates the regional playhead time parameter back to the absolute starting point ($t = 0.0\text{ s}$).
* **Play / Pause / Stop:** Drives regional audio engine playback loops restricted entirely to the data buffers allocated inside the current sub-tab workspace.
* **Loop Toggle:** Toggles continuous cycle loops over the highlighted section or the whole clip.
---
## 8. Volume Automation Envelope (Pen Tool)
This advanced timeline automation layer allows audio designers to draw custom gain curves over the background waveform graphics.
```text
VOLUME AUTOMATION ENVELOPE (PEN TOOL)
+3 dB ──────────────────────────────────────────────────────────────
\ Node 1 Node 3
\ ○ ○
0 dB ───\────/─\─────────────────────────────────────/─\─────────── (0 dB Unity Gain Axis)
\ / \ / \
\/ \ / \
○ \_______________________________/ \________
Node 2 Node 4
-30 dB ──────────────────────────────────────────────────────────────
|<─────────────────── Horizontal Axis (Time) ─────────────────────>|
```
### 8.1. Pen Tool Interaction Mechanics
* **Activation:** Clicking the designated Pen Tool icon in the control panel modifies the pointer device presentation into a drawing crosshair or pencil graphic.
* **Envelope Initialization:** Activating the Pen Tool generates a solid horizontal neon green line representing $0\text{ dB}$ (Unity Gain) across the track workspace, acting as the baseline master axis.
* **Drawing Automation Curves:**
* Left-clicking anywhere along this line creates an adjustable anchor point (**Control Node**).
* Dragging an initialized control node upward increases signal amplitude (up to a maximal ceiling boundary of $+3\text{ dB}$).
* Dragging a control node downward reduces signal amplitude (down to a lower attenuation floor of $-30\text{ dB}$).
* The graphics framework automatically updates straight vector paths between sequential nodes utilizing simple linear interpolation.
### 8.2. DSP Volume Envelope Math
Given two chronologically adjacent drawn points $P_1(t_1, V_1)$ and $P_2(t_2, V_2)$, the targeted instantaneous decibel gain variable $V_{\text{dB}}(t)$ at an arbitrary time index $t$ ($t_1 \le t \le t_2$) matches the following linear equation:
$$V_{\text{dB}}(t) = V_1 + (t - t_1) \cdot \frac{V_2 - V_1}{t_2 - t_1}$$
This decibel value must be translated into a standard linear gain scalar coefficient $G_{\text{linear}}(t)$ to multiply it into the core audio sample stream values:
$$G_{\text{linear}}(t) = 10^{\frac{V_{\text{dB}}(t)}{20}}$$
$$x_{\text{automation}}[n] = x[n] \cdot G_{\text{linear}}\left( \frac{n}{\text{Sample Rate}} \right)$$
---
## 9. Python Porting Manual (Docker Server Platform)
When translating these graphical volume automation envelope features to a desktop PyQt6 interface or an asynchronous Celery Docker worker pipeline, the standard scientific function `numpy.interp` handles array vector scaling processing loops:
```python
import numpy as np
def apply_volume_automation_envelope(y: np.ndarray, sr: int, nodes: list) -> np.ndarray:
"""
Applies a user-drawn volume automation envelope onto an acoustic signal NumPy array.
nodes: A list of point dictionaries, e.g., [{"time": 0.0, "db": 0.0}, {"time": 2.5, "db": -12.0}, ...]
"""
if not nodes:
return y
# Sort envelope nodes chronologically by time axis
nodes = sorted(nodes, key=lambda x: x["time"])
# 1. Map node variables into distinct coordinates arrays
node_times = np.array([node["time"] for node in nodes])
node_dbs = np.array([node["db"] for node in nodes])
# Hard-clamp boundary constraints matching the operational floor [-30.0dB, +3.0dB]
node_dbs = np.clip(node_dbs, -30.0, 3.0)
# 2. Evaluate absolute timeline timestamps for every index position inside the signal array
total_samples = len(y)
sample_times = np.arange(total_samples) / sr
# 3. Linearly interpolate localized decibel thresholds across every single sample step
interpolated_dbs = np.interp(sample_times, node_times, node_dbs, left=node_dbs[0], right=node_dbs[-1])
# 4. Map logarithmic values into standard linear gain scale arrays
linear_gains = 10.0 ** (interpolated_dbs / 20.0)
# 5. Multiply the raw amplitude vector array by the linear gain modifier mask
return y * linear_gains
```
+616 -69
View File
@@ -667,8 +667,8 @@
); );
}; };
// ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2) ── // ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2 & SUB_EDITOR.md) ──
const SubTabWaveform = ({ buffer }) => { const SubTabWaveform = ({ buffer, subTabId, activeTab, currentTime, selectionStart, selectionEnd, onSelectRange, onPlayheadSet, onContextMenu }) => {
const canvasRef = useRef(null); const canvasRef = useRef(null);
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
@@ -689,25 +689,94 @@
const len = data.length; const len = data.length;
if (len === 0) return; if (len === 0) return;
ctx.strokeStyle = '#6ee7b7'; // Draw waveform
ctx.lineWidth = 1; ctx.fillStyle = '#6ee7b7';
const mid = h / 2;
for (let px = 0; px < w; px++) { for (let px = 0; px < w; px++) {
const start = Math.floor((px / w) * len); const start = Math.floor((px / w) * len);
const end = Math.floor(((px + 1) / w) * len); const end = Math.floor(((px + 1) / w) * len);
let maxVal = 0; let maxVal = 0;
let minVal = 0;
for (let i = start; i < end && i < len; i++) { for (let i = start; i < end && i < len; i++) {
const abs = Math.abs(data[i]); const val = data[i];
if (abs > maxVal) maxVal = abs; if (val > maxVal) maxVal = val;
if (val < minVal) minVal = val;
} }
const mid = h / 2; const yTop = mid + (minVal * (h * 0.45));
const peakHeight = maxVal * (h * 0.4); 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.beginPath();
ctx.moveTo(px, mid - peakHeight); ctx.moveTo(leftPx, 0); ctx.lineTo(leftPx, h);
ctx.lineTo(px, mid + peakHeight); ctx.moveTo(rightPx, 0); ctx.lineTo(rightPx, h);
ctx.stroke(); 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 = () => { const App = () => {
@@ -857,7 +926,7 @@
// ── Tab System (LOOP_EDITOR_2.md §1) ── // ── Tab System (LOOP_EDITOR_2.md §1) ──
const [activeTab, setActiveTab] = useState('main'); 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 const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2
// ── Temp Edit Tab (LOOP_EDITOR_2.md §1.2 Sub Tab) ── // ── Temp Edit Tab (LOOP_EDITOR_2.md §1.2 Sub Tab) ──
@@ -902,12 +971,276 @@
const selectedClipIdRef = useRef(null); const selectedClipIdRef = useRef(null);
selectedClipIdRef.current = selectedClipId; 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(() => { useEffect(() => {
const handler = (e) => { const handler = (e) => {
const ctrl = e.ctrlKey || e.metaKey; const ctrl = e.ctrlKey || e.metaKey;
const alt = e.altKey; 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 === '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 && (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 === 'o') { e.preventDefault(); showToast('Open Project dialog','info'); return; }
@@ -1048,6 +1381,10 @@
endTime: selRight, endTime: selRight,
buffer: subBuffer, buffer: subBuffer,
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0 }, effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0 },
currentTime: 0,
selectionStart: null,
selectionEnd: null,
isPlaying: false
}]); }]);
setActiveTab(tabId); setActiveTab(tabId);
showToast(`Đã tạo Sub Tab: ${tabLabel}. Chỉnh sửa và Apply để gộp lại.`, 'info'); 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 tabId = 'subtab_' + Date.now();
const tabLabel = `Edit_${clip.name.replace('.wav','').slice(0,10)}`; const tabLabel = `Edit_${clip.name.replace('.wav','').slice(0,10)}`;
setSubTabs(prev => [...prev, { setSubTabs(prev => [...prev, {
id: tabId, id: tabId,
label: tabLabel, label: tabLabel,
trackId: trackId, trackId: trackId,
clipId: clip.id === 'default' ? 'default_' + trackId : clip.id, clipId: clip.id === 'default' ? 'default_' + trackId : clip.id,
startTime: clip.startTime, startTime: clip.startTime,
endTime: clip.startTime + clip.buffer.duration, endTime: clip.startTime + clip.buffer.duration,
buffer: subBuffer, buffer: subBuffer,
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0 }, effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0 },
}]); currentTime: 0,
selectionStart: null,
selectionEnd: null,
isPlaying: false
}]);
setActiveTab(tabId); setActiveTab(tabId);
showToast(`Đã tạo Sub Tab: ${tabLabel}. Chỉnh sửa và Apply để gộp lại.`, 'info'); showToast(`Đã tạo Sub Tab: ${tabLabel}. Chỉnh sửa và Apply để gộp lại.`, 'info');
}; };
@@ -1692,6 +2033,73 @@
// ── Update Playhead ── // ── Update Playhead ──
const updatePlayhead = () => { 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; if (!isPlaying) return;
const context = getAudioContext(); const context = getAudioContext();
const elapsed = context.currentTime - startAudioTimeRef.current; const elapsed = context.currentTime - startAudioTimeRef.current;
@@ -1734,13 +2142,13 @@
}; };
useEffect(() => { useEffect(() => {
if (isPlaying) { if (isPlaying || subTabs.some(s => s.isPlaying)) {
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
} else { } else {
cancelAnimationFrame(animationFrameIdRef.current); cancelAnimationFrame(animationFrameIdRef.current);
} }
return () => cancelAnimationFrame(animationFrameIdRef.current); return () => cancelAnimationFrame(animationFrameIdRef.current);
}, [isPlaying, isLoopingSelection, selLeft, selRight, selectionMode, localSelectionTrackId, selectionCleared]); }, [isPlaying, subTabs, isLoopingSelection, selLeft, selRight, selectionMode, localSelectionTrackId, selectionCleared, activeTab]);
// ── Playback ── // ── Playback ──
const startTrackPlayback = (offsetTime) => { const startTrackPlayback = (offsetTime) => {
@@ -1836,6 +2244,66 @@
}; };
const handlePlayPause = () => { 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(); const context = getAudioContext();
if (isPlaying) { if (isPlaying) {
stopAllPlayback(); stopAllPlayback();
@@ -1849,7 +2317,7 @@
handlePlayPauseRef.current = handlePlayPause; handlePlayPauseRef.current = handlePlayPause;
const handlePause = () => { const handlePause = () => {
if (isPlaying) stopAllPlayback(); if (isPlaying || subTabs.some(s => s.isPlaying)) stopAllPlayback();
}; };
const stopAllPlayback = () => { const stopAllPlayback = () => {
@@ -1858,11 +2326,16 @@
}); });
activeSourcesRef.current = []; activeSourcesRef.current = [];
setIsPlaying(false); setIsPlaying(false);
setSubTabs(prev => prev.map(s => ({ ...s, isPlaying: false })));
}; };
const handleStop = () => { const handleStop = () => {
stopAllPlayback(); stopAllPlayback();
setCurrentTime(0); if (activeTab !== 'main') {
setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, currentTime: 0 } : s));
} else {
setCurrentTime(0);
}
}; };
// ── Selection ── // ── Selection ──
@@ -3845,7 +4318,29 @@
</button> </button>
</div> </div>
<div className="flex-1 flex flex-col p-3 gap-3 overflow-y-auto"> <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="grid grid-cols-4 gap-3 max-w-2xl">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-[9px] text-zinc-500 font-bold uppercase">Reverse</span> <span className="text-[9px] text-zinc-500 font-bold uppercase">Reverse</span>
@@ -3894,46 +4389,98 @@
{/* ── Context Menu ── */} {/* ── Context Menu ── */}
{contextMenu && ( {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 }} contextMenu.isSubTab ? (
onClick={(e) => e.stopPropagation()}> <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 }}
<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"> onClick={(e) => e.stopPropagation()}>
<i data-lucide="file-edit" className="w-3.5 h-3.5 text-amber-400 shrink-0"></i> <div className="px-3 py-1 text-[10px] text-zinc-500 font-bold uppercase border-b border-zinc-800 pb-1 mb-1">
<span className="flex-1">Edit</span> Selection: {formatTime(subTabs.find(s => s.id === contextMenu.subTabId)?.selectionStart || 0)} - {formatTime(subTabs.find(s => s.id === contextMenu.subTabId)?.selectionEnd || 0)}
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">Ctrl+E</span> </div>
</button> <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">
<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="maximize" className="w-3.5 h-3.5 text-amber-400 shrink-0"></i>
<i data-lucide="scissors" className="w-3.5 h-3.5 text-cyan-400 shrink-0"></i> <span className="flex-1">Normalize Selection To Peak</span>
<span className="flex-1">Split</span> </button>
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">S</span> <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">
</button> <i data-lucide="volume-2" className="w-3.5 h-3.5 text-amber-400 shrink-0"></i>
<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"> <span className="flex-1">Adjust Gain/Volume...</span>
<i data-lucide="combine" className="w-3.5 h-3.5 text-purple-400 shrink-0"></i> </button>
<span className="flex-1">Merge</span> <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">
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">Ctrl+M</span> <i data-lucide="chevrons-right" className="w-3.5 h-3.5 text-amber-400 shrink-0"></i>
</button> <span className="flex-1">Fade In (Linear)</span>
<div className="h-px bg-zinc-700 my-1"></div> </button>
<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"> <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="copy" className="w-3.5 h-3.5 text-zinc-400 shrink-0"></i> <i data-lucide="chevrons-left" className="w-3.5 h-3.5 text-amber-400 shrink-0"></i>
<span className="flex-1">Copy</span> <span className="flex-1">Fade Out (Linear)</span>
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">Ctrl+C</span> </button>
</button> <div className="h-px bg-zinc-700 my-1"></div>
<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"> <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> <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="flex-1">Cut</span>
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">Ctrl+X</span> <span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto">Ctrl+X</span>
</button> </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"> <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="clipboard" className="w-3.5 h-3.5 text-emerald-400 shrink-0"></i> <i data-lucide="copy" className="w-3.5 h-3.5 text-zinc-400 shrink-0"></i>
<span className="flex-1">Paste</span> <span className="flex-1">Copy</span>
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">Ctrl+V</span> <span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto">Ctrl+C</span>
</button> </button>
<div className="h-px bg-zinc-700 my-1"></div> <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">
<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="clipboard" className="w-3.5 h-3.5 text-emerald-400 shrink-0"></i>
<i data-lucide="trash-2" className="w-3.5 h-3.5 text-red-400 shrink-0"></i> <span className="flex-1">Paste</span>
<span className="flex-1">Delete</span> <span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto">Ctrl+V</span>
<span className="text-amber-400 text-[12px] font-semibold font-mono ml-auto pl-8">Del</span> </button>
</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">
</div> <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 ── */} {/* ── Toast ── */}
+616 -69
View File
@@ -667,8 +667,8 @@
); );
}; };
// ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2) ── // ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2 & SUB_EDITOR.md) ──
const SubTabWaveform = ({ buffer }) => { const SubTabWaveform = ({ buffer, subTabId, activeTab, currentTime, selectionStart, selectionEnd, onSelectRange, onPlayheadSet, onContextMenu }) => {
const canvasRef = useRef(null); const canvasRef = useRef(null);
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
@@ -689,25 +689,94 @@
const len = data.length; const len = data.length;
if (len === 0) return; if (len === 0) return;
ctx.strokeStyle = '#6ee7b7'; // Draw waveform
ctx.lineWidth = 1; ctx.fillStyle = '#6ee7b7';
const mid = h / 2;
for (let px = 0; px < w; px++) { for (let px = 0; px < w; px++) {
const start = Math.floor((px / w) * len); const start = Math.floor((px / w) * len);
const end = Math.floor(((px + 1) / w) * len); const end = Math.floor(((px + 1) / w) * len);
let maxVal = 0; let maxVal = 0;
let minVal = 0;
for (let i = start; i < end && i < len; i++) { for (let i = start; i < end && i < len; i++) {
const abs = Math.abs(data[i]); const val = data[i];
if (abs > maxVal) maxVal = abs; if (val > maxVal) maxVal = val;
if (val < minVal) minVal = val;
} }
const mid = h / 2; const yTop = mid + (minVal * (h * 0.45));
const peakHeight = maxVal * (h * 0.4); 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.beginPath();
ctx.moveTo(px, mid - peakHeight); ctx.moveTo(leftPx, 0); ctx.lineTo(leftPx, h);
ctx.lineTo(px, mid + peakHeight); ctx.moveTo(rightPx, 0); ctx.lineTo(rightPx, h);
ctx.stroke(); 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 = () => { const App = () => {
@@ -857,7 +926,7 @@
// ── Tab System (LOOP_EDITOR_2.md §1) ── // ── Tab System (LOOP_EDITOR_2.md §1) ──
const [activeTab, setActiveTab] = useState('main'); 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 const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2
// ── Temp Edit Tab (LOOP_EDITOR_2.md §1.2 Sub Tab) ── // ── Temp Edit Tab (LOOP_EDITOR_2.md §1.2 Sub Tab) ──
@@ -902,12 +971,276 @@
const selectedClipIdRef = useRef(null); const selectedClipIdRef = useRef(null);
selectedClipIdRef.current = selectedClipId; 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(() => { useEffect(() => {
const handler = (e) => { const handler = (e) => {
const ctrl = e.ctrlKey || e.metaKey; const ctrl = e.ctrlKey || e.metaKey;
const alt = e.altKey; 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 === '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 && (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 === 'o') { e.preventDefault(); showToast('Open Project dialog','info'); return; }
@@ -1048,6 +1381,10 @@
endTime: selRight, endTime: selRight,
buffer: subBuffer, buffer: subBuffer,
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0 }, effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0 },
currentTime: 0,
selectionStart: null,
selectionEnd: null,
isPlaying: false
}]); }]);
setActiveTab(tabId); setActiveTab(tabId);
showToast(`Đã tạo Sub Tab: ${tabLabel}. Chỉnh sửa và Apply để gộp lại.`, 'info'); 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 tabId = 'subtab_' + Date.now();
const tabLabel = `Edit_${clip.name.replace('.wav','').slice(0,10)}`; const tabLabel = `Edit_${clip.name.replace('.wav','').slice(0,10)}`;
setSubTabs(prev => [...prev, { setSubTabs(prev => [...prev, {
id: tabId, id: tabId,
label: tabLabel, label: tabLabel,
trackId: trackId, trackId: trackId,
clipId: clip.id === 'default' ? 'default_' + trackId : clip.id, clipId: clip.id === 'default' ? 'default_' + trackId : clip.id,
startTime: clip.startTime, startTime: clip.startTime,
endTime: clip.startTime + clip.buffer.duration, endTime: clip.startTime + clip.buffer.duration,
buffer: subBuffer, buffer: subBuffer,
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0 }, effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0 },
}]); currentTime: 0,
selectionStart: null,
selectionEnd: null,
isPlaying: false
}]);
setActiveTab(tabId); setActiveTab(tabId);
showToast(`Đã tạo Sub Tab: ${tabLabel}. Chỉnh sửa và Apply để gộp lại.`, 'info'); showToast(`Đã tạo Sub Tab: ${tabLabel}. Chỉnh sửa và Apply để gộp lại.`, 'info');
}; };
@@ -1692,6 +2033,73 @@
// ── Update Playhead ── // ── Update Playhead ──
const updatePlayhead = () => { 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; if (!isPlaying) return;
const context = getAudioContext(); const context = getAudioContext();
const elapsed = context.currentTime - startAudioTimeRef.current; const elapsed = context.currentTime - startAudioTimeRef.current;
@@ -1734,13 +2142,13 @@
}; };
useEffect(() => { useEffect(() => {
if (isPlaying) { if (isPlaying || subTabs.some(s => s.isPlaying)) {
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
} else { } else {
cancelAnimationFrame(animationFrameIdRef.current); cancelAnimationFrame(animationFrameIdRef.current);
} }
return () => cancelAnimationFrame(animationFrameIdRef.current); return () => cancelAnimationFrame(animationFrameIdRef.current);
}, [isPlaying, isLoopingSelection, selLeft, selRight, selectionMode, localSelectionTrackId, selectionCleared]); }, [isPlaying, subTabs, isLoopingSelection, selLeft, selRight, selectionMode, localSelectionTrackId, selectionCleared, activeTab]);
// ── Playback ── // ── Playback ──
const startTrackPlayback = (offsetTime) => { const startTrackPlayback = (offsetTime) => {
@@ -1836,6 +2244,66 @@
}; };
const handlePlayPause = () => { 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(); const context = getAudioContext();
if (isPlaying) { if (isPlaying) {
stopAllPlayback(); stopAllPlayback();
@@ -1849,7 +2317,7 @@
handlePlayPauseRef.current = handlePlayPause; handlePlayPauseRef.current = handlePlayPause;
const handlePause = () => { const handlePause = () => {
if (isPlaying) stopAllPlayback(); if (isPlaying || subTabs.some(s => s.isPlaying)) stopAllPlayback();
}; };
const stopAllPlayback = () => { const stopAllPlayback = () => {
@@ -1858,11 +2326,16 @@
}); });
activeSourcesRef.current = []; activeSourcesRef.current = [];
setIsPlaying(false); setIsPlaying(false);
setSubTabs(prev => prev.map(s => ({ ...s, isPlaying: false })));
}; };
const handleStop = () => { const handleStop = () => {
stopAllPlayback(); stopAllPlayback();
setCurrentTime(0); if (activeTab !== 'main') {
setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, currentTime: 0 } : s));
} else {
setCurrentTime(0);
}
}; };
// ── Selection ── // ── Selection ──
@@ -3845,7 +4318,29 @@
</button> </button>
</div> </div>
<div className="flex-1 flex flex-col p-3 gap-3 overflow-y-auto"> <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="grid grid-cols-4 gap-3 max-w-2xl">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-[9px] text-zinc-500 font-bold uppercase">Reverse</span> <span className="text-[9px] text-zinc-500 font-bold uppercase">Reverse</span>
@@ -3894,46 +4389,98 @@
{/* ── Context Menu ── */} {/* ── Context Menu ── */}
{contextMenu && ( {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 }} contextMenu.isSubTab ? (
onClick={(e) => e.stopPropagation()}> <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 }}
<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"> onClick={(e) => e.stopPropagation()}>
<i data-lucide="file-edit" className="w-3.5 h-3.5 text-amber-400 shrink-0"></i> <div className="px-3 py-1 text-[10px] text-zinc-500 font-bold uppercase border-b border-zinc-800 pb-1 mb-1">
<span className="flex-1">Edit</span> Selection: {formatTime(subTabs.find(s => s.id === contextMenu.subTabId)?.selectionStart || 0)} - {formatTime(subTabs.find(s => s.id === contextMenu.subTabId)?.selectionEnd || 0)}
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">Ctrl+E</span> </div>
</button> <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">
<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="maximize" className="w-3.5 h-3.5 text-amber-400 shrink-0"></i>
<i data-lucide="scissors" className="w-3.5 h-3.5 text-cyan-400 shrink-0"></i> <span className="flex-1">Normalize Selection To Peak</span>
<span className="flex-1">Split</span> </button>
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">S</span> <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">
</button> <i data-lucide="volume-2" className="w-3.5 h-3.5 text-amber-400 shrink-0"></i>
<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"> <span className="flex-1">Adjust Gain/Volume...</span>
<i data-lucide="combine" className="w-3.5 h-3.5 text-purple-400 shrink-0"></i> </button>
<span className="flex-1">Merge</span> <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">
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">Ctrl+M</span> <i data-lucide="chevrons-right" className="w-3.5 h-3.5 text-amber-400 shrink-0"></i>
</button> <span className="flex-1">Fade In (Linear)</span>
<div className="h-px bg-zinc-700 my-1"></div> </button>
<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"> <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="copy" className="w-3.5 h-3.5 text-zinc-400 shrink-0"></i> <i data-lucide="chevrons-left" className="w-3.5 h-3.5 text-amber-400 shrink-0"></i>
<span className="flex-1">Copy</span> <span className="flex-1">Fade Out (Linear)</span>
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">Ctrl+C</span> </button>
</button> <div className="h-px bg-zinc-700 my-1"></div>
<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"> <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> <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="flex-1">Cut</span>
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">Ctrl+X</span> <span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto">Ctrl+X</span>
</button> </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"> <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="clipboard" className="w-3.5 h-3.5 text-emerald-400 shrink-0"></i> <i data-lucide="copy" className="w-3.5 h-3.5 text-zinc-400 shrink-0"></i>
<span className="flex-1">Paste</span> <span className="flex-1">Copy</span>
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">Ctrl+V</span> <span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto">Ctrl+C</span>
</button> </button>
<div className="h-px bg-zinc-700 my-1"></div> <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">
<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="clipboard" className="w-3.5 h-3.5 text-emerald-400 shrink-0"></i>
<i data-lucide="trash-2" className="w-3.5 h-3.5 text-red-400 shrink-0"></i> <span className="flex-1">Paste</span>
<span className="flex-1">Delete</span> <span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto">Ctrl+V</span>
<span className="text-amber-400 text-[12px] font-semibold font-mono ml-auto pl-8">Del</span> </button>
</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">
</div> <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 ── */} {/* ── Toast ── */}