From 4ca90f15cafb50e574612abd4454a842131731a0 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Sun, 19 Jul 2026 20:20:26 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20v=E1=BA=BD=20volume=20graph?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 16_FIX_GRAPH.md | 167 ++++++++ app/templates/index.html | 797 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 905 insertions(+), 59 deletions(-) create mode 100644 16_FIX_GRAPH.md diff --git a/16_FIX_GRAPH.md b/16_FIX_GRAPH.md new file mode 100644 index 0000000..67a105d --- /dev/null +++ b/16_FIX_GRAPH.md @@ -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.
+ +
+ +
* **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 + +``` \ No newline at end of file diff --git a/app/templates/index.html b/app/templates/index.html index be75df2..801acce 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -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 ( { 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 ( + + ); + }; + 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 @@ 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' }} /> {vTrack.pan > 0 ? 'R' + vTrack.pan : vTrack.pan < 0 ? 'L' + Math.abs(vTrack.pan) : 'C'} +
+ Norm: + 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' }} /> + {(st.effects || {}).normalizeDb || 0}dB +
+
+ Gain: + 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' }} /> + {(st.effects || {}).gainDb || 0}dB +
+
+ + Loop: + 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" /> +
Duration: @@ -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 @@
Selection: {formatTime(subTabs.find(s => s.id === contextMenu.subTabId)?.selectionStart || 0)} - {formatTime(subTabs.find(s => s.id === contextMenu.subTabId)?.selectionEnd || 0)}
- - - -