diff --git a/app/core/sub_tab_dsp.py b/app/core/sub_tab_dsp.py index 7ef8bb8..e0be490 100644 --- a/app/core/sub_tab_dsp.py +++ b/app/core/sub_tab_dsp.py @@ -136,3 +136,111 @@ class SubTabDSPEngine: ) return output_audio + + +class DSPAudioModulator: + @staticmethod + def apply_automation_and_panning( + y_raw: np.ndarray, + sr: int, + volume_points: list, # [{"time": 0.5, "db": -6.0}, ...] + panning_points: list, # [{"time": 1.0, "pan": -0.7}, ...] + fade_in_sec: float = 0.0, + fade_out_sec: float = 0.0 + ) -> np.ndarray: + """ + Applies multi-point volume envelopes, constant-power panning, and trigonometric fades + directly onto a 1D (Mono) or 2D (Stereo) acoustic NumPy signal array. + + Input: y_raw maps to the raw sound array (Mono/Stereo matrix bounded inside [-1.0, 1.0]). + Output: y_processed yields a 2D interleaved Stereo NumPy array (2, N) with baked modulations. + """ + total_samples = y_raw.shape[-1] if len(y_raw.shape) > 1 else len(y_raw) + duration_sec = total_samples / sr + + # 1. Guarantee Stereo geometry dimensions (2 discrete channels) for Panning operations + if len(y_raw.shape) == 1: + # For Mono arrays, clone sample metrics symmetrically to Left/Right matrices + y_stereo = np.vstack((y_raw, y_raw)) + else: + y_stereo = np.copy(y_raw) + + # 2. Allocate Envelope Mask arrays matching total track samples limits + volume_envelope = np.ones(total_samples, dtype=np.float32) + pan_envelope = np.zeros(total_samples, dtype=np.float32) # Default initialization: Center (0.0) + + # 3. Compile the Volume Envelope using linear interpolation bounds across nodes + if volume_points and len(volume_points) > 0: + # Enforce strict chronological sorting down the timeline axis + points = sorted(volume_points, key=lambda x: x["time"]) + + # Pad introductory bounds if the initial point coordinate sits past t = 0.0s + if points[0]["time"] > 0: + first_gain = 10.0 ** (points[0]["db"] / 20.0) + idx_end = int(points[0]["time"] * sr) + volume_envelope[:idx_end] = first_gain + + for i in range(len(points) - 1): + p1, p2 = points[i], points[i+1] + idx_start = int(p1["time"] * sr) + idx_end = int(p2["time"] * sr) + + gain_start = 10.0 ** (p1["db"] / 20.0) + gain_end = 10.0 ** (p2["db"] / 20.0) + + # Linearly interpolate vector increments between adjacent anchor positions + volume_envelope[idx_start:idx_end] = np.linspace(gain_start, gain_end, idx_end - idx_start) + + # Pad trailing bounds from the final milestone extending through end-of-file + if points[-1]["time"] < duration_sec: + last_gain = 10.0 ** (points[-1]["db"] / 20.0) + idx_start = int(points[-1]["time"] * sr) + volume_envelope[idx_start:] = last_gain + + # 4. Compile the Panning Envelope using linear interpolation bounds across nodes + if panning_points and len(panning_points) > 0: + points = sorted(panning_points, key=lambda x: x["time"]) + + if points[0]["time"] > 0: + pan_envelope[:int(points[0]["time"] * sr)] = points[0]["pan"] + + for i in range(len(points) - 1): + p1, p2 = points[i], points[i+1] + idx_start = int(p1["time"] * sr) + idx_end = int(p2["time"] * sr) + pan_envelope[idx_start:idx_end] = np.linspace(p1["pan"], p2["pan"], idx_end - idx_start) + + if points[-1]["time"] < duration_sec: + pan_envelope[int(points[-1]["time"] * sr):] = points[-1]["pan"] + + # 5. Apply Trigonometric Cosine Fade-In / Fade-Out functions onto the Volume Envelope mask + if fade_in_sec > 0: + fade_in_samples = min(total_samples, int(fade_in_sec * sr)) + x_fade = np.linspace(0.0, np.pi, fade_in_samples) + cosine_ramp = (1.0 - np.cos(x_fade)) / 2.0 + volume_envelope[:fade_in_samples] *= cosine_ramp + + if fade_out_sec > 0: + fade_out_samples = min(total_samples, int(fade_out_sec * sr)) + x_fade = np.linspace(0.0, np.pi, fade_out_samples) + cosine_ramp = (1.0 + np.cos(x_fade)) / 2.0 + volume_envelope[-fade_out_samples:] *= cosine_ramp + + # 6. Bake Volume Envelope matrices onto the Left and Right discrete audio paths + y_stereo[0, :] *= volume_envelope + y_stereo[1, :] *= volume_envelope + + # 7. Apply Constant-Power Stereo Panning allocations + # Map panning metrics range [-1.0, 1.0] onto angular radians field array [0, pi/2] + theta_envelope = ((pan_envelope + 1.0) / 2.0) * (np.pi / 2.0) + + # Evaluate localized amplitude coefficients for physical channels split + gain_left = np.cos(theta_envelope) + gain_right = np.sin(theta_envelope) + + # Multiply scaling factors directly across corresponding discrete matrices + y_stereo[0, :] *= gain_left + y_stereo[1, :] *= gain_right + + return y_stereo + diff --git a/app/templates/index.html b/app/templates/index.html index 801acce..c61c998 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -681,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, volumeNodes = [], panningNodes = [], fadeInLen = 0, fadeOutLen = 0, graphMode = null, onUpdateNodes, onUpdateFade, onModeToggle }) => { + 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, selectedNodeTime, setSelectedNodeTime }) => { const canvasRef = useRef(null); const isStretchingRef = useRef(false); const stretchStartRef = useRef({ mouseX: 0, originalDuration: 0, originalSpeed: 1.0 }); @@ -705,6 +705,53 @@ const len = data.length; if (len === 0) return; + // Helper to compute volume gain at a specific time in clip using Monotone Cubic Hermite Spline + const getVolumeGainAtTime = (t) => { + const volNodes = volumeNodes || []; + if (volNodes.length === 0) return 1.0; + const sortedNodes = [...volNodes].sort((a, b) => a.time - b.time); + + const computeHermiteTangents = (pts) => { + 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 = (pts[i].db - pts[i-1].db) / hP; + const sN = (pts[i+1].db - pts[i].db) / hN; + m[i] = (sP + sN) / 2; + } + m[0] = n > 1 ? (pts[1].db - pts[0].db) / (pts[1].time - pts[0].time) : 0; + m[n-1] = n > 1 ? (pts[n-1].db - pts[n-2].db) / (pts[n-1].time - pts[n-2].time) : 0; + return m; + }; + const tangents = computeHermiteTangents(sortedNodes); + + if (sortedNodes.length === 1) { + return Math.pow(10, sortedNodes[0].db / 20); + } + if (t <= sortedNodes[0].time) { + return Math.pow(10, sortedNodes[0].db / 20); + } + if (t >= sortedNodes[sortedNodes.length - 1].time) { + return Math.pow(10, sortedNodes[sortedNodes.length - 1].db / 20); + } + for (let i = 0; i < sortedNodes.length - 1; i++) { + const n1 = sortedNodes[i]; + const n2 = sortedNodes[i+1]; + if (t >= n1.time && t <= n2.time) { + const h = n2.time - n1.time; + if (h <= 0) return Math.pow(10, n1.db / 20); + const frac = (t - n1.time) / h; + const frac2 = frac * frac, frac3 = frac2 * frac; + const db = (2*frac3 - 3*frac2 + 1) * n1.db + (frac3 - 2*frac2 + frac) * h * tangents[i] + (-2*frac3 + 3*frac2) * n2.db + (frac3 - frac2) * h * tangents[i+1]; + return Math.pow(10, db / 20); + } + } + return 1.0; + }; + // Draw clip container (like main session clips) const xStart = 0; const wClip = (buffer.duration / speed) * zoom; // speed-adjusted width @@ -752,23 +799,38 @@ ctx.beginPath(); ctx.moveTo(px, clipTop); ctx.lineTo(px, clipTop + clipHeight); ctx.stroke(); } - // Horizontal grid lines (value markers) + // Always draw the reference Volume 0dB Axis (White) and Panning Center Axis (Brown) + const volZeroY = clipTop + (1/3) * clipHeight; + const panZeroY = clipTop + (1/2) * clipHeight; + + // White line for volume 0dB + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 1.2; + ctx.beginPath(); ctx.moveTo(xStart, volZeroY); ctx.lineTo(xStart + wClip, volZeroY); ctx.stroke(); + + // Brown line for panning center + ctx.strokeStyle = '#854d0e'; + ctx.lineWidth = 1.2; + ctx.beginPath(); ctx.moveTo(xStart, panZeroY); ctx.lineTo(xStart + wClip, panZeroY); ctx.stroke(); + + // Horizontal grid lines (other value markers) const isPanMode = graphMode === 'pan'; if (isPanMode) { for (let p = -100; p <= 100; p += 20) { + if (p === 0) continue; const y = clipTop + clipHeight * (1 - (p / 100 + 1) / 2); - ctx.strokeStyle = p === 0 ? '#a855f766' : '#2a2a2a'; - ctx.lineWidth = p === 0 ? 1 : 0.5; + ctx.strokeStyle = '#2a2a2a'; + ctx.lineWidth = 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) { + if (db === 0) continue; 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.strokeStyle = '#2a2a2a'; + ctx.lineWidth = 0.5; ctx.beginPath(); ctx.moveTo(xStart, y); ctx.lineTo(xStart + wClip, y); ctx.stroke(); } } @@ -780,13 +842,16 @@ 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.fillStyle = '#854d0e'; // Brown label for active Panning Center + ctx.fillText('C (Pan)', xStart - 2, clipTop + clipHeight * 0.5 + 2); + ctx.fillStyle = '#71717a'; 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.fillStyle = '#ffffff'; // White label for active Volume 0dB + ctx.fillText('0dB (Vol)', xStart - 2, volZeroY + 2); + ctx.fillStyle = '#71717a'; ctx.fillText('-15dB', xStart - 2, volZeroY + clipHeight / 6 + 2); ctx.fillText('-30dB', xStart - 2, clipTop + clipHeight - 2); } @@ -798,6 +863,11 @@ ctx.font = '8px sans-serif'; ctx.fillText('Kéo FI/FO trên đường cong để điều chỉnh', 8, clipTop + clipHeight + 12); + // Display speed percentage in the bottom left corner of the waveform area + ctx.fillStyle = '#e4e4e7'; + ctx.font = 'bold 9px sans-serif'; + ctx.fillText(`Tốc độ: ${Math.round(speed * 100)}%`, xStart + 8, clipTop + clipHeight - 8); + // Mode toggle button on waveform (bottom-right) const modeBtnW = 28; const modeBtnH = 14; @@ -840,6 +910,10 @@ fadeGain = (1 + Math.cos(Math.PI * t)) / 2; } + // Compute volume automation gain in real-time + const volGain = getVolumeGainAtTime(timeInClip); + const totalGain = fadeGain * volGain; + let maxVal = 0; let minVal = 0; for (let i = chunkStart; i < chunkEnd; i++) { @@ -847,8 +921,8 @@ if (val > maxVal) maxVal = val; if (val < minVal) minVal = val; } - maxVal *= fadeGain; - minVal *= fadeGain; + maxVal *= totalGain; + minVal *= totalGain; const yTop = mid + (minVal * (clipHeight * 0.45)); const yBottom = mid + (maxVal * (clipHeight * 0.45)); ctx.beginPath(); @@ -857,14 +931,13 @@ ctx.stroke(); } - // ── Graph Editor Automation overlay (Monotone Cubic Hermite Spline ── - // Volume: 0dB at 2/3 from bottom (piecewise for visual emphasis) + // Volume: 0dB at 1/3 from top const autoY = (node) => { const db = typeof node === 'number' ? node : node.db; - const zeroY = clipTop + (2/3) * clipHeight; + const zeroY = clipTop + (1/3) * clipHeight; return db >= 0 - ? zeroY - (db / 3) * (2/3 * clipHeight) - : zeroY + (-db / 30) * (1/3 * clipHeight); + ? zeroY - (db / 3) * (1/3 * clipHeight) + : zeroY + (-db / 30) * (2/3 * clipHeight); }; // Panning: 0 at center const autoPanY = (node) => { @@ -918,10 +991,12 @@ 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 t0 = nodes[i].time, t1 = nodes[i+1].time; 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); + const t = px / zoom; + const y = hermiteY(t, t0, y0, m0, t1, y1, m1); + if (px === Math.floor(x0) && i === 0) ctx.moveTo(px, y); else ctx.lineTo(px, y); } } ctx.stroke(); @@ -930,35 +1005,46 @@ // 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(); + const isSelected = n.time === selectedNodeTime; + ctx.fillStyle = isSelected ? '#ffebb3' : '#fff'; + ctx.beginPath(); ctx.arc(px, y, isSelected ? 6 : 4, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = isSelected ? '#fbbf24' : color; + ctx.lineWidth = isSelected ? 2.5 : 1.5; + ctx.beginPath(); ctx.arc(px, y, isSelected ? 6 : 4, 0, Math.PI * 2); ctx.stroke(); + + // Display value at node + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 12px sans-serif'; + const labelText = isPanMode + ? (n.pan > 0 ? 'R' + Math.round(n.pan * 100) : n.pan < 0 ? 'L' + Math.round(Math.abs(n.pan) * 100) : 'C') + : `${n.db >= 0 ? '+' : ''}${n.db.toFixed(1)}dB`; + ctx.fillText(labelText, px + 8, y + 4); }); }; - drawSpline(volumeNodes, autoY, '#f97316'); + drawSpline(volumeNodes, autoY, '#f43f5e'); 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) { + 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); + if (fadeInLen > 0) { + 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(); } - ctx.stroke(); // Endpoint handle (draggable) const endX = fiPx, endY = clipTop; @@ -975,21 +1061,23 @@ ctx.textAlign = 'start'; } - if (fadeOutLen > 0) { + 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); + const startX = wClip - foPx; + if (fadeOutLen > 0) { + 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(); } - ctx.stroke(); // Endpoint handle (draggable) const handleX = startX, handleY = clipTop; @@ -1044,7 +1132,7 @@ ctx.stroke(); } - }, [buffer, currentTime, selectionStart, selectionEnd, zoom, timelineWidth, color, name, speed, volumeNodes, panningNodes, fadeInLen, fadeOutLen, graphMode]); + }, [buffer, currentTime, selectionStart, selectionEnd, zoom, timelineWidth, color, name, speed, volumeNodes, panningNodes, fadeInLen, fadeOutLen, graphMode, selectedNodeTime]); const handleMouseDown = (e) => { if (e.button === 2) return; @@ -1107,14 +1195,14 @@ // 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 fiEndPx = fadeInLen * zoom; + const foStartPx = wClipPx - fadeOutLen * zoom; 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) { + if (distToFiHandle <= HANDLE_R + 6) { const handleMouseMove = (moveEvent) => { const x = moveEvent.clientX - rect.left + scrollLeft; - const t = Math.max(0, Math.min(bufDuration, x / zoom)); + const t = Math.max(0, Math.min(wallDuration, x / zoom)); if (onUpdateFade) onUpdateFade({ fadeInLen: t }); }; const handleMouseUp = () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; @@ -1122,10 +1210,10 @@ document.addEventListener('mouseup', handleMouseUp); return; } - if (fadeOutLen > 0 && distToFoHandle <= HANDLE_R + 6) { + if (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)); + const t = Math.max(0, Math.min(wallDuration, (wClipPx - x) / zoom)); if (onUpdateFade) onUpdateFade({ fadeOutLen: t }); }; const handleMouseUp = () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; @@ -1136,6 +1224,7 @@ // Tool-specific behavior if (activeTool === 'grab') { + setSelectedNodeTime(null); onPlayheadSet(startTime); const handleMouseMove = (moveEvent) => { @@ -1155,6 +1244,7 @@ } if (activeTool === 'razor') { + setSelectedNodeTime(null); onPlayheadSet(startTime); showToast(`Cut point at ${formatTime(startTime)}`, 'info'); return; @@ -1178,16 +1268,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)); + return yOff <= 1/3 + ? Math.max(0, Math.min(3, 3 * (1 - yOff * 3))) + : Math.max(-30, Math.min(0, -30 * (yOff - 1/3) * (3/2))); }; 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 volNodeY = (v) => { const z = cTop + (1/3) * cHeight; return v >= 0 ? z - (v / 3) * (1/3 * cHeight) : z + (-v / 30) * (2/3 * cHeight); }; const nearNode = curNodes.findIndex(n => { const t = Math.abs(n.time - startTime); const val = isPan ? n.pan : n.db; @@ -1198,6 +1288,7 @@ if (nearNode >= 0) { // Drag existing node + setSelectedNodeTime(curNodes[nearNode].time); let working = [...curNodes]; let dragIdx = nearNode; @@ -1212,6 +1303,8 @@ if (onUpdateNodes) onUpdateNodes(cleaned); working = [...cleaned]; dragIdx = working.findIndex(n => n.time === updated.time && (isPan ? n.pan === updated.pan : n.db === updated.db)); + // Follow the selected node during dragging + setSelectedNodeTime(updated.time); }; const handleMouseUp = () => { document.removeEventListener('mousemove', handleMouseMove); @@ -1224,6 +1317,7 @@ } if (isCtrl) { + setSelectedNodeTime(null); const pts = []; let lastKey = ''; const handleMouseMove = (moveEvent) => { @@ -1249,6 +1343,7 @@ 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) }; + setSelectedNodeTime(newNode.time); const merged = mergeNodes(curNodes, [newNode]); if (onUpdateNodes) onUpdateNodes(merged); @@ -1267,6 +1362,7 @@ if (onUpdateNodes) onUpdateNodes(cleaned); working = [...cleaned]; dragIdx = working.findIndex(n => n.time === updated.time && (isPan ? n.pan === updated.pan : n.db === updated.db)); + setSelectedNodeTime(updated.time); }; const handleMouseUp = () => { document.removeEventListener('mousemove', handleMouseMove); @@ -1280,6 +1376,7 @@ } // Select tool (default): drag to select range + setSelectedNodeTime(null); onSelectRange(startTime, startTime); onPlayheadSet(startTime); @@ -1691,10 +1788,50 @@ ); }; + const createMockAudioBufferObj = (duration, sampleRate) => { + const frameCount = sampleRate * duration; + const data = new Float32Array(frameCount); + for (let i = 0; i < frameCount; i++) { + const t = i / sampleRate; + const env = Math.exp(-Math.pow(t - 1.5, 2) / 0.15) * 0.4 + Math.exp(-Math.pow(t - 1.5, 2) / 0.05) * 0.3; + const signal = Math.sin(2 * Math.PI * 120 * t) * Math.sin(2 * Math.PI * 8 * t) + (Math.random() - 0.5) * 0.15; + data[i] = signal * env; + } + return { + duration, + sampleRate, + numberOfChannels: 1, + getChannelData: (c) => data + }; + }; + + const mockBuffer = createMockAudioBufferObj(3.0, 44100); + const App = () => { // ── State Definitions ── const [tracks, setTracks] = useState([ - { id: '1', name: 'Track 01', buffer: null, startTime: 0, height: 96, volumeDb: 0, pan: 0, muted: false, solo: false, color: '#0f766e', markers: [], serverFileId: null }, + { + id: '1', + name: 'Creak_DeepWood2.wav', + buffer: mockBuffer, + startTime: 0, + height: 96, + volumeDb: 0, + pan: 0, + muted: false, + solo: false, + color: '#0f766e', + markers: [], + serverFileId: null, + clips: [ + { + id: 'clip_1', + buffer: mockBuffer, + startTime: 0, + name: 'Creak_DeepWood2.wav' + } + ] + }, { id: '2', name: 'Track 02', buffer: null, startTime: 0, height: 96, volumeDb: 0, pan: 0, muted: false, solo: false, color: '#1d4ed8', markers: [], serverFileId: null }, ]); const [bpm, setBpm] = useState(localStorage.getItem('studio_bpm') || '120'); @@ -1845,8 +1982,47 @@ }; // ── Tab System (LOOP_EDITOR_2.md §1) ── - const [activeTab, setActiveTab] = useState('main'); - const [subTabs, setSubTabs] = useState([]); // [{id, label, trackId, startTime, endTime, buffer, effects, currentTime, selectionStart, selectionEnd, isPlaying}, ...] + const [activeTab, setActiveTab] = useState('subtab_1'); + const [subTabSelectedNodeTime, setSubTabSelectedNodeTime] = useState(null); + const [subTabs, setSubTabs] = useState([ + { + id: 'subtab_1', + label: 'Edit_Creak_Deep', + trackId: '1', + clipId: 'clip_1', + startTime: 0, + endTime: 3.0, + buffer: mockBuffer, + effects: { normalizeDb: 0, gainDb: 0, pitch: 0, speedStretch: 100 }, + currentTime: 1.0, + selectionStart: null, + selectionEnd: null, + isPlaying: false, + fadeInLen: 1.0, + fadeOutLen: 1.0, + graphMode: null, // Volume Mode + volumeNodes: [ + { time: 0.0, db: -18.0 }, + { time: 0.2, db: -15.0 }, + { time: 0.4, db: -11.0 }, + { time: 0.6, db: -7.0 }, + { time: 0.8, db: -4.0 }, + { time: 1.0, db: -2.0 }, + { time: 1.2, db: -0.5 }, + { time: 1.4, db: 0.5 }, + { time: 1.6, db: 1.5 }, + { time: 1.8, db: 2.0 }, + { time: 2.0, db: 1.8 }, + { time: 2.2, db: 1.2 }, + { time: 2.4, db: 0.0 }, + { time: 2.6, db: -3.0 }, + { time: 2.8, db: -8.0 }, + { time: 3.0, db: -15.0 } + ], + panningNodes: [], + speed: 1.0 + } + ]); // [{id, label, trackId, startTime, endTime, buffer, effects, currentTime, selectionStart, selectionEnd, isPlaying}, ...] const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2 // ── Temp Edit Tab (LOOP_EDITOR_2.md §1.2 Sub Tab) ── @@ -1953,6 +2129,8 @@ activeTabRef.current = activeTab; const subTabsRef = useRef(subTabs); subTabsRef.current = subTabs; + const subTabSelectedNodeTimeRef = useRef(null); + subTabSelectedNodeTimeRef.current = subTabSelectedNodeTime; const handleSubTabNormalize = (tabId) => { setSubTabs(prev => prev.map(st => { @@ -2222,7 +2400,25 @@ if (ctrl && e.key === 'x') { e.preventDefault(); handleSubTabCut(curTabId); return; } if (ctrl && e.key === 'c') { e.preventDefault(); handleSubTabCopy(curTabId); return; } if (ctrl && e.key === 'v') { e.preventDefault(); handleSubTabPaste(curTabId); return; } - if (e.key === 'Delete' || e.key === 'Backspace' || e.key === 'Del') { e.preventDefault(); handleSubTabDelete(curTabId); return; } + if (e.key === 'Delete' || e.key === 'Backspace' || e.key === 'Del') { + e.preventDefault(); + if (subTabSelectedNodeTimeRef.current !== null) { + const selTime = subTabSelectedNodeTimeRef.current; + setSubTabs(prev => prev.map(s => { + if (s.id !== curTabId) return s; + const curNodes = s.graphMode === 'pan' ? (s.panningNodes || []) : (s.volumeNodes || []); + const updated = curNodes.filter(n => n.time !== selTime); + return { + ...s, + [s.graphMode === 'pan' ? 'panningNodes' : 'volumeNodes']: updated + }; + })); + setSubTabSelectedNodeTime(null); + } else { + handleSubTabDelete(curTabId); + } + return; + } return; } if (ctrl && e.key === 'z' && !e.shiftKey) { e.preventDefault(); handleUndoRef.current(); return; } @@ -2508,6 +2704,58 @@ resultData[i] *= (1 + Math.cos(Math.PI * t / fadeSamples)) / 2; } } + + // Graph Editor volume automation spline (Monotone Cubic Hermite Spline, 16_FIX_GRAPH.md §2.1) + const volNodes = subTab.volumeNodes || []; + if (volNodes.length > 0) { + const sortedNodes = [...volNodes].sort((a, b) => a.time - b.time); + const computeHermiteTangents = (pts) => { + 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 = (pts[i].db - pts[i-1].db) / hP; + const sN = (pts[i+1].db - pts[i].db) / hN; + m[i] = (sP + sN) / 2; + } + m[0] = n > 1 ? (pts[1].db - pts[0].db) / (pts[1].time - pts[0].time) : 0; + m[n-1] = n > 1 ? (pts[n-1].db - pts[n-2].db) / (pts[n-1].time - pts[n-2].time) : 0; + return m; + }; + + const tangents = computeHermiteTangents(sortedNodes); + + const getVolumeGainAtTime = (t) => { + if (sortedNodes.length === 1) { + return Math.pow(10, sortedNodes[0].db / 20); + } + if (t <= sortedNodes[0].time) { + return Math.pow(10, sortedNodes[0].db / 20); + } + if (t >= sortedNodes[sortedNodes.length - 1].time) { + return Math.pow(10, sortedNodes[sortedNodes.length - 1].db / 20); + } + for (let i = 0; i < sortedNodes.length - 1; i++) { + const n1 = sortedNodes[i]; + const n2 = sortedNodes[i+1]; + if (t >= n1.time && t <= n2.time) { + const h = n2.time - n1.time; + if (h <= 0) return Math.pow(10, n1.db / 20); + const frac = (t - n1.time) / h; + const frac2 = frac * frac, frac3 = frac2 * frac; + const db = (2*frac3 - 3*frac2 + 1) * n1.db + (frac3 - 2*frac2 + frac) * h * tangents[i] + (-2*frac3 + 3*frac2) * n2.db + (frac3 - frac2) * h * tangents[i+1]; + return Math.pow(10, db / 20); + } + } + return 1.0; + }; + for (let i = 0; i < resultData.length; i++) { + const t = i / sr; + resultData[i] *= getVolumeGainAtTime(t); + } + } 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; } @@ -5093,10 +5341,26 @@
- - - -
@@ -5684,22 +5968,44 @@ fadeInLen={st.fadeInLen || 0} fadeOutLen={st.fadeOutLen || 0} graphMode={st.graphMode} + selectedNodeTime={subTabSelectedNodeTime} + setSelectedNodeTime={setSubTabSelectedNodeTime} 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, - speed: newSpeed, - label: s.label.replace(/\s\(\d+%\)$/, '') + ` (${Math.round(newSpeed * 100)}%)` - } : s)); + setSubTabs(prev => prev.map(s => { + if (s.id !== st.id) return s; + const oldSpeed = s.speed || 1.0; + const ratio = oldSpeed / newSpeed; + const newVolumeNodes = (s.volumeNodes || []).map(n => ({ + ...n, + time: n.time * ratio + })); + const newPanningNodes = (s.panningNodes || []).map(n => ({ + ...n, + time: n.time * ratio + })); + return { + ...s, + speed: newSpeed, + volumeNodes: newVolumeNodes, + panningNodes: newPanningNodes, + fadeInLen: (s.fadeInLen || 0) * ratio, + fadeOutLen: (s.fadeOutLen || 0) * ratio, + currentTime: (s.currentTime || 0) * ratio, + label: s.label.replace(/\s\(\d+%\)$/, '') + ` (${Math.round(newSpeed * 100)}%)` + }; + })); const n = activeTrackNodesRef.current[st.trackId]; if (n && n.source) n.source.playbackRate.value = newSpeed; // Reset time refs to prevent playhead jump when speed changes mid-playback const ctx = getAudioContext(); const elapsed = ctx.currentTime - startAudioTimeRef.current; - startBufferOffsetRef.current = startBufferOffsetRef.current + elapsed * (st.speed || 1.0); - startOffsetTimeRef.current = startOffsetTimeRef.current + elapsed; + const oldSpeed = st.speed || 1.0; + const ratio = oldSpeed / newSpeed; + startBufferOffsetRef.current = startBufferOffsetRef.current + elapsed * oldSpeed; + startOffsetTimeRef.current = (startOffsetTimeRef.current + elapsed) * ratio; startAudioTimeRef.current = ctx.currentTime; }} />