fix: vẽ volume graph
This commit is contained in:
+167
@@ -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
|
||||
|
||||
```
|
||||
+738
-59
@@ -403,7 +403,9 @@
|
||||
const hoveredClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
|
||||
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';
|
||||
} else if (activeTool === 'razor') {
|
||||
canvasRef.current.style.cursor = isOverClip ? 'cell' : 'not-allowed';
|
||||
@@ -475,6 +477,17 @@
|
||||
setSelectedClipId(null);
|
||||
}
|
||||
|
||||
if (activeTool === 'pen') {
|
||||
if (clickedClip) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onEditClipInSubTab) {
|
||||
onEditClipInSubTab(track.id, clickedClip.id);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTool === 'razor') {
|
||||
if (clickedClip) {
|
||||
e.preventDefault();
|
||||
@@ -668,7 +681,7 @@
|
||||
};
|
||||
|
||||
// ── 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 isStretchingRef = useRef(false);
|
||||
const stretchStartRef = useRef({ mouseX: 0, originalDuration: 0, originalSpeed: 1.0 });
|
||||
@@ -722,10 +735,90 @@
|
||||
}
|
||||
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 drawXEnd = Math.min(w, Math.ceil(xEnd));
|
||||
const samplesPerPixel = (buffer.sampleRate / zoom) * speed;
|
||||
const bufDur = buffer.duration;
|
||||
|
||||
ctx.strokeStyle = '#6ee7b7';
|
||||
ctx.lineWidth = 1;
|
||||
@@ -738,6 +831,15 @@
|
||||
const chunkStart = Math.max(0, sampleIdx - Math.floor(chunkSize / 2));
|
||||
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 minVal = 0;
|
||||
for (let i = chunkStart; i < chunkEnd; i++) {
|
||||
@@ -745,6 +847,8 @@
|
||||
if (val > maxVal) maxVal = val;
|
||||
if (val < minVal) minVal = val;
|
||||
}
|
||||
maxVal *= fadeGain;
|
||||
minVal *= fadeGain;
|
||||
const yTop = mid + (minVal * (clipHeight * 0.45));
|
||||
const yBottom = mid + (maxVal * (clipHeight * 0.45));
|
||||
ctx.beginPath();
|
||||
@@ -753,6 +857,154 @@
|
||||
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
|
||||
if (wClip > 0 && wClip < w) {
|
||||
ctx.strokeStyle = clipColor;
|
||||
@@ -792,7 +1044,7 @@
|
||||
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) => {
|
||||
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
|
||||
if (activeTool === 'grab') {
|
||||
onPlayheadSet(startTime);
|
||||
@@ -868,23 +1161,121 @@
|
||||
}
|
||||
|
||||
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);
|
||||
canvas.style.cursor = 'crosshair';
|
||||
|
||||
const handleMouseMove = (moveEvent) => {
|
||||
const currentX = moveEvent.clientX - rect.left + scrollLeft;
|
||||
const ct = Math.max(0, Math.min(wallDuration, currentX / zoom));
|
||||
onPlayheadSet(ct);
|
||||
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 handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
canvas.style.cursor = 'crosshair';
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -920,11 +1311,61 @@
|
||||
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 (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full rounded border border-zinc-800"
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onContextMenu={handleContextMenuInternal}
|
||||
onMouseMove={(e) => {
|
||||
if (canvasRef.current && e.altKey && onSpeedChange) {
|
||||
@@ -937,7 +1378,17 @@
|
||||
const tolerance = 8;
|
||||
canvasRef.current.style.cursor = (Math.abs(mx - wClip) <= tolerance && !isStretchingRef.current) ? 'ew-resize' : 'crosshair';
|
||||
} 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 = () => {
|
||||
// ── State Definitions ──
|
||||
const [tracks, setTracks] = useState([
|
||||
@@ -1767,10 +2378,16 @@
|
||||
selectionStart: null,
|
||||
selectionEnd: null,
|
||||
isPlaying: false,
|
||||
speed: 1.0
|
||||
speed: 1.0,
|
||||
volumeNodes: [],
|
||||
panningNodes: [],
|
||||
fadeInLen: 0,
|
||||
fadeOutLen: 0,
|
||||
graphMode: null,
|
||||
isLooping: false,
|
||||
loopCount: 0
|
||||
}]);
|
||||
setActiveTab(tabId);
|
||||
showToast(`Đã tạo Sub Tab: ${tabLabel}. Chỉnh sửa và Apply để gộp lại.`, 'info');
|
||||
};
|
||||
|
||||
const handleEditClipInSubTab = (trackId, clipId) => {
|
||||
@@ -1788,7 +2405,6 @@
|
||||
if (!clip || !clip.buffer) return;
|
||||
|
||||
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);
|
||||
if (existing) {
|
||||
setActiveTab(existing.id);
|
||||
@@ -1818,10 +2434,16 @@
|
||||
selectionStart: null,
|
||||
selectionEnd: null,
|
||||
isPlaying: false,
|
||||
speed: 1.0
|
||||
speed: 1.0,
|
||||
volumeNodes: [],
|
||||
panningNodes: [],
|
||||
fadeInLen: 0,
|
||||
fadeOutLen: 0,
|
||||
graphMode: null,
|
||||
isLooping: false,
|
||||
loopCount: 0
|
||||
}]);
|
||||
setActiveTab(tabId);
|
||||
showToast(`Đã tạo Sub Tab: ${tabLabel}. Chỉnh sửa và Apply để gộp lại.`, 'info');
|
||||
setActiveTab(tabId);
|
||||
};
|
||||
|
||||
// ── Sub Tab: apply effects & merge back (LOOP_EDITOR_2.md §1.2) ──
|
||||
@@ -1870,13 +2492,23 @@
|
||||
const fadeSamples = Math.min(resultData.length, Math.floor(fx.fadeInMs / 1000 * sr));
|
||||
for (let i = 0; i < fadeSamples; i++) resultData[i] *= i / fadeSamples;
|
||||
}
|
||||
if (fx.fadeOutMs > 0) {
|
||||
const fadeSamples = Math.min(resultData.length, Math.floor(fx.fadeOutMs / 1000 * sr));
|
||||
for (let i = resultData.length - fadeSamples; i < resultData.length; i++) {
|
||||
resultData[i] *= (resultData.length - 1 - i) / fadeSamples;
|
||||
// Graph Editor fade curves (trigonometric, 15_GRAPH_EDIT.md §2.3)
|
||||
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 (fx.normalizeDb !== 0) {
|
||||
if (gFadeOut > 0) {
|
||||
const fadeSamples = Math.min(resultData.length, Math.floor(gFadeOut * sr));
|
||||
for (let i = resultData.length - fadeSamples; i < resultData.length; i++) {
|
||||
const t = i - (resultData.length - fadeSamples);
|
||||
resultData[i] *= (1 + Math.cos(Math.PI * t / fadeSamples)) / 2;
|
||||
}
|
||||
}
|
||||
if (fx.normalizeDb !== 0) {
|
||||
let maxVal = 0;
|
||||
for (let i = 0; i < resultData.length; i++) { const abs = Math.abs(resultData[i]); if (abs > maxVal) maxVal = abs; }
|
||||
if (maxVal > 0) {
|
||||
@@ -2556,25 +3188,64 @@
|
||||
source.buffer = edBuffer;
|
||||
source.playbackRate.value = speed;
|
||||
|
||||
// Look up track for volume/pan
|
||||
const subTrack = tracks.find(t => t.id === st.trackId);
|
||||
const trackVolDb = subTrack ? (subTrack.volumeDb ?? 0) : 0;
|
||||
const trackPan = subTrack ? (subTrack.pan ?? 0) : 0;
|
||||
// Graph Editor Automation Node Chain (15_GRAPH_EDIT.md §3)
|
||||
// source → volumeGainNode → pannerNode → fadeGainNode → destination
|
||||
|
||||
const gainNode = context.createGain();
|
||||
const volLinear = trackVolDb <= -50 ? 0 : Math.pow(10, trackVolDb / 20);
|
||||
gainNode.gain.setValueAtTime(volLinear, context.currentTime);
|
||||
const volumeGainNode = context.createGain();
|
||||
volumeGainNode.gain.setValueAtTime(1.0, context.currentTime);
|
||||
|
||||
const pannerNode = context.createStereoPanner();
|
||||
pannerNode.pan.setValueAtTime(trackPan / 100, context.currentTime);
|
||||
pannerNode.pan.setValueAtTime(0.0, context.currentTime);
|
||||
|
||||
source.connect(gainNode);
|
||||
gainNode.connect(pannerNode);
|
||||
pannerNode.connect(context.destination);
|
||||
const fadeGainNode = context.createGain();
|
||||
fadeGainNode.gain.setValueAtTime(1.0, context.currentTime);
|
||||
|
||||
// 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);
|
||||
activeSourcesRef.current = [source];
|
||||
activeTrackNodesRef.current[st.trackId] = { gainNode, pannerNode, source };
|
||||
activeTrackNodesRef.current[st.trackId] = { gainNode: volumeGainNode, pannerNode, source };
|
||||
startOffsetTimeRef.current = offsetWallTime;
|
||||
startBufferOffsetRef.current = offsetBuffer;
|
||||
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' }} />
|
||||
<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 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 className="flex items-center gap-1 text-[9px] text-zinc-400">
|
||||
<span className="text-[8px] text-zinc-500 font-semibold uppercase">Duration:</span>
|
||||
@@ -4991,7 +5678,15 @@
|
||||
timelineWidth={subTabTimelineWidth}
|
||||
color={vTrack.color}
|
||||
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) => {
|
||||
setSubTabs(prev => prev.map(s => s.id === st.id ? {
|
||||
...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">
|
||||
Selection: {formatTime(subTabs.find(s => s.id === contextMenu.subTabId)?.selectionStart || 0)} - {formatTime(subTabs.find(s => s.id === contextMenu.subTabId)?.selectionEnd || 0)}
|
||||
</div>
|
||||
<button onClick={() => { handleSubTabNormalize(contextMenu.subTabId); closeContextMenu(); }} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="maximize" className="w-3.5 h-3.5 text-amber-400 shrink-0"></i>
|
||||
<span className="flex-1">Normalize Selection To Peak</span>
|
||||
</button>
|
||||
<button onClick={() => { const val = prompt("Nhập Gain điều chỉnh (dB):", "0"); if(val) handleSubTabGain(contextMenu.subTabId, parseFloat(val) || 0); closeContextMenu(); }} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="volume-2" className="w-3.5 h-3.5 text-amber-400 shrink-0"></i>
|
||||
<span className="flex-1">Adjust Gain/Volume...</span>
|
||||
</button>
|
||||
<button onClick={() => { handleSubTabFade(contextMenu.subTabId, 'in'); closeContextMenu(); }} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="chevrons-right" className="w-3.5 h-3.5 text-amber-400 shrink-0"></i>
|
||||
<span className="flex-1">Fade In (Linear)</span>
|
||||
</button>
|
||||
<button onClick={() => { handleSubTabFade(contextMenu.subTabId, 'out'); closeContextMenu(); }} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="chevrons-left" className="w-3.5 h-3.5 text-amber-400 shrink-0"></i>
|
||||
<span className="flex-1">Fade Out (Linear)</span>
|
||||
</button>
|
||||
<div className="h-px bg-zinc-700 my-1"></div>
|
||||
<button onClick={() => { handleSubTabCut(contextMenu.subTabId); closeContextMenu(); }} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
|
||||
<i data-lucide="scissors" className="w-3.5 h-3.5 text-rose-400 shrink-0"></i>
|
||||
|
||||
Reference in New Issue
Block a user