fix: vẽ volume graph

This commit is contained in:
2026-07-19 20:20:26 +07:00
parent 32fab9d366
commit 4ca90f15ca
2 changed files with 905 additions and 59 deletions
+167
View File
@@ -0,0 +1,167 @@
# Technical Specification: Mapping Matrix & Interactive Graphical Rendering Algorithms
This document defines the interactive real-time non-linear curves system showcased.
---
## 1. UI Element Mapping Matrix
To upgrade the interface workflow from Image 1 to Image 2, a 1-to-1 mapping of graphical components is executed based on the structural breakdown below:
| --- | --- | --- |
| **Horizontal blue bar at the top** (Contains a node chain representing the default $0\text{ dB}$ volume level) | Multi-point peach-colored automation spine (**Automation Spline**) overlaying the waveform viewport area. | * **Double-click** anywhere along the spline to generate a new control node.<br>
<br>
<br>* **Click & Drag** a node vertically to scale Volume (Gain), or horizontally to adjust its chronological time position. |
| **"FI" text label** in the upper-left corner | Deep red arched **Fade-In Bezier Curve** smoothing the volume transition from $0\%$ up to $100\%$. | * **Click & Hold** the "FI" handle and drag rightward to increase the target Fade-In length ($L_{\text{fade\_in}}$). This action automatically projects a smooth curve overlay on top of the waveform graphic. |
| **"FO" text label** in the upper-right corner | Deep red arched **Fade-Out Bezier Curve** decaying the volume envelope from $100\%$ down to $0\%$ at the end of the clip boundary. | * **Click & Hold** the "FO" handle and drag leftward to increase the target Fade-Out length ($L_{\text{fade\_out}}$). The inverse curve automatically stretches or compresses based on the active dragging cursor coordinates. |
| **"VOL" button** in the lower-right corner | **Graphical Envelope Mode Switcher** (Toggles automation layer matrices). | * **Click** to hot-swap between multiple interactive graphs: Volume (VOL) (peach curve), Panning (PAN) (L/R Stereo Image automation trajectory), or FX Send grids. |
---
## 2. Non-Linear Graphical Curve Rendering Algorithms (Image 2)
### 2.1. Multi-Point Volume Automation Curves (Smooth Monotone Spline)
To ensure the interpolating paths connecting the peach-colored nodes in Image 2 are curved smoothly without generating sharp angular peaks, the framework runs a **Monotone Cubic Hermite Spline** interpolation algorithm.
Given two chronologically consecutive control nodes $P_a(x_a, y_a)$ and $P_b(x_b, y_b)$, an arbitrary absolute timeline position $x$ is normalized into a relative horizontal index interval $t$:
$$t = \frac{x - x_a}{x_b - x_a} \quad (0 \le t \le 1)$$
The target interpolated amplitude value $y(x)$ at position $x$ is evaluated using the cubic polynomial equation:
$$y(x) = (2t^3 - 3t^2 + 1)y_a + (t^3 - 2t^2 + t)h \cdot m_a + (-2t^3 + 3t^2)y_b + (t^3 - t^2)h \cdot m_b$$
Where: $h = x_b - x_a$, and $m_a, m_b$ correspond to the localized slopes (tangents) computed from adjacent surrounding node coordinates. This constraint ensures strict monotonicity to eliminate graphical or mathematical overshoot anomalies.
### 2.2. Fade Curve Contours (Fade-In & Fade-Out)
The physical curvature profile of the two deep red envelopes in Image 2 is evaluated using a trigonometric Cosine S-Curve or a 3rd-order Cubic Bezier equation framework:
* **Trigonometric Cosine Fade-In Curve** (Across a duration bound of $L_{\text{fade\_in}}$ seconds):
$$f_{\text{in}}(t) = \frac{1 - \cos\left( \pi \cdot \frac{t}{L_{\text{fade\_in}}} \right)}{2} \quad \left( 0 \le t \le L_{\text{fade\_in}} \right)$$
* **Trigonometric Cosine Fade-Out Curve** (Across a trailing termination window of $L_{\text{fade\_out}}$ seconds):
$$f_{\text{out}}(t) = \frac{1 + \cos\left( \pi \cdot \frac{t - (T_{\text{max}} - L_{\text{fade\_out}})}{L_{\text{fade\_out}}} \right)}{2} \quad \left( T_{\text{max}} - L_{\text{fade\_out}} \le t \le T_{\text{max}} \right)$$
---
## 3. Client-Side Runtime Integration (HTML5 Canvas Engine)
To make the static canvas layer from Image 1 respond fluidly to drag gestures like the interactive system in Image 2, the painting routine segregates graphic elements into distinct presentation layers, driven inside a low-latency `requestAnimationFrame` render loop:
```javascript
/**
* Renders non-linear Fade-In and Fade-Out curves over the Waveform canvas viewport.
* @param {CanvasRenderingContext2D} ctx - Target 2D rendering canvas context.
* @param {number} width - Total physical viewport tracking pixel width.
* @param {number} height - Total physical viewport tracking pixel height.
* @param {number} fadeInSec - Bounding target Fade-In duration in seconds.
* @param {number} fadeOutSec - Bounding target Fade-Out duration in seconds.
* @param {number} zoom - Current layout pixel compression scaling factor (pixels/second).
*/
function drawFadeCurves(ctx, width, height, fadeInSec, fadeOutSec, zoom) {
const fadeInWidth = fadeInSec * zoom;
const fadeOutWidth = fadeOutSec * zoom;
const midY = height / 2;
ctx.strokeStyle = '#800000'; // Professional dark deep red hue theme
ctx.lineWidth = 1.8;
// 1. Compile the non-linear Fade-In curve polyline
if (fadeInWidth > 0) {
ctx.beginPath();
for (let x = 0; x <= fadeInWidth; x++) {
const ratio = x / fadeInWidth;
// Apply trigonometric cosine to map curved vertical y-coordinates
const amp = (1 - Math.cos(Math.PI * ratio)) / 2;
const y = height - (amp * height); // Apply envelope tracking from bottom to top
if (x === 0) ctx.moveTo(x, height);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
// 2. Compile the non-linear Fade-Out curve polyline
if (fadeOutWidth > 0) {
ctx.beginPath();
const startX = width - fadeOutWidth;
for (let x = 0; x <= fadeOutWidth; x++) {
const ratio = x / fadeOutWidth;
const amp = (1 + Math.cos(Math.PI * ratio)) / 2;
const y = height - (amp * height);
if (x === 0) ctx.moveTo(startX + x, 0);
else ctx.lineTo(startX + x, y);
}
ctx.stroke();
}
}
```
---
## 4. Server-Side DSP Automation Processing (Dockerized Python Engine)
When an operator commits tracking edits via the Frontend client layer, the mapped coordinates are encoded as a serialized JSON package and transferred down to the FastAPI server gateway. The Python core layer runs performance-optimized, vectorized array loops inside NumPy to multiply envelope filters straight into the raw source data buffer matrices:
```python
import numpy as np
class DSPAutomationProcessor:
@staticmethod
def apply_curves_to_samples(
y: np.ndarray,
sr: int,
fade_in_sec: float,
fade_out_sec: float,
automation_points: list # [{"time": 0.5, "db": -3.0}, ...]
) -> np.ndarray:
"""
Bakes multi-point Volume Automation splines and non-linear fade curves
directly onto a raw acoustic sample NumPy array.
"""
total_samples = len(y)
duration_sec = total_samples / sr
# 1. Initialize the baseline Gain Envelope at Unity Gain (1.0 or 0 dB)
gain_envelope = np.ones(total_samples, dtype=np.float32)
# 2. Evaluate Volume Automation scaling paths (Peach-colored nodes in Image 2)
if automation_points and len(automation_points) > 0:
points = sorted(automation_points, key=lambda x: x["time"])
xp = [p["time"] for p in points]
fp = [10.0 ** (p["db"] / 20.0) for p in points] # Map decibel factors to linear scalars
# Linearly interpolate point values quickly across the full timeline width
times = np.linspace(0, duration_sec, total_samples)
gain_envelope = np.interp(times, xp, fp)
# 3. Multiply the introductory Fade-In envelope (Cosine transition mask at Image 2 boundary)
if fade_in_sec > 0:
fade_in_samples = min(total_samples, int(fade_in_sec * sr))
x_fade = np.linspace(0, np.pi, fade_in_samples)
cosine_ramp = (1.0 - np.cos(x_fade)) / 2.0
gain_envelope[:fade_in_samples] *= cosine_ramp
# 4. Multiply the trailing Fade-Out envelope (Cosine decay mask at Image 2 boundary)
if fade_out_sec > 0:
fade_out_samples = min(total_samples, int(fade_out_sec * sr))
x_fade = np.linspace(0, np.pi, fade_out_samples)
cosine_ramp = (1.0 + np.cos(x_fade)) / 2.0
gain_envelope[-fade_out_samples:] *= cosine_ramp
# 5. Execute vectorized element-wise multiplication into raw audio values
return y * gain_envelope
```
+723 -44
View File
@@ -403,7 +403,9 @@
const hoveredClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); const hoveredClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
const isOverClip = !!hoveredClip; const isOverClip = !!hoveredClip;
if (activeTool === 'grab') { if (activeTool === 'pen') {
canvasRef.current.style.cursor = isOverClip ? 'copy' : 'not-allowed';
} else if (activeTool === 'grab') {
canvasRef.current.style.cursor = isOverClip ? 'grab' : 'default'; canvasRef.current.style.cursor = isOverClip ? 'grab' : 'default';
} else if (activeTool === 'razor') { } else if (activeTool === 'razor') {
canvasRef.current.style.cursor = isOverClip ? 'cell' : 'not-allowed'; canvasRef.current.style.cursor = isOverClip ? 'cell' : 'not-allowed';
@@ -475,6 +477,17 @@
setSelectedClipId(null); setSelectedClipId(null);
} }
if (activeTool === 'pen') {
if (clickedClip) {
e.preventDefault();
e.stopPropagation();
if (onEditClipInSubTab) {
onEditClipInSubTab(track.id, clickedClip.id);
}
}
return;
}
if (activeTool === 'razor') { if (activeTool === 'razor') {
if (clickedClip) { if (clickedClip) {
e.preventDefault(); e.preventDefault();
@@ -668,7 +681,7 @@
}; };
// ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2 & SUB_EDITOR.md) ── // ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2 & SUB_EDITOR.md) ──
const SubTabWaveform = ({ buffer, subTabId, activeTab, currentTime, selectionStart, selectionEnd, onSelectRange, onPlayheadSet, onContextMenu, activeTool, zoom, timelineWidth, color, name, speed = 1.0, onSpeedChange }) => { const SubTabWaveform = ({ buffer, subTabId, activeTab, currentTime, selectionStart, selectionEnd, onSelectRange, onPlayheadSet, onContextMenu, activeTool, zoom, timelineWidth, color, name, speed = 1.0, onSpeedChange, volumeNodes = [], panningNodes = [], fadeInLen = 0, fadeOutLen = 0, graphMode = null, onUpdateNodes, onUpdateFade, onModeToggle }) => {
const canvasRef = useRef(null); const canvasRef = useRef(null);
const isStretchingRef = useRef(false); const isStretchingRef = useRef(false);
const stretchStartRef = useRef({ mouseX: 0, originalDuration: 0, originalSpeed: 1.0 }); const stretchStartRef = useRef({ mouseX: 0, originalDuration: 0, originalSpeed: 1.0 });
@@ -722,10 +735,90 @@
} }
ctx.fillText(displayName, xStart + 8, clipTop + 14); ctx.fillText(displayName, xStart + 8, clipTop + 14);
// Draw waveform inside clip (speed-adjusted) // ── Graph Grid & Axes ──
const drawGrid = true;
if (drawGrid) {
// Background fill
ctx.fillStyle = '#181818';
ctx.fillRect(xStart, clipTop, wClip, clipHeight);
// Vertical grid lines (time markers)
ctx.strokeStyle = '#2a2a2a';
ctx.lineWidth = 0.5;
ctx.setLineDash([]);
const timeStep = Math.max(0.1, Math.ceil((buffer.duration / 20) * 10) / 10);
for (let t = 0; t <= buffer.duration; t += timeStep) {
const px = t * zoom;
ctx.beginPath(); ctx.moveTo(px, clipTop); ctx.lineTo(px, clipTop + clipHeight); ctx.stroke();
}
// Horizontal grid lines (value markers)
const isPanMode = graphMode === 'pan';
if (isPanMode) {
for (let p = -100; p <= 100; p += 20) {
const y = clipTop + clipHeight * (1 - (p / 100 + 1) / 2);
ctx.strokeStyle = p === 0 ? '#a855f766' : '#2a2a2a';
ctx.lineWidth = p === 0 ? 1 : 0.5;
ctx.beginPath(); ctx.moveTo(xStart, y); ctx.lineTo(xStart + wClip, y); ctx.stroke();
}
} else {
const volZeroY = clipTop + (2/3) * clipHeight;
for (let db = -30; db <= 3; db += 3) {
const y = db >= 0
? volZeroY - (db / 3) * (2/3 * clipHeight)
: volZeroY + (-db / 30) * (1/3 * clipHeight);
ctx.strokeStyle = db === 0 ? '#ef444466' : '#2a2a2a';
ctx.lineWidth = db === 0 ? 1 : 0.5;
ctx.beginPath(); ctx.moveTo(xStart, y); ctx.lineTo(xStart + wClip, y); ctx.stroke();
}
}
// Y-axis labels (left side)
ctx.fillStyle = '#71717a';
ctx.font = '7px monospace';
ctx.textAlign = 'right';
if (isPanMode) {
ctx.fillText('L100', xStart - 2, clipTop + 8);
ctx.fillText('R50', xStart - 2, clipTop + clipHeight * 0.25 + 2);
ctx.fillText('C', xStart - 2, clipTop + clipHeight * 0.5 + 2);
ctx.fillText('L50', xStart - 2, clipTop + clipHeight * 0.75 + 2);
ctx.fillText('R100', xStart - 2, clipTop + clipHeight - 2);
} else {
const volZeroY = clipTop + (2/3) * clipHeight;
ctx.fillText('+3dB', xStart - 2, clipTop + 8);
ctx.fillText('0dB', xStart - 2, volZeroY + 2);
ctx.fillText('-15dB', xStart - 2, volZeroY + clipHeight / 6 + 2);
ctx.fillText('-30dB', xStart - 2, clipTop + clipHeight - 2);
}
ctx.textAlign = 'start';
}
// Drag hint labels for fade endpoints
ctx.fillStyle = '#71717a';
ctx.font = '8px sans-serif';
ctx.fillText('Kéo FI/FO trên đường cong để điều chỉnh', 8, clipTop + clipHeight + 12);
// Mode toggle button on waveform (bottom-right)
const modeBtnW = 28;
const modeBtnH = 14;
const modeBtnX = wClip - modeBtnW - 4;
const modeBtnY = clipTop + clipHeight - modeBtnH - 2;
const isPanMode = graphMode === 'pan';
ctx.fillStyle = isPanMode ? 'rgba(168, 85, 247, 0.5)' : 'rgba(6, 182, 212, 0.5)';
ctx.beginPath();
ctx.roundRect(modeBtnX, modeBtnY, modeBtnW, modeBtnH, 3);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = 'bold 7px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(isPanMode ? 'PAN' : 'VOL', modeBtnX + modeBtnW / 2, modeBtnY + 10);
ctx.textAlign = 'start';
// Draw waveform inside clip (speed-adjusted) with fade envelope applied
const drawXStart = Math.max(0, Math.floor(xStart)); const drawXStart = Math.max(0, Math.floor(xStart));
const drawXEnd = Math.min(w, Math.ceil(xEnd)); const drawXEnd = Math.min(w, Math.ceil(xEnd));
const samplesPerPixel = (buffer.sampleRate / zoom) * speed; const samplesPerPixel = (buffer.sampleRate / zoom) * speed;
const bufDur = buffer.duration;
ctx.strokeStyle = '#6ee7b7'; ctx.strokeStyle = '#6ee7b7';
ctx.lineWidth = 1; ctx.lineWidth = 1;
@@ -738,6 +831,15 @@
const chunkStart = Math.max(0, sampleIdx - Math.floor(chunkSize / 2)); const chunkStart = Math.max(0, sampleIdx - Math.floor(chunkSize / 2));
const chunkEnd = Math.min(len, chunkStart + chunkSize); const chunkEnd = Math.min(len, chunkStart + chunkSize);
// Compute fade gain at this position
let fadeGain = 1;
if (fadeInLen > 0 && timeInClip < fadeInLen) {
fadeGain = (1 - Math.cos(Math.PI * timeInClip / fadeInLen)) / 2;
} else if (fadeOutLen > 0 && timeInClip > bufDur - fadeOutLen) {
const t = (timeInClip - (bufDur - fadeOutLen)) / fadeOutLen;
fadeGain = (1 + Math.cos(Math.PI * t)) / 2;
}
let maxVal = 0; let maxVal = 0;
let minVal = 0; let minVal = 0;
for (let i = chunkStart; i < chunkEnd; i++) { for (let i = chunkStart; i < chunkEnd; i++) {
@@ -745,6 +847,8 @@
if (val > maxVal) maxVal = val; if (val > maxVal) maxVal = val;
if (val < minVal) minVal = val; if (val < minVal) minVal = val;
} }
maxVal *= fadeGain;
minVal *= fadeGain;
const yTop = mid + (minVal * (clipHeight * 0.45)); const yTop = mid + (minVal * (clipHeight * 0.45));
const yBottom = mid + (maxVal * (clipHeight * 0.45)); const yBottom = mid + (maxVal * (clipHeight * 0.45));
ctx.beginPath(); ctx.beginPath();
@@ -753,6 +857,154 @@
ctx.stroke(); ctx.stroke();
} }
// ── Graph Editor Automation overlay (Monotone Cubic Hermite Spline ──
// Volume: 0dB at 2/3 from bottom (piecewise for visual emphasis)
const autoY = (node) => {
const db = typeof node === 'number' ? node : node.db;
const zeroY = clipTop + (2/3) * clipHeight;
return db >= 0
? zeroY - (db / 3) * (2/3 * clipHeight)
: zeroY + (-db / 30) * (1/3 * clipHeight);
};
// Panning: 0 at center
const autoPanY = (node) => {
const pan = typeof node === 'number' ? node : node.pan;
return clipTop + ((1 - (pan + 1) / 2) * clipHeight);
};
// Helper: compute tangents for monotone Hermite spline
const computeTangents = (pts, yFn) => {
const n = pts.length;
if (n < 2) return [];
const m = new Array(n);
for (let i = 1; i < n - 1; i++) {
const hP = pts[i].time - pts[i-1].time;
const hN = pts[i+1].time - pts[i].time;
const sP = (yFn(pts[i]) - yFn(pts[i-1])) / hP;
const sN = (yFn(pts[i+1]) - yFn(pts[i])) / hN;
m[i] = (sP + sN) / 2;
}
m[0] = n > 1 ? (yFn(pts[1]) - yFn(pts[0])) / (pts[1].time - pts[0].time) : 0;
m[n-1] = n > 1 ? (yFn(pts[n-1]) - yFn(pts[n-2])) / (pts[n-1].time - pts[n-2].time) : 0;
return m;
};
// Helper: evaluate Hermite at pixel position px
const hermiteY = (px, x0, y0, m0, x1, y1, m1) => {
const h = x1 - x0;
if (h <= 0) return y0;
const t = (px - x0) / h;
const t2 = t * t, t3 = t2 * t;
return (2*t3 - 3*t2 + 1) * y0 + (t3 - 2*t2 + t) * h * m0 + (-2*t3 + 3*t2) * y1 + (t3 - t2) * h * m1;
};
// Draw automation curve with Hermite spline
const drawSpline = (nodes, yFn, color, lineDash) => {
if (nodes.length < 2) {
if (nodes.length === 1) {
const px = nodes[0].time * zoom;
const y = yFn(nodes[0]);
ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(px, y, 4, 0, Math.PI * 2); ctx.fill();
}
return;
}
const tangents = computeTangents(nodes, yFn);
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.setLineDash(lineDash || []);
ctx.beginPath();
for (let i = 0; i < nodes.length - 1; i++) {
const x0 = nodes[i].time * zoom, y0 = yFn(nodes[i]);
const x1 = nodes[i+1].time * zoom, y1 = yFn(nodes[i+1]);
const m0 = tangents[i], m1 = tangents[i+1];
for (let px = Math.floor(x0); px < Math.ceil(x1); px++) {
const y = hermiteY(px, x0, y0, m0, x1, y1, m1);
if (px === 0 && i === 0) ctx.moveTo(px, y); else ctx.lineTo(px, y);
}
}
ctx.stroke();
ctx.setLineDash([]);
// Draw node handles
nodes.forEach(n => {
const px = n.time * zoom, y = yFn(n);
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(px, y, 4, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(px, y, 4, 0, Math.PI * 2); ctx.stroke();
});
};
drawSpline(volumeNodes, autoY, '#f97316');
drawSpline(panningNodes, autoPanY, '#a855f7', [4, 4]);
// ── Fade Curves (transparent, only curve lines + endpoint handles) ──
const FADE_COLOR = '#b91c1c';
const HANDLE_RADIUS = 5;
if (fadeInLen > 0) {
const fiPx = fadeInLen * zoom;
ctx.strokeStyle = FADE_COLOR;
ctx.lineWidth = 1.8;
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(0, clipTop + clipHeight);
for (let px = 0; px <= fiPx; px++) {
const ratio = px / fiPx;
const amp = (1 - Math.cos(Math.PI * ratio)) / 2;
const y = clipTop + clipHeight - amp * clipHeight;
ctx.lineTo(px, y);
}
ctx.stroke();
// Endpoint handle (draggable)
const endX = fiPx, endY = clipTop;
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(endX, endY, HANDLE_RADIUS, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = FADE_COLOR;
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(endX, endY, HANDLE_RADIUS, 0, Math.PI * 2); ctx.stroke();
// Label
ctx.fillStyle = '#b91c1c';
ctx.font = 'bold 7px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('FI', endX, endY - HANDLE_RADIUS - 3);
ctx.textAlign = 'start';
}
if (fadeOutLen > 0) {
const foPx = fadeOutLen * zoom;
const startX = (buffer.duration - fadeOutLen) * zoom;
ctx.strokeStyle = FADE_COLOR;
ctx.lineWidth = 1.8;
ctx.setLineDash([]);
ctx.beginPath();
ctx.moveTo(startX, clipTop);
for (let px = 0; px <= foPx; px++) {
const ratio = px / foPx;
const amp = (1 + Math.cos(Math.PI * ratio)) / 2;
const y = clipTop + clipHeight - amp * clipHeight;
ctx.lineTo(startX + px, y);
}
ctx.stroke();
// Endpoint handle (draggable)
const handleX = startX, handleY = clipTop;
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(handleX, handleY, HANDLE_RADIUS, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = FADE_COLOR;
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(handleX, handleY, HANDLE_RADIUS, 0, Math.PI * 2); ctx.stroke();
ctx.fillStyle = '#b91c1c';
ctx.font = 'bold 7px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('FO', handleX, handleY - HANDLE_RADIUS - 3);
ctx.textAlign = 'start';
}
// Draw right-edge stretch handle indicator // Draw right-edge stretch handle indicator
if (wClip > 0 && wClip < w) { if (wClip > 0 && wClip < w) {
ctx.strokeStyle = clipColor; ctx.strokeStyle = clipColor;
@@ -792,7 +1044,7 @@
ctx.stroke(); ctx.stroke();
} }
}, [buffer, currentTime, selectionStart, selectionEnd, zoom, timelineWidth, color, name, speed]); }, [buffer, currentTime, selectionStart, selectionEnd, zoom, timelineWidth, color, name, speed, volumeNodes, panningNodes, fadeInLen, fadeOutLen, graphMode]);
const handleMouseDown = (e) => { const handleMouseDown = (e) => {
if (e.button === 2) return; if (e.button === 2) return;
@@ -841,6 +1093,47 @@
} }
} }
// Mode toggle button click (bottom-right VOL/PAN)
const wClipPx = (bufDuration / (speed || 1.0)) * zoom;
const cTop = 8;
const cSize = 16;
const cHeight = rect.height - 16;
const modeBtnX = wClipPx - 28 - 4;
const modeBtnY = cTop + cHeight - 14 - 2;
if (mouseX >= modeBtnX && mouseX <= modeBtnX + 28 && (e.clientY - rect.top) >= modeBtnY && (e.clientY - rect.top) <= modeBtnY + 14) {
if (onModeToggle) onModeToggle();
return;
}
// Fade endpoint handle drag (click on FI/FO handle circles)
const HANDLE_R = 5;
const fiEndPx = fadeInLen > 0 ? fadeInLen * zoom : -100;
const foStartPx = fadeOutLen > 0 ? (bufDuration - fadeOutLen) * zoom : -100;
const distToFiHandle = Math.abs(mouseX - fiEndPx) + Math.abs((e.clientY - rect.top) - cTop);
const distToFoHandle = Math.abs(mouseX - foStartPx) + Math.abs((e.clientY - rect.top) - cTop);
if (fadeInLen > 0 && distToFiHandle <= HANDLE_R + 6) {
const handleMouseMove = (moveEvent) => {
const x = moveEvent.clientX - rect.left + scrollLeft;
const t = Math.max(0, Math.min(bufDuration, x / zoom));
if (onUpdateFade) onUpdateFade({ fadeInLen: t });
};
const handleMouseUp = () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); };
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return;
}
if (fadeOutLen > 0 && distToFoHandle <= HANDLE_R + 6) {
const handleMouseMove = (moveEvent) => {
const x = moveEvent.clientX - rect.left + scrollLeft;
const t = Math.max(0, Math.min(bufDuration, (wClipPx - x) / zoom));
if (onUpdateFade) onUpdateFade({ fadeOutLen: t });
};
const handleMouseUp = () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); };
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return;
}
// Tool-specific behavior // Tool-specific behavior
if (activeTool === 'grab') { if (activeTool === 'grab') {
onPlayheadSet(startTime); onPlayheadSet(startTime);
@@ -868,26 +1161,124 @@
} }
if (activeTool === 'pen') { if (activeTool === 'pen') {
// Deduplicate by time: keep last occurrence per time key
const mergeNodes = (existing, incoming) => {
const map = new Map();
existing.forEach(n => map.set(n.time, n));
incoming.forEach(n => map.set(n.time, n));
return Array.from(map.values()).sort((a, b) => a.time - b.time);
};
onPlayheadSet(startTime); onPlayheadSet(startTime);
canvas.style.cursor = 'crosshair'; canvas.style.cursor = 'crosshair';
const isPan = (graphMode || 'volume') === 'pan';
const isCtrl = e.ctrlKey || e.metaKey;
const cTop = 8;
const cHeight = rect.height - 16;
const curNodes = isPan ? panningNodes : volumeNodes;
const valFromY = (y) => {
if (isPan) return Math.max(-1, Math.min(1, -(y - cTop) / cHeight * 2 + 1));
const yOff = (y - cTop) / cHeight;
return yOff <= 2/3
? Math.max(0, Math.min(3, 3 * (1 - yOff * 3 / 2)))
: Math.max(-30, Math.min(0, -30 * (yOff - 2/3) * 3));
};
const snapVal = (v) => isPan ? Math.round(v * 20) / 20 : Math.round(v * 2) / 2;
const snapTime = (t) => Math.round(t * 10) / 10;
// Check if clicking near existing node (any mode)
const volNodeY = (v) => { const z = cTop + (2/3) * cHeight; return v >= 0 ? z - (v / 3) * (2/3 * cHeight) : z + (-v / 30) * (1/3 * cHeight); };
const nearNode = curNodes.findIndex(n => {
const t = Math.abs(n.time - startTime);
const val = isPan ? n.pan : n.db;
const ny = isPan ? cTop + (1 - (val + 1) / 2) * cHeight : volNodeY(val);
const dy = Math.abs((e.clientY - rect.top) - ny);
return t < 0.1 / (speed || 1) && dy < 10;
});
if (nearNode >= 0) {
// Drag existing node
let working = [...curNodes];
let dragIdx = nearNode;
const handleMouseMove = (moveEvent) => { const handleMouseMove = (moveEvent) => {
const currentX = moveEvent.clientX - rect.left + scrollLeft; const currentX = moveEvent.clientX - rect.left + scrollLeft;
const ct = Math.max(0, Math.min(wallDuration, currentX / zoom)); const ct = Math.max(0, Math.min(wallDuration, currentX / zoom));
onPlayheadSet(ct); const val = snapVal(valFromY(moveEvent.clientY - rect.top));
const updated = isPan
? { time: Math.min(snapTime(ct), wallDuration), pan: val }
: { time: Math.min(snapTime(ct), wallDuration), db: val };
const cleaned = mergeNodes(working.filter((_, i) => i !== dragIdx), [updated]);
if (onUpdateNodes) onUpdateNodes(cleaned);
working = [...cleaned];
dragIdx = working.findIndex(n => n.time === updated.time && (isPan ? n.pan === updated.pan : n.db === updated.db));
}; };
const handleMouseUp = () => { const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('mouseup', handleMouseUp);
canvas.style.cursor = 'crosshair'; canvas.style.cursor = 'crosshair';
}; };
document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp); document.addEventListener('mouseup', handleMouseUp);
return; return;
} }
if (isCtrl) {
const pts = [];
let lastKey = '';
const handleMouseMove = (moveEvent) => {
const currentX = moveEvent.clientX - rect.left + scrollLeft;
const ct = Math.max(0, Math.min(wallDuration, currentX / zoom));
const t = +snapTime(ct).toFixed(3);
const v = isPan
? +snapVal(valFromY(moveEvent.clientY - rect.top)).toFixed(2)
: +snapVal(valFromY(moveEvent.clientY - rect.top)).toFixed(1);
const key = t + '|' + v;
if (key !== lastKey) { pts.push({ time: t, [isPan ? 'pan' : 'db']: v }); lastKey = key; }
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
canvas.style.cursor = 'crosshair';
const merged = mergeNodes(curNodes, pts);
if (onUpdateNodes) onUpdateNodes(merged);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
} else {
const newNode = isPan
? { time: +snapTime(startTime).toFixed(3), pan: +snapVal(valFromY(e.clientY - rect.top)).toFixed(2) }
: { time: +snapTime(startTime).toFixed(3), db: +snapVal(valFromY(e.clientY - rect.top)).toFixed(1) };
const merged = mergeNodes(curNodes, [newNode]);
if (onUpdateNodes) onUpdateNodes(merged);
// Now drag this newly created node
let working = [...merged];
let dragIdx = working.findIndex(n => n.time === newNode.time && (isPan ? n.pan === newNode.pan : n.db === newNode.db));
const handleMouseMove = (moveEvent) => {
const currentX = moveEvent.clientX - rect.left + scrollLeft;
const ct = Math.max(0, Math.min(wallDuration, currentX / zoom));
const val = snapVal(valFromY(moveEvent.clientY - rect.top));
const updated = isPan
? { time: Math.min(snapTime(ct), wallDuration), pan: val }
: { time: Math.min(snapTime(ct), wallDuration), db: val };
const cleaned = mergeNodes(working.filter((_, i) => i !== dragIdx), [updated]);
if (onUpdateNodes) onUpdateNodes(cleaned);
working = [...cleaned];
dragIdx = working.findIndex(n => n.time === updated.time && (isPan ? n.pan === updated.pan : n.db === updated.db));
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
canvas.style.cursor = 'crosshair';
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
return;
}
// Select tool (default): drag to select range // Select tool (default): drag to select range
onSelectRange(startTime, startTime); onSelectRange(startTime, startTime);
onPlayheadSet(startTime); onPlayheadSet(startTime);
@@ -920,11 +1311,61 @@
onContextMenu(e, clickTime); onContextMenu(e, clickTime);
}; };
// Double-click on automation curve to create a new node
const handleDoubleClick = (e) => {
const canvas = canvasRef.current;
if (!canvas || !buffer) return;
if (activeTool !== 'select' && activeTool !== 'pen') return;
const rect = canvas.getBoundingClientRect();
const parent = canvas.parentElement;
const scrollContainer = parent ? parent.parentElement : null;
const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0;
const x = e.clientX - rect.left + scrollLeft;
const clickTime = Math.max(0, Math.min(buffer.duration / (speed || 1.0), x / zoom));
const isPan = (graphMode || 'volume') === 'pan';
const curNodes = isPan ? panningNodes : volumeNodes;
const cTop = 8;
const cHeight = rect.height - 16;
const valFromY = (y) => {
if (isPan) return Math.max(-1, Math.min(1, -(y - cTop) / cHeight * 2 + 1));
const yOff = (y - cTop) / cHeight;
return yOff <= 2/3
? Math.max(0, Math.min(3, 3 * (1 - yOff * 3 / 2)))
: Math.max(-30, Math.min(0, -30 * (yOff - 2/3) * 3));
};
// Interpolate value at click position from existing curve
let interpolatedVal = valFromY(e.clientY - rect.top);
if (curNodes.length >= 2) {
const sorted = [...curNodes].sort((a, b) => a.time - b.time);
for (let i = 0; i < sorted.length - 1; i++) {
if (clickTime >= sorted[i].time && clickTime <= sorted[i+1].time) {
const t = (clickTime - sorted[i].time) / (sorted[i+1].time - sorted[i].time);
const v1 = isPan ? sorted[i].pan : sorted[i].db;
const v2 = isPan ? sorted[i+1].pan : sorted[i+1].db;
interpolatedVal = v1 + t * (v2 - v1);
break;
}
}
}
const newNode = isPan
? { time: +Math.round(clickTime * 10) / 10, pan: +Math.round(interpolatedVal * 20) / 20 }
: { time: +Math.round(clickTime * 10) / 10, db: +Math.round(interpolatedVal * 2) / 2 };
const merged = (() => {
const map = new Map();
curNodes.forEach(n => map.set(n.time, n));
map.set(newNode.time, newNode);
return Array.from(map.values()).sort((a, b) => a.time - b.time);
})();
if (onUpdateNodes) onUpdateNodes(merged);
};
return ( return (
<canvas <canvas
ref={canvasRef} ref={canvasRef}
className="w-full h-full rounded border border-zinc-800" className="w-full h-full rounded border border-zinc-800"
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onDoubleClick={handleDoubleClick}
onContextMenu={handleContextMenuInternal} onContextMenu={handleContextMenuInternal}
onMouseMove={(e) => { onMouseMove={(e) => {
if (canvasRef.current && e.altKey && onSpeedChange) { if (canvasRef.current && e.altKey && onSpeedChange) {
@@ -937,7 +1378,17 @@
const tolerance = 8; const tolerance = 8;
canvasRef.current.style.cursor = (Math.abs(mx - wClip) <= tolerance && !isStretchingRef.current) ? 'ew-resize' : 'crosshair'; canvasRef.current.style.cursor = (Math.abs(mx - wClip) <= tolerance && !isStretchingRef.current) ? 'ew-resize' : 'crosshair';
} else if (canvasRef.current && !isStretchingRef.current) { } else if (canvasRef.current && !isStretchingRef.current) {
canvasRef.current.style.cursor = 'crosshair'; const rect = canvasRef.current.getBoundingClientRect();
const parent = canvasRef.current.parentElement;
const scrollContainer = parent ? parent.parentElement : null;
const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0;
const mx = e.clientX - rect.left + scrollLeft;
const wClipPx = (buffer.duration / (speed || 1.0)) * zoom;
const cH = canvasRef.current.height / (window.devicePixelRatio || 1) - 16;
const btnX = wClipPx - 28 - 4;
const btnY = 8 + cH - 14 - 2;
const overBtn = mx >= btnX && mx <= btnX + 28 && (e.clientY - rect.top) >= btnY && (e.clientY - rect.top) <= btnY + 14;
canvasRef.current.style.cursor = overBtn ? 'pointer' : 'crosshair';
} }
}} }}
/> />
@@ -1080,6 +1531,166 @@
); );
}; };
// ── Graph Editor Canvas for Volume/Pan/Fade Automation ──
const GraphEditorCanvas = ({ buffer, zoom, timelineWidth, volumeNodes, panningNodes, fadeInLen, fadeOutLen, onUpdateNodes, graphMode }) => {
const canvasRef = useRef(null);
const isDraggingNode = useRef(false);
const dragNodeIdx = useRef(-1);
const isCreatingNode = useRef(false);
const getNodes = () => graphMode === 'pan' ? panningNodes : volumeNodes;
const nodeLabel = (n) => graphMode === 'pan' ? `${n.pan.toFixed(2)}` : `${n.db.toFixed(1)}dB`;
const nodeY = (n, h) => {
if (graphMode === 'pan') return ((1 - (n.pan + 1) / 2) * h);
const zeroY = (2/3) * h;
return n.db >= 0 ? zeroY - (n.db / 3) * (2/3 * h) : zeroY + (-n.db / 30) * (1/3 * h);
};
const nodeValFromY = (y, h) => {
if (graphMode === 'pan') return -(y / h * 2 - 1);
const yOff = y / h;
return yOff <= 2/3 ? 3 * (1 - yOff * 3 / 2) : -30 * (yOff - 2/3) * 3;
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !buffer) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
const w = timelineWidth;
const h = rect.height;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = '#2a2a4e';
ctx.lineWidth = 0.5;
for (let t = 0; t <= buffer.duration; t += 0.5) {
const x = (t / buffer.duration) * w;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
}
for (let i = 0; i <= 10; i++) {
const y = (i / 10) * h;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
}
if (fadeInLen > 0) {
const fadeX = (fadeInLen / buffer.duration) * w;
ctx.fillStyle = 'rgba(16, 185, 129, 0.12)';
ctx.fillRect(0, 0, fadeX, h);
}
if (fadeOutLen > 0) {
const fadeX = ((buffer.duration - fadeOutLen) / buffer.duration) * w;
const fadeW = (fadeOutLen / buffer.duration) * w;
ctx.fillStyle = 'rgba(239, 68, 68, 0.12)';
ctx.fillRect(fadeX, 0, fadeW, h);
}
const nodes = getNodes();
if (nodes.length > 0) {
ctx.strokeStyle = graphMode === 'pan' ? '#a855f7' : '#06b6d4';
ctx.lineWidth = 2;
ctx.beginPath();
nodes.forEach((n, i) => {
const x = (n.time / buffer.duration) * w;
const y = nodeY(n, h);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.stroke();
nodes.forEach((n, i) => {
const x = (n.time / buffer.duration) * w;
const y = nodeY(n, h);
ctx.fillStyle = graphMode === 'pan' ? '#a855f7' : '#06b6d4';
ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#e4e4e7';
ctx.font = '9px monospace';
ctx.fillText(nodeLabel(n), x + 8, y + 3);
});
} else {
ctx.fillStyle = '#52525b';
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(graphMode === 'pan' ? 'Click to add Pan points' : 'Click to add Volume points', w / 2, h / 2);
ctx.textAlign = 'start';
}
const zeroY = graphMode === 'pan' ? h / 2 : nodeY({ db: 0 }, h);
ctx.strokeStyle = graphMode === 'pan' ? '#a855f744' : '#06b6d444';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(0, zeroY); ctx.lineTo(w, zeroY); ctx.stroke();
ctx.setLineDash([]);
}, [buffer, zoom, timelineWidth, volumeNodes, panningNodes, fadeInLen, fadeOutLen, graphMode]);
const handleMouseDown = (e) => {
const canvas = canvasRef.current;
if (!canvas || !buffer) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const time = (x / rect.width) * buffer.duration;
const val = nodeValFromY(y, rect.height);
const nodes = getNodes();
const snapped = Math.max(-30, Math.min(3, val));
const snappedPan = Math.max(-1, Math.min(1, val));
const threshold = 12 / rect.width * buffer.duration;
const nearIdx = nodes.findIndex(n => Math.abs(n.time - time) < threshold);
if (nearIdx >= 0) { isDraggingNode.current = true; dragNodeIdx.current = nearIdx; return; }
const newNode = graphMode === 'pan' ? { time: +time.toFixed(3), pan: +snappedPan.toFixed(2) } : { time: +time.toFixed(3), db: +snapped.toFixed(1) };
const sorted = [...nodes, newNode].sort((a, b) => a.time - b.time);
onUpdateNodes(sorted);
const newIdx = sorted.findIndex(n => n.time === newNode.time && (graphMode === 'pan' ? n.pan : n.db) === (graphMode === 'pan' ? newNode.pan : newNode.db));
isDraggingNode.current = true; dragNodeIdx.current = newIdx; isCreatingNode.current = true;
};
const handleMouseMove = (e) => {
if (!isDraggingNode.current || dragNodeIdx.current < 0) return;
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const time = Math.max(0, Math.min(buffer.duration, (x / rect.width) * buffer.duration));
const val = nodeValFromY(y, rect.height);
const nodes = [...getNodes()];
nodes[dragNodeIdx.current] = graphMode === 'pan'
? { time: +time.toFixed(3), pan: +Math.max(-1, Math.min(1, val)).toFixed(2) }
: { time: +time.toFixed(3), db: +Math.max(-30, Math.min(3, val)).toFixed(1) };
onUpdateNodes(nodes.sort((a, b) => a.time - b.time));
};
const handleMouseUp = () => { isDraggingNode.current = false; dragNodeIdx.current = -1; isCreatingNode.current = false; };
const handleContextMenu = (e) => {
e.preventDefault();
const canvas = canvasRef.current;
if (!canvas || !buffer) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const time = (x / rect.width) * buffer.duration;
const threshold = 12 / rect.width * buffer.duration;
const nodes = getNodes();
const nearIdx = nodes.findIndex(n => Math.abs(n.time - time) < threshold);
if (nearIdx >= 0) onUpdateNodes(nodes.filter((_, i) => i !== nearIdx));
};
return (
<canvas ref={canvasRef}
className="w-full h-20 rounded border border-zinc-700 cursor-crosshair"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onContextMenu={handleContextMenu}
/>
);
};
const App = () => { const App = () => {
// ── State Definitions ── // ── State Definitions ──
const [tracks, setTracks] = useState([ const [tracks, setTracks] = useState([
@@ -1767,10 +2378,16 @@
selectionStart: null, selectionStart: null,
selectionEnd: null, selectionEnd: null,
isPlaying: false, isPlaying: false,
speed: 1.0 speed: 1.0,
volumeNodes: [],
panningNodes: [],
fadeInLen: 0,
fadeOutLen: 0,
graphMode: null,
isLooping: false,
loopCount: 0
}]); }]);
setActiveTab(tabId); setActiveTab(tabId);
showToast(`Đã tạo Sub Tab: ${tabLabel}. Chỉnh sửa và Apply để gộp lại.`, 'info');
}; };
const handleEditClipInSubTab = (trackId, clipId) => { const handleEditClipInSubTab = (trackId, clipId) => {
@@ -1788,7 +2405,6 @@
if (!clip || !clip.buffer) return; if (!clip || !clip.buffer) return;
const resolvedClipId = clip.id === 'default' ? 'default_' + trackId : clip.id; const resolvedClipId = clip.id === 'default' ? 'default_' + trackId : clip.id;
// Check if a subtab for this clip already exists
const existing = subTabs.find(s => s.clipId === resolvedClipId && s.trackId === trackId); const existing = subTabs.find(s => s.clipId === resolvedClipId && s.trackId === trackId);
if (existing) { if (existing) {
setActiveTab(existing.id); setActiveTab(existing.id);
@@ -1818,10 +2434,16 @@
selectionStart: null, selectionStart: null,
selectionEnd: null, selectionEnd: null,
isPlaying: false, isPlaying: false,
speed: 1.0 speed: 1.0,
volumeNodes: [],
panningNodes: [],
fadeInLen: 0,
fadeOutLen: 0,
graphMode: null,
isLooping: false,
loopCount: 0
}]); }]);
setActiveTab(tabId); setActiveTab(tabId);
showToast(`Đã tạo Sub Tab: ${tabLabel}. Chỉnh sửa và Apply để gộp lại.`, 'info');
}; };
// ── Sub Tab: apply effects & merge back (LOOP_EDITOR_2.md §1.2) ── // ── Sub Tab: apply effects & merge back (LOOP_EDITOR_2.md §1.2) ──
@@ -1870,10 +2492,20 @@
const fadeSamples = Math.min(resultData.length, Math.floor(fx.fadeInMs / 1000 * sr)); const fadeSamples = Math.min(resultData.length, Math.floor(fx.fadeInMs / 1000 * sr));
for (let i = 0; i < fadeSamples; i++) resultData[i] *= i / fadeSamples; for (let i = 0; i < fadeSamples; i++) resultData[i] *= i / fadeSamples;
} }
if (fx.fadeOutMs > 0) { // Graph Editor fade curves (trigonometric, 15_GRAPH_EDIT.md §2.3)
const fadeSamples = Math.min(resultData.length, Math.floor(fx.fadeOutMs / 1000 * sr)); const gFadeIn = subTab.fadeInLen || 0;
const gFadeOut = subTab.fadeOutLen || 0;
if (gFadeIn > 0) {
const fadeSamples = Math.min(resultData.length, Math.floor(gFadeIn * sr));
for (let i = 0; i < fadeSamples; i++) {
resultData[i] *= (1 - Math.cos(Math.PI * i / fadeSamples)) / 2;
}
}
if (gFadeOut > 0) {
const fadeSamples = Math.min(resultData.length, Math.floor(gFadeOut * sr));
for (let i = resultData.length - fadeSamples; i < resultData.length; i++) { for (let i = resultData.length - fadeSamples; i < resultData.length; i++) {
resultData[i] *= (resultData.length - 1 - i) / fadeSamples; const t = i - (resultData.length - fadeSamples);
resultData[i] *= (1 + Math.cos(Math.PI * t / fadeSamples)) / 2;
} }
} }
if (fx.normalizeDb !== 0) { if (fx.normalizeDb !== 0) {
@@ -2556,25 +3188,64 @@
source.buffer = edBuffer; source.buffer = edBuffer;
source.playbackRate.value = speed; source.playbackRate.value = speed;
// Look up track for volume/pan // Graph Editor Automation Node Chain (15_GRAPH_EDIT.md §3)
const subTrack = tracks.find(t => t.id === st.trackId); // source → volumeGainNode → pannerNode → fadeGainNode → destination
const trackVolDb = subTrack ? (subTrack.volumeDb ?? 0) : 0;
const trackPan = subTrack ? (subTrack.pan ?? 0) : 0;
const gainNode = context.createGain(); const volumeGainNode = context.createGain();
const volLinear = trackVolDb <= -50 ? 0 : Math.pow(10, trackVolDb / 20); volumeGainNode.gain.setValueAtTime(1.0, context.currentTime);
gainNode.gain.setValueAtTime(volLinear, context.currentTime);
const pannerNode = context.createStereoPanner(); const pannerNode = context.createStereoPanner();
pannerNode.pan.setValueAtTime(trackPan / 100, context.currentTime); pannerNode.pan.setValueAtTime(0.0, context.currentTime);
source.connect(gainNode); const fadeGainNode = context.createGain();
gainNode.connect(pannerNode); fadeGainNode.gain.setValueAtTime(1.0, context.currentTime);
pannerNode.connect(context.destination);
// Schedule volume automation nodes
const volNodes = st.volumeNodes || [];
if (volNodes.length > 0) {
volumeGainNode.gain.cancelScheduledValues(context.currentTime);
volNodes.forEach((n, i) => {
const t = context.currentTime + (n.time / speed);
const linearGain = Math.pow(10, n.db / 20);
if (i === 0) volumeGainNode.gain.setValueAtTime(linearGain, t);
else volumeGainNode.gain.linearRampToValueAtTime(linearGain, t);
});
}
// Schedule panning automation nodes
const panNodes = st.panningNodes || [];
if (panNodes.length > 0) {
pannerNode.pan.cancelScheduledValues(context.currentTime);
panNodes.forEach((n, i) => {
const t = context.currentTime + (n.time / speed);
const clamped = Math.max(-1, Math.min(1, n.pan));
if (i === 0) pannerNode.pan.setValueAtTime(clamped, t);
else pannerNode.pan.linearRampToValueAtTime(clamped, t);
});
}
// Schedule fade curves
const duration = st.buffer.duration;
const fIn = st.fadeInLen || 0;
const fOut = st.fadeOutLen || 0;
if (fIn > 0) {
fadeGainNode.gain.setValueAtTime(0.0, context.currentTime);
fadeGainNode.gain.linearRampToValueAtTime(1.0, context.currentTime + fIn / speed);
}
if (fOut > 0) {
const fadeOutStart = (duration - fOut) / speed;
fadeGainNode.gain.setValueAtTime(1.0, context.currentTime + Math.max(0, fadeOutStart));
fadeGainNode.gain.linearRampToValueAtTime(0.0, context.currentTime + duration / speed);
}
source.connect(volumeGainNode);
volumeGainNode.connect(pannerNode);
pannerNode.connect(fadeGainNode);
fadeGainNode.connect(context.destination);
source.start(context.currentTime, offsetBuffer); source.start(context.currentTime, offsetBuffer);
activeSourcesRef.current = [source]; activeSourcesRef.current = [source];
activeTrackNodesRef.current[st.trackId] = { gainNode, pannerNode, source }; activeTrackNodesRef.current[st.trackId] = { gainNode: volumeGainNode, pannerNode, source };
startOffsetTimeRef.current = offsetWallTime; startOffsetTimeRef.current = offsetWallTime;
startBufferOffsetRef.current = offsetBuffer; startBufferOffsetRef.current = offsetBuffer;
startAudioTimeRef.current = context.currentTime; startAudioTimeRef.current = context.currentTime;
@@ -4931,6 +5602,22 @@
<input type="range" min="-100" max="100" step="1" value={vTrack.pan ?? 0} onChange={e => updateTrackPan(vTrack.id, parseInt(e.target.value))} className="flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-purple-500" style={{ height: '4px' }} /> <input type="range" min="-100" max="100" step="1" value={vTrack.pan ?? 0} onChange={e => updateTrackPan(vTrack.id, parseInt(e.target.value))} className="flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-purple-500" style={{ height: '4px' }} />
<span className="w-12 text-right font-mono text-zinc-300 text-[9px]">{vTrack.pan > 0 ? 'R' + vTrack.pan : vTrack.pan < 0 ? 'L' + Math.abs(vTrack.pan) : 'C'}</span> <span className="w-12 text-right font-mono text-zinc-300 text-[9px]">{vTrack.pan > 0 ? 'R' + vTrack.pan : vTrack.pan < 0 ? 'L' + Math.abs(vTrack.pan) : 'C'}</span>
</div> </div>
<div className="flex items-center gap-1">
<span className="w-8 text-right text-zinc-500 text-[9px]">Norm:</span>
<input type="range" min="-12" max="0" step="0.1" value={(st.effects || {}).normalizeDb || 0} onChange={e => updateSubTabEffects(st.id, { normalizeDb: parseFloat(e.target.value) })} className="flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-amber-500" style={{ height: '4px' }} />
<span className="w-12 text-right font-mono text-zinc-300 text-[9px]">{(st.effects || {}).normalizeDb || 0}dB</span>
</div>
<div className="flex items-center gap-1">
<span className="w-8 text-right text-zinc-500 text-[9px]">Gain:</span>
<input type="range" min="-40" max="24" step="0.1" value={(st.effects || {}).gainDb || 0} onChange={e => updateSubTabEffects(st.id, { gainDb: parseFloat(e.target.value) })} className="flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-cyan-500" style={{ height: '4px' }} />
<span className="w-12 text-right font-mono text-zinc-300 text-[9px]">{(st.effects || {}).gainDb || 0}dB</span>
</div>
<div className="flex items-center gap-1">
<button onClick={() => setSubTabs(prev => prev.map(s => s.id === st.id ? {...s, isLooping: !s.isLooping} : s))}
className={`px-1.5 py-0.5 text-[9px] rounded border font-bold ${st.isLooping ? 'bg-amber-700 text-white border-amber-500' : 'bg-zinc-800 text-zinc-400 border-zinc-700'}`}><i data-lucide="repeat" className="w-3 h-3"></i></button>
<span className="w-8 text-right text-zinc-500 text-[9px]">Loop:</span>
<input type="number" min="0" max="999" value={st.loopCount || 0} onChange={e => setSubTabs(prev => prev.map(s => s.id === st.id ? {...s, loopCount: Math.max(0, parseInt(e.target.value) || 0)} : s))} className="w-12 bg-black text-amber-300 text-[9px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" title="Loop count" />
</div>
</div> </div>
<div className="flex items-center gap-1 text-[9px] text-zinc-400"> <div className="flex items-center gap-1 text-[9px] text-zinc-400">
<span className="text-[8px] text-zinc-500 font-semibold uppercase">Duration:</span> <span className="text-[8px] text-zinc-500 font-semibold uppercase">Duration:</span>
@@ -4992,6 +5679,14 @@
color={vTrack.color} color={vTrack.color}
name={vTrack.name} name={vTrack.name}
speed={st.speed || 1.0} speed={st.speed || 1.0}
volumeNodes={st.volumeNodes || []}
panningNodes={st.panningNodes || []}
fadeInLen={st.fadeInLen || 0}
fadeOutLen={st.fadeOutLen || 0}
graphMode={st.graphMode}
onUpdateNodes={(nodes) => setSubTabs(prev => prev.map(s => s.id === st.id ? {...s, [s.graphMode === 'pan' ? 'panningNodes' : 'volumeNodes']: nodes} : s))}
onUpdateFade={(fade) => setSubTabs(prev => prev.map(s => s.id === st.id ? {...s, fadeInLen: fade.fadeInLen ?? s.fadeInLen, fadeOutLen: fade.fadeOutLen ?? s.fadeOutLen} : s))}
onModeToggle={() => setSubTabs(prev => prev.map(s => s.id === st.id ? {...s, graphMode: s.graphMode === 'pan' ? null : 'pan'} : s))}
onSpeedChange={(newSpeed) => { onSpeedChange={(newSpeed) => {
setSubTabs(prev => prev.map(s => s.id === st.id ? { setSubTabs(prev => prev.map(s => s.id === st.id ? {
...s, ...s,
@@ -5081,22 +5776,6 @@
<div className="px-3 py-1 text-[10px] text-zinc-500 font-bold uppercase border-b border-zinc-800 pb-1 mb-1"> <div className="px-3 py-1 text-[10px] text-zinc-500 font-bold uppercase border-b border-zinc-800 pb-1 mb-1">
Selection: {formatTime(subTabs.find(s => s.id === contextMenu.subTabId)?.selectionStart || 0)} - {formatTime(subTabs.find(s => s.id === contextMenu.subTabId)?.selectionEnd || 0)} Selection: {formatTime(subTabs.find(s => s.id === contextMenu.subTabId)?.selectionStart || 0)} - {formatTime(subTabs.find(s => s.id === contextMenu.subTabId)?.selectionEnd || 0)}
</div> </div>
<button onClick={() => { handleSubTabNormalize(contextMenu.subTabId); closeContextMenu(); }} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
<i data-lucide="maximize" className="w-3.5 h-3.5 text-amber-400 shrink-0"></i>
<span className="flex-1">Normalize Selection To Peak</span>
</button>
<button onClick={() => { const val = prompt("Nhập Gain điều chỉnh (dB):", "0"); if(val) handleSubTabGain(contextMenu.subTabId, parseFloat(val) || 0); closeContextMenu(); }} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
<i data-lucide="volume-2" className="w-3.5 h-3.5 text-amber-400 shrink-0"></i>
<span className="flex-1">Adjust Gain/Volume...</span>
</button>
<button onClick={() => { handleSubTabFade(contextMenu.subTabId, 'in'); closeContextMenu(); }} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
<i data-lucide="chevrons-right" className="w-3.5 h-3.5 text-amber-400 shrink-0"></i>
<span className="flex-1">Fade In (Linear)</span>
</button>
<button onClick={() => { handleSubTabFade(contextMenu.subTabId, 'out'); closeContextMenu(); }} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
<i data-lucide="chevrons-left" className="w-3.5 h-3.5 text-amber-400 shrink-0"></i>
<span className="flex-1">Fade Out (Linear)</span>
</button>
<div className="h-px bg-zinc-700 my-1"></div> <div className="h-px bg-zinc-700 my-1"></div>
<button onClick={() => { handleSubTabCut(contextMenu.subTabId); closeContextMenu(); }} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"> <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>