diff --git a/13_SUBTAB_EDITOR.md b/13_SUBTAB_EDITOR.md new file mode 100644 index 0000000..8c39bf8 --- /dev/null +++ b/13_SUBTAB_EDITOR.md @@ -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 + +``` \ No newline at end of file diff --git a/app/templates/index.html b/app/templates/index.html index fe0f6ba..f8b51c4 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -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 ; + + // 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 ( + + ); }; 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 @@