fix: lỗi vẽ volume và panning trên waveform
This commit is contained in:
+382
-76
@@ -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;
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user