fix: lỗi vẽ volume và panning trên waveform

This commit is contained in:
2026-07-19 21:21:54 +07:00
parent 4ca90f15ca
commit 283555c78e
2 changed files with 490 additions and 76 deletions
+108
View File
@@ -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
+382 -76
View File
@@ -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 @@
<div className="w-[1px] h-5 bg-zinc-800 mx-0.5"></div>
<button onClick={() => setCurrentTime(0)}
<button onClick={() => {
if (activeTab !== 'main') {
setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, currentTime: 0 } : s));
} else {
setCurrentTime(0);
}
}}
className="w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition"
title="Quay lại đầu"><i data-lucide="skip-back" className="w-3.5 h-3.5"></i></button>
<button onClick={() => { if (selLeft !== null) setCurrentTime(selLeft); }}
<button onClick={() => {
if (activeTab !== 'main') {
setSubTabs(prev => prev.map(s => {
if (s.id !== activeTab) return s;
const left = s.selectionStart !== null && s.selectionEnd !== null ? Math.min(s.selectionStart, s.selectionEnd) : null;
return left !== null ? { ...s, currentTime: left } : s;
}));
} else {
if (selLeft !== null) setCurrentTime(selLeft);
}
}}
className="w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition"
title="Đầu vùng chọn"><i data-lucide="step-back" className="w-3.5 h-3.5"></i></button>
<button onClick={handlePlayPause}
@@ -5111,10 +5375,30 @@
<button onClick={handleStop}
className="w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition"
title="Stop"><i data-lucide="square" className="w-3.5 h-3.5 fill-current"></i></button>
<button onClick={() => { if (selRight !== null) setCurrentTime(selRight); }}
<button onClick={() => {
if (activeTab !== 'main') {
setSubTabs(prev => prev.map(s => {
if (s.id !== activeTab) return s;
const right = s.selectionStart !== null && s.selectionEnd !== null ? Math.max(s.selectionStart, s.selectionEnd) : null;
return right !== null ? { ...s, currentTime: right } : s;
}));
} else {
if (selRight !== null) setCurrentTime(selRight);
}
}}
className="w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition"
title="Cuối vùng chọn"><i data-lucide="step-forward" className="w-3.5 h-3.5"></i></button>
<button onClick={() => setCurrentTime(maxDuration)}
<button onClick={() => {
if (activeTab !== 'main') {
setSubTabs(prev => prev.map(s => {
if (s.id !== activeTab) return s;
const duration = s.buffer ? s.buffer.duration / (s.speed || 1.0) : 0;
return { ...s, currentTime: duration };
}));
} else {
setCurrentTime(maxDuration);
}
}}
className="w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition"
title="Đến cuối"><i data-lucide="skip-forward" className="w-3.5 h-3.5"></i></button>
<div className="w-[1px] h-5 bg-zinc-800 mx-0.5"></div>
@@ -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;
}}
/>