Files
SonicForgeStudio/app/templates/index.html
T

6398 lines
374 KiB
HTML

<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SonicForge Studio - Professional DAW Editor</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.26.0/babel.min.js"></script>
<style>
body {
background-color: #1a1a1a;
color: #c0c0c0;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
overflow: hidden;
user-select: none;
}
.daw-bg { background-color: #1e1e1e; }
.daw-panel { background-color: #262626; }
.daw-header { background-color: #2e2e2e; }
.daw-border { border-color: #181818; }
.daw-track-active { background-color: #333333; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: #141414; }
::-webkit-scrollbar-thumb { background: #3a3a3a; border: 2px solid #141414; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #4a4a4a; }
.knob-container { position: relative; width: 28px; height: 28px; }
.knob-dial { transform-origin: center; transition: transform 0.1s ease; }
.selection-interactive-box {
min-width: 4px;
}
.no-scrollbar {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE 10+ */
}
.no-scrollbar::-webkit-scrollbar {
display: none; /* Safari and Chrome */
}
</style>
</head>
<body class="h-screen w-screen flex flex-col">
<div id="root" class="h-full w-full flex flex-col"></div>
<script type="text/babel">
const { useState, useRef, useEffect, useMemo } = React;
// ── FastAPI Backend Configuration ──
const API_BASE_URL = window.location.origin;
const API_AUDIO = `${API_BASE_URL}/api/v1/audio`;
const API_MULTITRACK = `${API_BASE_URL}/api/v1/multitrack`;
const API_TASKS = `${API_BASE_URL}/api/v1/audio/tasks`;
// Storage for server-side file IDs mapped to track IDs
let serverFileIdMap = {};
let audioCtx;
function getAudioContext() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
return audioCtx;
}
const formatTime = (secs) => {
if (isNaN(secs) || secs < 0) return "0:00.000";
const m = Math.floor(secs / 60);
const s = Math.floor(secs % 60);
const ms = Math.floor((secs % 1) * 1000).toString().padStart(3, '0');
return `${m}:${s.toString().padStart(2, '0')}.${ms}`;
};
const formatTimeSimple = (secs) => {
if (isNaN(secs) || secs < 0) return "0.00s";
return `${secs.toFixed(2)}s`;
};
const formatBeat = (secs, bpmVal) => {
if (isNaN(secs) || secs < 0) return "1.1.1";
const beatDuration = 60 / bpmVal;
const barDuration = beatDuration * 4;
const bar = Math.floor(secs / barDuration) + 1;
const beat = Math.floor((secs % barDuration) / beatDuration) + 1;
const sub = Math.floor((secs % beatDuration) / (beatDuration / 4)) + 1;
return `${bar}.${beat}.${sub}`;
};
const getBeatMarkers = (maxDur, bpmVal) => {
const beatDuration = 60 / bpmVal;
const barDuration = beatDuration * 4;
const markers = [];
for (let t = 0; t <= maxDur; t += beatDuration) {
const isBar = Math.abs(t % barDuration) < 0.001 || Math.abs(t % barDuration - barDuration) < 0.001;
markers.push({ time: t, isBar, beatNum: Math.floor(t / beatDuration) + 1 });
}
return markers;
};
const findZeroCrossing = (buffer, targetTime) => {
if (!buffer) return targetTime;
const sampleRate = buffer.sampleRate;
const data = buffer.getChannelData(0);
const targetSample = Math.floor(targetTime * sampleRate);
const windowSize = Math.floor(0.04 * sampleRate);
const start = Math.max(0, targetSample - windowSize);
const end = Math.min(data.length - 2, targetSample + windowSize);
let bestSample = targetSample;
let minDistance = Infinity;
for (let i = start; i <= end; i++) {
if ((data[i] >= 0 && data[i+1] <= 0) || (data[i] <= 0 && data[i+1] >= 0)) {
const dist = Math.abs(i - targetSample);
if (dist < minDistance) {
minDistance = dist;
bestSample = i;
}
}
}
return bestSample / sampleRate;
};
const VolumeKnob = ({ value, onChange, min = 0, max = 1 }) => {
const [isDragging, setIsDragging] = useState(false);
const startY = useRef(0);
const startValue = useRef(0);
const rotation = useMemo(() => {
const percent = (value - min) / (max - min);
return -135 + percent * 270;
}, [value, min, max]);
const handleMouseDown = (e) => {
setIsDragging(true);
startY.current = e.clientY;
startValue.current = value;
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
const handleMouseMove = (e) => {
const deltaY = startY.current - e.clientY;
const sensitivity = 0.005;
const newValue = Math.max(min, Math.min(max, startValue.current + deltaY * sensitivity));
onChange(parseFloat(newValue.toFixed(2)));
};
const handleMouseUp = () => {
setIsDragging(false);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
return (
<div
className="knob-container cursor-ns-resize flex flex-col items-center"
onMouseDown={handleMouseDown}
title={`Volume: ${Math.round(value * 100)}%`}
>
<svg className="w-7 h-7" viewBox="0 0 40 40">
<circle cx="20" cy="20" r="16" fill="#141414" stroke="#444" strokeWidth="2" />
<g transform={`rotate(${rotation} 20 20)`} className="knob-dial">
<line x1="20" y1="20" x2="20" y2="6" stroke="#ef4444" strokeWidth="3" strokeLinecap="round" />
</g>
</svg>
</div>
);
};
const WaveformLane = ({
track,
zoom,
timelineWidth,
onSelectRange,
onPlayheadSet,
isSelected,
onSelectTrack,
markers,
selectionMode,
localSelectionTrackId,
localSelLeft,
localSelRight,
onTrackLaneMouseDown,
onContextMenu,
onClipDragStart,
onClipStretchStart,
onSelectionEdgeDragStart,
setSelectedClipId,
activeTool,
onSplitTrackAtTime,
onEditClipInSubTab,
snapValue,
bpm,
}) => {
const canvasRef = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const width = timelineWidth;
const height = canvas.parentElement.clientHeight;
const maxW = 10000;
const useW = Math.min(timelineWidth, maxW);
canvas.width = useW * dpr;
canvas.height = height * dpr;
ctx.scale(dpr * (useW / timelineWidth), dpr);
ctx.fillStyle = isSelected ? '#2a2a2a' : (track.id % 2 === 0 ? '#181818' : '#1d1d1d');
ctx.fillRect(0, 0, width, height);
// Grid lines based on Snap value
ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)';
ctx.lineWidth = 1;
const totalSec = width / zoom;
let gridSpacing = 1.0; // default 1 second
if (snapValue && snapValue !== 'free') {
const beatDuration = 60 / parseFloat(bpm || 120);
let divisor = 1;
if (snapValue === '1') divisor = 1;
else if (snapValue === '1/2') divisor = 0.5;
else if (snapValue === '1/4') divisor = 0.25;
else if (snapValue === '1/8') divisor = 0.125;
else if (snapValue === '1/16') divisor = 0.0625;
else if (snapValue === '1/32') divisor = 0.03125;
gridSpacing = beatDuration * divisor;
} else {
gridSpacing = 60 / parseFloat(bpm || 120); // default to 1 beat
}
// Guard: if lines are too close, scale grid spacing by multiples of 2
let drawSpacing = gridSpacing;
while (drawSpacing * zoom < 10) {
drawSpacing *= 2;
}
for (let s = 0; s <= totalSec; s += drawSpacing) {
const x = s * zoom;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
// Draw waveform lane
const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
id: 'default',
buffer: track.buffer,
startTime: track.startTime || 0,
name: track.name,
speed: track.speed || 1.0
}] : []);
if (clips.length > 0) {
clips.forEach(clip => {
const data = clip.buffer.getChannelData(0);
const sampleRate = clip.buffer.sampleRate;
const totalSamples = data.length;
const originalDuration = totalSamples / sampleRate;
const clipSpeed = clip.speed || 1.0;
const duration = originalDuration / clipSpeed;
const xStart = (clip.startTime || 0) * zoom;
const wClip = duration * zoom;
const xEnd = xStart + wClip;
// 1. Draw Clip Layer Background & Border
ctx.fillStyle = track.color ? track.color + '22' : 'rgba(6, 182, 212, 0.12)';
ctx.strokeStyle = track.color || '#06b6d4';
ctx.lineWidth = 1.5;
const clipTop = 8;
const clipHeight = height - 16;
ctx.beginPath();
if (ctx.roundRect) {
ctx.roundRect(xStart, clipTop, wClip, clipHeight, 4);
} else {
ctx.rect(xStart, clipTop, wClip, clipHeight);
}
ctx.fill();
ctx.stroke();
// 2. Draw Clip Label & Speed Label (Speed Math: D/D' * 100)
ctx.fillStyle = '#e4e4e7';
ctx.font = 'bold 9px sans-serif';
ctx.fillText(clip.name || 'Clip', xStart + 8, clipTop + 14);
if (clipSpeed !== 1.0) {
ctx.fillStyle = '#fbbf24'; // Yellow color
ctx.font = 'bold 8px sans-serif';
ctx.fillText(`Speed: ${(clipSpeed * 100).toFixed(1)}%`, xStart + 8, clipTop + 24);
}
// Draw markers
if (markers && markers.length > 0) {
markers.forEach(m => {
const mx = m.time * zoom;
ctx.fillStyle = '#fbbf24';
ctx.fillRect(mx - 1, 0, 2, height);
ctx.fillStyle = 'rgba(251, 191, 36, 0.1)';
ctx.fillRect(mx - 1, 0, 2, height);
});
}
// 3. Peak waveform drawing only within clip bounds (speed adjusted)
ctx.strokeStyle = isSelected ? '#22d3ee' : '#a7f3d0';
ctx.lineWidth = 1;
const drawXStart = Math.max(0, Math.floor(xStart));
const drawXEnd = Math.min(width, Math.ceil(xEnd));
const samplesPerPixel = (sampleRate / zoom) * clipSpeed;
for (let px = drawXStart; px < drawXEnd; px++) {
const timeInClip = ((px - xStart) / zoom) * clipSpeed;
const sampleIdx = Math.floor(timeInClip * sampleRate);
const chunkSize = Math.max(1, Math.floor(samplesPerPixel));
const chunkStart = Math.max(0, sampleIdx - Math.floor(chunkSize / 2));
const chunkEnd = Math.min(totalSamples, chunkStart + chunkSize);
let maxVal = 0;
for (let i = chunkStart; i < chunkEnd; i++) {
const abs = Math.abs(data[i]);
if (abs > maxVal) maxVal = abs;
}
const mid = height / 2;
const peakHeight = maxVal * (clipHeight * 0.45);
ctx.beginPath();
ctx.moveTo(px, mid - peakHeight);
ctx.lineTo(px, mid + peakHeight);
ctx.stroke();
}
});
} else {
ctx.fillStyle = '#444';
ctx.font = '12px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Kéo thả hoặc tải file âm thanh vào Track này', width / 2, height / 2);
}
// Selection highlight - local selection on this track
if (selectionMode === 'local' && localSelectionTrackId === track.id &&
localSelLeft !== null && localSelRight !== null && localSelRight > localSelLeft) {
const hlLeft = localSelLeft * zoom;
const hlWidth = (localSelRight - localSelLeft) * zoom;
ctx.fillStyle = 'rgba(245, 158, 11, 0.15)';
ctx.fillRect(hlLeft, 0, hlWidth, height);
ctx.strokeStyle = '#f59e0b';
ctx.lineWidth = 1.5;
ctx.strokeRect(hlLeft, 0, hlWidth, height);
}
}, [track, zoom, timelineWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight, snapValue, bpm]);
return (
<canvas
ref={canvasRef}
className="w-full h-full cursor-crosshair"
onMouseMove={(e) => {
if (!canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const wrapper = canvasRef.current?.parentElement?.parentElement;
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
const x = e.clientX - rect.left + scrollLeft;
const time = x / zoom;
const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
id: 'default',
buffer: track.buffer,
startTime: track.startTime || 0,
name: track.name,
speed: track.speed || 1.0
}] : []);
// Check if hovering near local selection boundaries of this track
const isLocal = selectionMode === 'local' && localSelectionTrackId === track.id;
if (isLocal && localSelLeft !== null && localSelRight !== null) {
const leftPx = localSelLeft * zoom;
const rightPx = localSelRight * zoom;
const distToLeft = Math.abs(x - leftPx);
const distToRight = Math.abs(x - rightPx);
if (distToLeft <= 5 || distToRight <= 5) {
canvasRef.current.style.cursor = 'ew-resize';
return;
}
}
// Check if hovering near right edge of a clip for time-stretching (Alt key required)
const toleranceSec = 8 / zoom;
const rightEdgeClip = clips.find(c => {
const duration = c.buffer.duration / (c.speed || 1.0);
return Math.abs(time - (c.startTime + duration)) <= toleranceSec;
});
if (rightEdgeClip && e.altKey) {
canvasRef.current.style.cursor = 'ew-resize';
return;
}
const hoveredClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
const isOverClip = !!hoveredClip;
if (activeTool === 'pen') {
canvasRef.current.style.cursor = isOverClip ? 'copy' : 'not-allowed';
} else if (activeTool === 'grab') {
canvasRef.current.style.cursor = isOverClip ? 'grab' : 'default';
} else if (activeTool === 'razor') {
canvasRef.current.style.cursor = isOverClip ? 'cell' : 'not-allowed';
} else {
// select tool
canvasRef.current.style.cursor = (isOverClip && (e.altKey || e.ctrlKey)) ? 'grab' : 'crosshair';
}
}}
onMouseDown={(e) => {
// Ignore right-click for local selection drag (context menu handles it)
if (e.button === 2) return;
const rect = canvasRef.current.getBoundingClientRect();
const wrapper = canvasRef.current?.parentElement?.parentElement;
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
const x = e.clientX - rect.left + scrollLeft;
const time = Math.max(0, x / zoom);
onSelectTrack(track.id);
const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
id: 'default',
buffer: track.buffer,
startTime: track.startTime || 0,
name: track.name,
speed: track.speed || 1.0
}] : []);
// Check if dragging selection boundaries (local mode)
const isLocal = selectionMode === 'local' && localSelectionTrackId === track.id;
if (isLocal && localSelLeft !== null && localSelRight !== null) {
const leftPx = localSelLeft * zoom;
const rightPx = localSelRight * zoom;
const distToLeft = Math.abs(x - leftPx);
const distToRight = Math.abs(x - rightPx);
if (distToLeft <= 5) {
e.preventDefault();
e.stopPropagation();
if (onSelectionEdgeDragStart) onSelectionEdgeDragStart(e, track.id, 'left');
return;
} else if (distToRight <= 5) {
e.preventDefault();
e.stopPropagation();
if (onSelectionEdgeDragStart) onSelectionEdgeDragStart(e, track.id, 'right');
return;
}
}
// Check if time-stretching (Alt + Right Edge)
const toleranceSec = 8 / zoom;
const rightEdgeClip = clips.find(c => {
const duration = c.buffer.duration / (c.speed || 1.0);
return Math.abs(time - (c.startTime + duration)) <= toleranceSec;
});
if (rightEdgeClip && e.altKey) {
e.preventDefault();
e.stopPropagation();
if (onClipStretchStart) {
onClipStretchStart(track.id, rightEdgeClip.id, time);
}
return;
}
const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
// Set selected clip ID
if (clickedClip) {
setSelectedClipId({ trackId: track.id, clipId: clickedClip.id === 'default' ? 'default_' + track.id : clickedClip.id });
} else {
setSelectedClipId(null);
}
if (activeTool === 'pen') {
if (clickedClip) {
e.preventDefault();
e.stopPropagation();
if (onEditClipInSubTab) {
onEditClipInSubTab(track.id, clickedClip.id);
}
}
return;
}
if (activeTool === 'razor') {
if (clickedClip) {
e.preventDefault();
e.stopPropagation();
if (onSplitTrackAtTime) {
onSplitTrackAtTime(track.id, clickedClip.id, time);
}
}
return;
}
if (activeTool === 'grab') {
if (clickedClip) {
e.preventDefault();
e.stopPropagation();
if (onClipDragStart) {
onClipDragStart(track.id, clickedClip.id, time - clickedClip.startTime, e.ctrlKey);
}
} else {
onPlayheadSet(time);
}
return;
}
// Check for click drag clip (Alt to move, Ctrl to duplicate)
if (clickedClip && (e.altKey || e.ctrlKey)) {
e.preventDefault();
e.stopPropagation();
if (onClipDragStart) {
onClipDragStart(track.id, clickedClip.id, time - clickedClip.startTime, e.ctrlKey);
}
return;
}
// Check if Ctrl+Click outside active local selection to clear
if (e.ctrlKey) {
const minSel = localSelLeft !== null && localSelRight !== null ? Math.min(localSelLeft, localSelRight) : null;
const maxSel = localSelLeft !== null && localSelRight !== null ? Math.max(localSelLeft, localSelRight) : null;
const isInsideSelection = isLocal && minSel !== null && maxSel !== null && time >= minSel && time <= maxSel;
if (!isInsideSelection) {
clearLocalSelection();
setSelectionMode(null);
}
return;
}
onPlayheadSet(time);
if (onTrackLaneMouseDown) {
onTrackLaneMouseDown(track.id, time, e);
}
e.stopPropagation();
}}
onDoubleClick={(e) => {
const rect = canvasRef.current.getBoundingClientRect();
const wrapper = canvasRef.current?.parentElement?.parentElement;
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
const x = e.clientX - rect.left + scrollLeft;
const time = Math.max(0, x / zoom);
const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
id: 'default',
buffer: track.buffer,
startTime: track.startTime || 0,
name: track.name,
speed: track.speed || 1.0
}] : []);
const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0));
if (clickedClip) {
e.preventDefault();
e.stopPropagation();
if (onEditClipInSubTab) {
onEditClipInSubTab(track.id, clickedClip.id);
}
}
}}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onSelectTrack(track.id);
const rect = canvasRef.current.getBoundingClientRect();
const wrapper = canvasRef.current?.parentElement?.parentElement;
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
const x = e.clientX - rect.left + scrollLeft;
const time = Math.max(0, x / zoom);
if (onContextMenu) onContextMenu(e, track.id, time);
}}
/>
);
};
const TempoTrackLane = ({ bpm, zoom, timelineWidth, onPlayheadSet, snapValue }) => {
const canvasRef = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const width = timelineWidth;
const parent = canvas.parentElement;
const height = parent ? parent.clientHeight : 40;
const maxW = 10000;
const useW = Math.min(timelineWidth, maxW);
canvas.width = useW * dpr;
canvas.height = height * dpr;
ctx.scale(dpr * (useW / timelineWidth), dpr);
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, width, height);
const beatDuration = 60 / bpm;
const barDuration = beatDuration * 4;
const totalSec = width / zoom;
for (let t = 0; t <= totalSec; t += beatDuration) {
const beatNum = Math.floor(t / beatDuration) + 1;
const isBar = beatNum % 4 === 1;
const x = t * zoom;
if (isBar) {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
ctx.font = 'bold 9px Inter, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(`${Math.ceil(beatNum / 4)}`, x + 3, 11);
} else {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
}
// Draw snap sub-ticks at the bottom
if (snapValue && snapValue !== 'free') {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
ctx.lineWidth = 0.8;
let divisor = 1;
if (snapValue === '1') divisor = 1;
else if (snapValue === '1/2') divisor = 0.5;
else if (snapValue === '1/4') divisor = 0.25;
else if (snapValue === '1/8') divisor = 0.125;
else if (snapValue === '1/16') divisor = 0.0625;
else if (snapValue === '1/32') divisor = 0.03125;
const snapInterval = beatDuration * divisor;
if (snapInterval * zoom >= 4) {
for (let t = 0; t <= totalSec; t += snapInterval) {
const onBeat = Math.abs((t / beatDuration) - Math.round(t / beatDuration)) < 0.001;
if (!onBeat) {
const x = t * zoom;
ctx.beginPath();
ctx.moveTo(x, height - 6);
ctx.lineTo(x, height);
ctx.stroke();
}
}
}
}
ctx.fillStyle = 'rgba(255, 255, 255, 0.35)';
ctx.font = 'bold 10px Inter, sans-serif';
ctx.textAlign = 'right';
ctx.fillText(`${bpm} BPM`, width - 6, 12);
}, [bpm, zoom, timelineWidth, snapValue]);
return (
<canvas
ref={canvasRef}
className="w-full h-full cursor-crosshair"
onMouseDown={(e) => {
const wrapper = canvasRef.current?.parentElement?.parentElement;
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
const rect = canvasRef.current.getBoundingClientRect();
const x = e.clientX - rect.left + scrollLeft;
const time = Math.max(0, x / zoom);
onPlayheadSet(time);
e.stopPropagation();
}}
/>
);
};
// ── 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, selectedNodeTime, setSelectedNodeTime }) => {
const canvasRef = useRef(null);
const isStretchingRef = useRef(false);
const stretchStartRef = useRef({ mouseX: 0, originalDuration: 0, originalSpeed: 1.0 });
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !buffer) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
const w = timelineWidth;
const h = rect.height;
const maxW = 10000;
const useW = Math.min(timelineWidth, maxW);
canvas.width = useW * dpr;
canvas.height = h * dpr;
ctx.scale(dpr * (useW / timelineWidth), dpr);
ctx.fillStyle = '#181818';
ctx.fillRect(0, 0, w, h);
const data = buffer.getChannelData(0);
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 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 getVolumeDbAtTime = (t) => {
const volNodes = volumeNodes || [];
if (volNodes.length === 0) return 0.0;
const sortedNodes = [...volNodes].sort((a, b) => a.time - b.time);
if (sortedNodes.length === 1) return sortedNodes[0].db;
if (t <= sortedNodes[0].time) return sortedNodes[0].db;
if (t >= sortedNodes[sortedNodes.length - 1].time) return sortedNodes[sortedNodes.length - 1].db;
const tangents = computeHermiteTangents(sortedNodes);
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 n1.db;
const frac = (t - n1.time) / h;
const frac2 = frac * frac, frac3 = frac2 * frac;
return (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 0.0;
};
const getVolumeGainAtTime = (t) => {
return Math.pow(10, getVolumeDbAtTime(t) / 20);
};
const computeHermiteTangentsForPan = (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].pan - pts[i-1].pan) / hP;
const sN = (pts[i+1].pan - pts[i].pan) / hN;
m[i] = (sP + sN) / 2;
}
m[0] = n > 1 ? (pts[1].pan - pts[0].pan) / (pts[1].time - pts[0].time) : 0;
m[n-1] = n > 1 ? (pts[n-1].pan - pts[n-2].pan) / (pts[n-1].time - pts[n-2].time) : 0;
return m;
};
const getPanningValueAtTime = (t) => {
const panNodes = panningNodes || [];
if (panNodes.length === 0) return 0;
const sortedNodes = [...panNodes].sort((a, b) => a.time - b.time);
if (sortedNodes.length === 1) return Math.round(sortedNodes[0].pan * 100);
if (t <= sortedNodes[0].time) return Math.round(sortedNodes[0].pan * 100);
if (t >= sortedNodes[sortedNodes.length - 1].time) return Math.round(sortedNodes[sortedNodes.length - 1].pan * 100);
const tangents = computeHermiteTangentsForPan(sortedNodes);
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.round(n1.pan * 100);
const frac = (t - n1.time) / h;
const frac2 = frac * frac, frac3 = frac2 * frac;
const val = (2*frac3 - 3*frac2 + 1) * n1.pan + (frac3 - 2*frac2 + frac) * h * tangents[i] + (-2*frac3 + 3*frac2) * n2.pan + (frac3 - frac2) * h * tangents[i+1];
return Math.round(val * 100);
}
}
return 0;
};
// Draw clip container (like main session clips)
const xStart = 0;
const wClip = (buffer.duration / speed) * zoom; // speed-adjusted width
const xEnd = xStart + wClip;
const clipTop = 8;
const clipHeight = h - 16;
const clipColor = color || '#06b6d4';
ctx.fillStyle = clipColor + '22';
ctx.strokeStyle = clipColor;
ctx.lineWidth = 1.5;
if (ctx.roundRect) {
ctx.beginPath();
ctx.roundRect(xStart, clipTop, wClip, clipHeight, 4);
ctx.fill();
ctx.stroke();
} else {
ctx.fillRect(xStart, clipTop, wClip, clipHeight);
ctx.strokeRect(xStart, clipTop, wClip, clipHeight);
}
// Draw clip label with speed %
ctx.fillStyle = '#e4e4e7';
ctx.font = 'bold 10px sans-serif';
let displayName = name || 'Audio Clip';
if (speed !== 1.0) {
displayName += ` (${Math.round(speed * 100)}%)`;
}
ctx.fillText(displayName, xStart + 8, clipTop + 14);
// ── Graph Grid & Axes ──
const drawGrid = true;
if (drawGrid) {
// Background fill
ctx.fillStyle = '#181818';
ctx.fillRect(xStart, clipTop, wClip, clipHeight);
// Vertical grid lines (time markers)
ctx.strokeStyle = '#2a2a2a';
ctx.lineWidth = 0.5;
ctx.setLineDash([]);
const timeStep = Math.max(0.1, Math.ceil((buffer.duration / 20) * 10) / 10);
for (let t = 0; t <= buffer.duration; t += timeStep) {
const px = t * zoom;
ctx.beginPath(); ctx.moveTo(px, clipTop); ctx.lineTo(px, clipTop + clipHeight); ctx.stroke();
}
// 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 = '#2a2a2a';
ctx.lineWidth = 0.5;
ctx.beginPath(); ctx.moveTo(xStart, y); ctx.lineTo(xStart + wClip, y); ctx.stroke();
}
} else {
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 = '#2a2a2a';
ctx.lineWidth = 0.5;
ctx.beginPath(); ctx.moveTo(xStart, y); ctx.lineTo(xStart + wClip, y); ctx.stroke();
}
}
// Y-axis labels (left side)
ctx.fillStyle = '#71717a';
ctx.font = '7px monospace';
ctx.textAlign = 'right';
if (isPanMode) {
ctx.fillText('L100', xStart - 2, clipTop + 8);
ctx.fillText('R50', xStart - 2, clipTop + clipHeight * 0.25 + 2);
ctx.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 {
ctx.fillText('+3dB', xStart - 2, clipTop + 8);
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);
}
ctx.textAlign = 'start';
}
// Drag hint labels for fade endpoints
ctx.fillStyle = '#71717a';
ctx.font = '8px sans-serif';
ctx.fillText('Kéo FI/FO trên đường cong để điều chỉnh', 8, clipTop + clipHeight + 12);
// 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;
const modeBtnX = wClip - modeBtnW - 4;
const modeBtnY = clipTop + clipHeight - modeBtnH - 2;
const isPanMode = graphMode === 'pan';
ctx.fillStyle = isPanMode ? 'rgba(168, 85, 247, 0.5)' : 'rgba(6, 182, 212, 0.5)';
ctx.beginPath();
ctx.roundRect(modeBtnX, modeBtnY, modeBtnW, modeBtnH, 3);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = 'bold 7px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(isPanMode ? 'PAN' : 'VOL', modeBtnX + modeBtnW / 2, modeBtnY + 10);
ctx.textAlign = 'start';
// Draw waveform inside clip (speed-adjusted) with fade envelope applied
const drawXStart = Math.max(0, Math.floor(xStart));
const drawXEnd = Math.min(w, Math.ceil(xEnd));
const samplesPerPixel = (buffer.sampleRate / zoom) * speed;
const bufDur = buffer.duration;
ctx.strokeStyle = '#6ee7b7';
ctx.lineWidth = 1;
const mid = h / 2;
for (let px = drawXStart; px < drawXEnd; px++) {
const timeInClip = (px / zoom) * speed;
const sampleIdx = Math.floor(timeInClip * buffer.sampleRate);
const chunkSize = Math.max(1, Math.floor(samplesPerPixel));
const chunkStart = Math.max(0, sampleIdx - Math.floor(chunkSize / 2));
const chunkEnd = Math.min(len, chunkStart + chunkSize);
// Compute fade gain at this position
let fadeGain = 1;
if (fadeInLen > 0 && timeInClip < fadeInLen) {
fadeGain = (1 - Math.cos(Math.PI * timeInClip / fadeInLen)) / 2;
} else if (fadeOutLen > 0 && timeInClip > bufDur - fadeOutLen) {
const t = (timeInClip - (bufDur - fadeOutLen)) / fadeOutLen;
fadeGain = (1 + Math.cos(Math.PI * t)) / 2;
}
// Compute volume automation gain in real-time
const volGain = getVolumeGainAtTime(px / zoom);
const totalGain = fadeGain * volGain;
let maxVal = 0;
let minVal = 0;
for (let i = chunkStart; i < chunkEnd; i++) {
const val = data[i];
if (val > maxVal) maxVal = val;
if (val < minVal) minVal = val;
}
maxVal *= totalGain;
minVal *= totalGain;
const yTop = mid + (minVal * (clipHeight * 0.45));
const yBottom = mid + (maxVal * (clipHeight * 0.45));
ctx.beginPath();
ctx.moveTo(px, yTop);
ctx.lineTo(px, yBottom);
ctx.stroke();
}
// Volume: 0dB at 1/3 from top
const autoY = (node) => {
const db = typeof node === 'number' ? node : node.db;
const zeroY = clipTop + (1/3) * clipHeight;
return db >= 0
? zeroY - (db / 3) * (1/3 * clipHeight)
: zeroY + (-db / 30) * (2/3 * clipHeight);
};
// Panning: 0 at center
const autoPanY = (node) => {
const pan = typeof node === 'number' ? node : node.pan;
return clipTop + ((1 - (pan + 1) / 2) * clipHeight);
};
// Helper: compute tangents for monotone Hermite spline
const computeTangents = (pts, yFn) => {
const n = pts.length;
if (n < 2) return [];
const m = new Array(n);
for (let i = 1; i < n - 1; i++) {
const hP = pts[i].time - pts[i-1].time;
const hN = pts[i+1].time - pts[i].time;
const sP = (yFn(pts[i]) - yFn(pts[i-1])) / hP;
const sN = (yFn(pts[i+1]) - yFn(pts[i])) / hN;
m[i] = (sP + sN) / 2;
}
m[0] = n > 1 ? (yFn(pts[1]) - yFn(pts[0])) / (pts[1].time - pts[0].time) : 0;
m[n-1] = n > 1 ? (yFn(pts[n-1]) - yFn(pts[n-2])) / (pts[n-1].time - pts[n-2].time) : 0;
return m;
};
// Helper: evaluate Hermite at pixel position px
const hermiteY = (px, x0, y0, m0, x1, y1, m1) => {
const h = x1 - x0;
if (h <= 0) return y0;
const t = (px - x0) / h;
const t2 = t * t, t3 = t2 * t;
return (2*t3 - 3*t2 + 1) * y0 + (t3 - 2*t2 + t) * h * m0 + (-2*t3 + 3*t2) * y1 + (t3 - t2) * h * m1;
};
// Draw automation curve with Hermite spline
const drawSpline = (nodes, yFn, color, lineDash) => {
if (nodes.length < 2) {
if (nodes.length === 1) {
const px = nodes[0].time * zoom;
const y = yFn(nodes[0]);
ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(px, y, 4, 0, Math.PI * 2); ctx.fill();
}
return;
}
const tangents = computeTangents(nodes, yFn);
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.setLineDash(lineDash || []);
ctx.beginPath();
for (let i = 0; i < nodes.length - 1; i++) {
const x0 = nodes[i].time * zoom, y0 = yFn(nodes[i]);
const x1 = nodes[i+1].time * zoom, y1 = yFn(nodes[i+1]);
const 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 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();
ctx.setLineDash([]);
// Draw node handles
nodes.forEach(n => {
const px = n.time * zoom, y = yFn(n);
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, '#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) {
const fiPx = fadeInLen * zoom;
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();
}
// Endpoint handle (draggable)
const endX = fiPx, endY = clipTop;
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(endX, endY, HANDLE_RADIUS, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = FADE_COLOR;
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(endX, endY, HANDLE_RADIUS, 0, Math.PI * 2); ctx.stroke();
// Label
ctx.fillStyle = '#b91c1c';
ctx.font = 'bold 7px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('FI', endX, endY - HANDLE_RADIUS - 3);
ctx.textAlign = 'start';
}
if (fadeOutLen >= 0) {
const foPx = fadeOutLen * zoom;
const startX = 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();
}
// Endpoint handle (draggable)
const handleX = startX, handleY = clipTop;
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(handleX, handleY, HANDLE_RADIUS, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = FADE_COLOR;
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(handleX, handleY, HANDLE_RADIUS, 0, Math.PI * 2); ctx.stroke();
ctx.fillStyle = '#b91c1c';
ctx.font = 'bold 7px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('FO', handleX, handleY - HANDLE_RADIUS - 3);
ctx.textAlign = 'start';
}
// Draw right-edge stretch handle indicator
if (wClip > 0 && wClip < w) {
ctx.strokeStyle = clipColor;
ctx.lineWidth = 1.5;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(wClip + xStart, clipTop);
ctx.lineTo(wClip + xStart, clipTop + clipHeight);
ctx.stroke();
ctx.setLineDash([]);
}
// Highlight selection if active
if (selectionStart !== null && selectionEnd !== null && selectionStart !== selectionEnd) {
const left = Math.min(selectionStart, selectionEnd);
const right = Math.max(selectionStart, selectionEnd);
const leftPx = left * zoom;
const rightPx = right * zoom;
ctx.fillStyle = 'rgba(245, 158, 11, 0.15)';
ctx.fillRect(leftPx, 0, rightPx - leftPx, h);
ctx.strokeStyle = '#f59e0b';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(leftPx, 0); ctx.lineTo(leftPx, h);
ctx.moveTo(rightPx, 0); ctx.lineTo(rightPx, h);
ctx.stroke();
}
// Draw playhead
if (currentTime !== null && currentTime >= 0 && currentTime <= buffer.duration / speed) {
const playheadPx = currentTime * zoom;
ctx.strokeStyle = '#ef4444';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(playheadPx, 0);
ctx.lineTo(playheadPx, h);
ctx.stroke();
}
// Update TCP slider values in real-time to match the curve at currentTime
const volInput = document.getElementById(`tcp-vol-${subTabId}`);
const volLabel = document.getElementById(`tcp-vol-label-${subTabId}`);
if (volInput && volLabel) {
const dbVal = getVolumeDbAtTime(currentTime || 0);
volInput.value = dbVal.toFixed(1);
volLabel.textContent = `${dbVal.toFixed(1)}dB`;
}
const panInput = document.getElementById(`tcp-pan-${subTabId}`);
const panLabel = document.getElementById(`tcp-pan-label-${subTabId}`);
if (panInput && panLabel) {
const panVal = getPanningValueAtTime(currentTime || 0);
panInput.value = panVal;
panLabel.textContent = panVal > 0 ? 'R' : panVal < 0 ? 'L' : 'C';
const panLabelDetailed = document.getElementById(`tcp-pan-label-detailed-${subTabId}`);
if (panLabelDetailed) {
panLabelDetailed.textContent = panVal > 0 ? 'R' + panVal : panVal < 0 ? 'L' + Math.abs(panVal) : 'C';
}
}
}, [buffer, currentTime, selectionStart, selectionEnd, zoom, timelineWidth, color, name, speed, volumeNodes, panningNodes, fadeInLen, fadeOutLen, graphMode, selectedNodeTime, subTabId]);
const handleMouseDown = (e) => {
if (e.button === 2) return;
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const parent = canvas.parentElement;
const scrollContainer = parent ? parent.parentElement : null;
const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0;
const bufDuration = buffer.duration;
const wallDuration = bufDuration / (speed || 1.0);
const mouseX = e.clientX - rect.left + scrollLeft;
const startTime = Math.max(0, Math.min(wallDuration, mouseX / zoom));
// Alt+Click near right edge → speed stretch
if (e.altKey && onSpeedChange) {
const clipRightEdge = (bufDuration / (speed || 1.0)) * zoom;
const tolerance = 8;
if (Math.abs(mouseX - clipRightEdge) <= tolerance) {
isStretchingRef.current = true;
stretchStartRef.current = {
mouseX,
originalDuration: bufDuration,
originalSpeed: speed
};
canvas.style.cursor = 'ew-resize';
const handleMouseMove = (moveEvent) => {
const currentX = moveEvent.clientX - rect.left + scrollLeft;
const wClipPx = (stretchStartRef.current.originalDuration / stretchStartRef.current.originalSpeed) * zoom;
const deltaX = currentX - stretchStartRef.current.mouseX;
const newWClip = Math.max(10, wClipPx + deltaX);
const newSpeed = stretchStartRef.current.originalDuration / (newWClip / zoom);
if (onSpeedChange) onSpeedChange(Math.max(0.05, Math.min(10, newSpeed)));
};
const handleMouseUp = () => {
isStretchingRef.current = false;
canvas.style.cursor = 'default';
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return;
}
}
// Mode toggle button click (bottom-right VOL/PAN)
const wClipPx = (bufDuration / (speed || 1.0)) * zoom;
const cTop = 8;
const cSize = 16;
const cHeight = rect.height - 16;
const modeBtnX = wClipPx - 28 - 4;
const modeBtnY = cTop + cHeight - 14 - 2;
if (mouseX >= modeBtnX && mouseX <= modeBtnX + 28 && (e.clientY - rect.top) >= modeBtnY && (e.clientY - rect.top) <= modeBtnY + 14) {
if (onModeToggle) onModeToggle();
return;
}
// Fade endpoint handle drag (click on FI/FO handle circles)
const HANDLE_R = 5;
const fiEndPx = fadeInLen * 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 (distToFiHandle <= HANDLE_R + 6) {
const handleMouseMove = (moveEvent) => {
const x = moveEvent.clientX - rect.left + scrollLeft;
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); };
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return;
}
if (distToFoHandle <= HANDLE_R + 6) {
const handleMouseMove = (moveEvent) => {
const x = moveEvent.clientX - rect.left + scrollLeft;
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); };
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return;
}
// Tool-specific behavior
if (activeTool === 'grab') {
setSelectedNodeTime(null);
onPlayheadSet(startTime);
const handleMouseMove = (moveEvent) => {
const currentX = moveEvent.clientX - rect.left + scrollLeft;
const ct = Math.max(0, Math.min(wallDuration, currentX / zoom));
onPlayheadSet(ct);
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return;
}
if (activeTool === 'razor') {
setSelectedNodeTime(null);
onPlayheadSet(startTime);
showToast(`Cut point at ${formatTime(startTime)}`, 'info');
return;
}
if (activeTool === 'pen') {
// Deduplicate by time: keep last occurrence per time key
const mergeNodes = (existing, incoming) => {
const map = new Map();
existing.forEach(n => map.set(n.time, n));
incoming.forEach(n => map.set(n.time, n));
return Array.from(map.values()).sort((a, b) => a.time - b.time);
};
onPlayheadSet(startTime);
canvas.style.cursor = 'crosshair';
const isPan = (graphMode || 'volume') === 'pan';
const isCtrl = e.ctrlKey || e.metaKey;
const cTop = 8;
const cHeight = rect.height - 16;
const curNodes = isPan ? panningNodes : volumeNodes;
const valFromY = (y) => {
if (isPan) return Math.max(-1, Math.min(1, -(y - cTop) / cHeight * 2 + 1));
const yOff = (y - cTop) / cHeight;
return yOff <= 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 + (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;
const ny = isPan ? cTop + (1 - (val + 1) / 2) * cHeight : volNodeY(val);
const dy = Math.abs((e.clientY - rect.top) - ny);
return t < 0.1 / (speed || 1) && dy < 10;
});
if (nearNode >= 0) {
// Drag existing node
setSelectedNodeTime(curNodes[nearNode].time);
let working = [...curNodes];
let dragIdx = nearNode;
const handleMouseMove = (moveEvent) => {
const currentX = moveEvent.clientX - rect.left + scrollLeft;
const ct = Math.max(0, Math.min(wallDuration, currentX / zoom));
const val = snapVal(valFromY(moveEvent.clientY - rect.top));
const updated = isPan
? { time: Math.min(snapTime(ct), wallDuration), pan: val }
: { time: Math.min(snapTime(ct), wallDuration), db: val };
const cleaned = mergeNodes(working.filter((_, i) => i !== dragIdx), [updated]);
if (onUpdateNodes) onUpdateNodes(cleaned);
working = [...cleaned];
dragIdx = working.findIndex(n => n.time === updated.time && (isPan ? n.pan === updated.pan : n.db === updated.db));
// Follow the selected node during dragging
setSelectedNodeTime(updated.time);
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
canvas.style.cursor = 'crosshair';
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return;
}
if (isCtrl) {
setSelectedNodeTime(null);
const pts = [];
let lastKey = '';
const handleMouseMove = (moveEvent) => {
const currentX = moveEvent.clientX - rect.left + scrollLeft;
const ct = Math.max(0, Math.min(wallDuration, currentX / zoom));
const t = +snapTime(ct).toFixed(3);
const v = isPan
? +snapVal(valFromY(moveEvent.clientY - rect.top)).toFixed(2)
: +snapVal(valFromY(moveEvent.clientY - rect.top)).toFixed(1);
const key = t + '|' + v;
if (key !== lastKey) { pts.push({ time: t, [isPan ? 'pan' : 'db']: v }); lastKey = key; }
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
canvas.style.cursor = 'crosshair';
const merged = mergeNodes(curNodes, pts);
if (onUpdateNodes) onUpdateNodes(merged);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
} else {
const newNode = isPan
? { time: +snapTime(startTime).toFixed(3), pan: +snapVal(valFromY(e.clientY - rect.top)).toFixed(2) }
: { time: +snapTime(startTime).toFixed(3), db: +snapVal(valFromY(e.clientY - rect.top)).toFixed(1) };
setSelectedNodeTime(newNode.time);
const merged = mergeNodes(curNodes, [newNode]);
if (onUpdateNodes) onUpdateNodes(merged);
// Now drag this newly created node
let working = [...merged];
let dragIdx = working.findIndex(n => n.time === newNode.time && (isPan ? n.pan === newNode.pan : n.db === newNode.db));
const handleMouseMove = (moveEvent) => {
const currentX = moveEvent.clientX - rect.left + scrollLeft;
const ct = Math.max(0, Math.min(wallDuration, currentX / zoom));
const val = snapVal(valFromY(moveEvent.clientY - rect.top));
const updated = isPan
? { time: Math.min(snapTime(ct), wallDuration), pan: val }
: { time: Math.min(snapTime(ct), wallDuration), db: val };
const cleaned = mergeNodes(working.filter((_, i) => i !== dragIdx), [updated]);
if (onUpdateNodes) onUpdateNodes(cleaned);
working = [...cleaned];
dragIdx = working.findIndex(n => n.time === updated.time && (isPan ? n.pan === updated.pan : n.db === updated.db));
setSelectedNodeTime(updated.time);
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
canvas.style.cursor = 'crosshair';
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
return;
}
// Select tool (default): drag to select range
setSelectedNodeTime(null);
onSelectRange(startTime, startTime);
onPlayheadSet(startTime);
const handleMouseMove = (moveEvent) => {
const currentX = moveEvent.clientX - rect.left + scrollLeft;
const ct = Math.max(0, Math.min(wallDuration, currentX / zoom));
onSelectRange(startTime, ct);
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
const handleContextMenuInternal = (e) => {
e.preventDefault();
e.stopPropagation();
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const parent = canvas.parentElement;
const scrollContainer = parent ? parent.parentElement : null;
const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0;
const x = e.clientX - rect.left + scrollLeft;
const clickTime = Math.max(0, Math.min(buffer.duration / (speed || 1.0), x / zoom));
onContextMenu(e, clickTime);
};
// Double-click on automation curve to create a new node
const handleDoubleClick = (e) => {
const canvas = canvasRef.current;
if (!canvas || !buffer) return;
if (activeTool !== 'select' && activeTool !== 'pen') return;
const rect = canvas.getBoundingClientRect();
const parent = canvas.parentElement;
const scrollContainer = parent ? parent.parentElement : null;
const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0;
const x = e.clientX - rect.left + scrollLeft;
const clickTime = Math.max(0, Math.min(buffer.duration / (speed || 1.0), x / zoom));
const isPan = (graphMode || 'volume') === 'pan';
const curNodes = isPan ? panningNodes : volumeNodes;
const cTop = 8;
const cHeight = rect.height - 16;
const valFromY = (y) => {
if (isPan) return Math.max(-1, Math.min(1, -(y - cTop) / cHeight * 2 + 1));
const yOff = (y - cTop) / cHeight;
return yOff <= 2/3
? Math.max(0, Math.min(3, 3 * (1 - yOff * 3 / 2)))
: Math.max(-30, Math.min(0, -30 * (yOff - 2/3) * 3));
};
// Interpolate value at click position from existing curve
let interpolatedVal = valFromY(e.clientY - rect.top);
if (curNodes.length >= 2) {
const sorted = [...curNodes].sort((a, b) => a.time - b.time);
for (let i = 0; i < sorted.length - 1; i++) {
if (clickTime >= sorted[i].time && clickTime <= sorted[i+1].time) {
const t = (clickTime - sorted[i].time) / (sorted[i+1].time - sorted[i].time);
const v1 = isPan ? sorted[i].pan : sorted[i].db;
const v2 = isPan ? sorted[i+1].pan : sorted[i+1].db;
interpolatedVal = v1 + t * (v2 - v1);
break;
}
}
}
const newNode = isPan
? { time: +Math.round(clickTime * 10) / 10, pan: +Math.round(interpolatedVal * 20) / 20 }
: { time: +Math.round(clickTime * 10) / 10, db: +Math.round(interpolatedVal * 2) / 2 };
const merged = (() => {
const map = new Map();
curNodes.forEach(n => map.set(n.time, n));
map.set(newNode.time, newNode);
return Array.from(map.values()).sort((a, b) => a.time - b.time);
})();
if (onUpdateNodes) onUpdateNodes(merged);
};
return (
<canvas
ref={canvasRef}
className="w-full h-full rounded border border-zinc-800"
onMouseDown={handleMouseDown}
onDoubleClick={handleDoubleClick}
onContextMenu={handleContextMenuInternal}
onMouseMove={(e) => {
if (canvasRef.current && e.altKey && onSpeedChange) {
const rect = canvasRef.current.getBoundingClientRect();
const parent = canvasRef.current.parentElement;
const scrollContainer = parent ? parent.parentElement : null;
const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0;
const mx = e.clientX - rect.left + scrollLeft;
const wClip = (buffer.duration / speed) * zoom;
const tolerance = 8;
canvasRef.current.style.cursor = (Math.abs(mx - wClip) <= tolerance && !isStretchingRef.current) ? 'ew-resize' : 'crosshair';
} else if (canvasRef.current && !isStretchingRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
const parent = canvasRef.current.parentElement;
const scrollContainer = parent ? parent.parentElement : null;
const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0;
const mx = e.clientX - rect.left + scrollLeft;
const wClipPx = (buffer.duration / (speed || 1.0)) * zoom;
const cH = canvasRef.current.height / (window.devicePixelRatio || 1) - 16;
const btnX = wClipPx - 28 - 4;
const btnY = 8 + cH - 14 - 2;
const overBtn = mx >= btnX && mx <= btnX + 28 && (e.clientY - rect.top) >= btnY && (e.clientY - rect.top) <= btnY + 14;
canvasRef.current.style.cursor = overBtn ? 'pointer' : 'crosshair';
}
}}
/>
);
};
const SubTabToolbar = ({ st, activeTool, setActiveTool, handleSubTabNormalizeWithValue, handleSubTabGainWithValue, handleSubTabPitch, handleSubTabStretch, handleSubTabFade, onPlayPause, onStop, onRewind, onForward, onLoop, onRecord, onRateChange, onCut, onCopy, onPaste, onGlue, snapValue, onSnapChange }) => {
const isPlaying = st?.isPlaying || false;
const isLooping = st?.isLooping || false;
const isRecording = st?.isRecording || false;
return (
<div className="daw-header flex h-16 items-center px-4 border-b daw-border gap-3 bg-zinc-800">
{/* Tools Section - passthrough từ main session */}
<div className="flex items-center space-x-1 border-r border-zinc-700 pr-3">
<button className={`p-1 rounded ${activeTool === 'select' ? 'bg-cyan-700' : 'bg-zinc-700'}`} onClick={() => setActiveTool('select')} title="Select Tool">
<i data-lucide="mouse-pointer" className="w-4 h-4"></i>
</button>
<button className={`p-1 rounded ${activeTool === 'grab' ? 'bg-cyan-700' : 'bg-zinc-700'}`} onClick={() => setActiveTool('grab')} title="Grab Tool">
<i data-lucide="hand" className="w-4 h-4"></i>
</button>
<button className={`p-1 rounded ${activeTool === 'razor' ? 'bg-cyan-700' : 'bg-zinc-700'}`} onClick={() => setActiveTool('razor')} title="Razor Tool">
<svg className="w-4 h-4 text-orange-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 3h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/>
<path d="M4 9h16l-3 9H7z"/>
<circle cx="12" cy="6" r="1"/>
</svg>
</button>
<button className={`p-1 rounded ${activeTool === 'pen' ? 'bg-cyan-700' : 'bg-zinc-700'}`} onClick={() => setActiveTool('pen')} title="Pen Tool">
<i data-lucide="pen-tool" className="w-4 h-4"></i>
</button>
<div className="w-[1px] h-4 bg-zinc-800 mx-0.5"></div>
<button onClick={onGlue} className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-purple-400 transition" title="Glue Clips">
<i data-lucide="link" className="w-3.5 h-3.5"></i>
</button>
<button onClick={onCut} className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-red-400 transition" title="Cut (Ctrl+X)">
<i data-lucide="scissors" className="w-3.5 h-3.5"></i>
</button>
<button onClick={onCopy} className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-blue-400 transition" title="Copy (Ctrl+C)">
<i data-lucide="copy" className="w-3.5 h-3.5"></i>
</button>
<button onClick={onPaste} className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-emerald-400 transition" title="Paste (Ctrl+V)">
<i data-lucide="clipboard" className="w-3.5 h-3.5"></i>
</button>
</div>
{/* Snap Section - passthrough */}
<div className="flex items-center gap-1 border-r border-zinc-700 pr-3">
<span className="text-[9px] text-zinc-500 font-bold uppercase">Snap</span>
<select
value={snapValue || 'free'}
onChange={(e) => onSnapChange(e.target.value)}
className="bg-zinc-850 text-zinc-300 text-[10px] px-1 py-0.5 rounded border border-zinc-800 focus:outline-none font-mono"
>
<option value="free">Free</option>
<option value="1">1</option>
<option value="1/2">1/2</option>
<option value="1/4">1/4</option>
<option value="1/8">1/8</option>
<option value="1/16">1/16</option>
<option value="1/32">1/32</option>
</select>
</div>
{/* Effects Controls */}
<div className="flex items-center space-x-2 flex-1 overflow-x-auto no-scrollbar">
<div className="flex items-center gap-1">
<span className="text-[10px] text-zinc-400">Normalize:</span>
<input type="range" min="-12" max="0" value={st.effects?.normalizeDb || 0} step="0.1" onChange={(e) => handleSubTabNormalizeWithValue(st.id, parseFloat(e.target.value))} className="w-20 h-1.5"/>
<span className="text-[10px] text-zinc-500 w-10 text-right">{st.effects?.normalizeDb || 0} dB</span>
</div>
<div className="flex items-center gap-1">
<span className="text-[10px] text-zinc-400">Gain:</span>
<input type="range" min="-40" max="24" value={st.effects?.gainDb || 0} step="0.1" onChange={(e) => handleSubTabGainWithValue(st.id, parseFloat(e.target.value))} className="w-20 h-1.5"/>
<span className="text-[10px] text-zinc-500 w-10 text-right">{st.effects?.gainDb || 0} dB</span>
</div>
<div className="flex items-center gap-1">
<span className="text-[10px] text-zinc-400">Pitch:</span>
<input type="range" min="-12" max="12" value={st.effects?.pitch || 0} step="0.1" onChange={(e) => handleSubTabPitch(st.id, parseFloat(e.target.value))} className="w-20 h-1.5"/>
<span className="text-[10px] text-zinc-500 w-10 text-right">{st.effects?.pitch || 0} st</span>
</div>
<div className="flex items-center gap-1">
<span className="text-[10px] text-zinc-400">Stretch:</span>
<input type="range" min="50" max="200" value={st.effects?.speedStretch || 100} step="1" onChange={(e) => handleSubTabStretch(st.id, parseInt(e.target.value))} className="w-20 h-1.5"/>
<span className="text-[10px] text-zinc-500 w-10 text-right">{st.effects?.speedStretch || 100}%</span>
</div>
</div>
{/* Fade Buttons */}
<div className="flex items-center space-x-1 border-r border-zinc-700 pr-3">
<button onClick={() => handleSubTabFade(st.id, 'in')} className="px-2 py-1 text-[10px] rounded hover:bg-zinc-700">Fade In</button>
<button onClick={() => handleSubTabFade(st.id, 'out')} className="px-2 py-1 text-[10px] rounded hover:bg-zinc-700">Fade Out</button>
</div>
{/* Transport Controls */}
<div className="flex items-center space-x-1 border-r border-zinc-700 pr-3">
<button onClick={onRewind} className="w-8 h-8 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition" title="Rewind">
<i data-lucide="skip-back" className="w-4 h-4"></i>
</button>
<button onClick={onPlayPause} className={`w-8 h-8 flex items-center justify-center rounded border transition ${
isPlaying
? 'bg-emerald-600 text-black border-emerald-500 hover:bg-emerald-500'
: 'bg-cyan-600 text-white border-cyan-500 hover:bg-cyan-500'
}`} title={isPlaying ? "Pause" : "Play"}>
<i data-lucide={isPlaying ? 'pause' : 'play'} className="w-4 h-4 fill-current"></i>
</button>
<button onClick={onStop} className="w-8 h-8 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-4 h-4 fill-current"></i>
</button>
<button onClick={onForward} className="w-8 h-8 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition" title="Forward">
<i data-lucide="skip-forward" className="w-4 h-4"></i>
</button>
<button onClick={onLoop} className={`w-8 h-8 flex items-center justify-center rounded border transition ${
isLooping ? 'bg-amber-600 text-black border-amber-500 hover:bg-amber-500' : 'bg-zinc-800 text-zinc-200 border-zinc-700 hover:bg-zinc-700'
}`} title={isLooping ? "Loop On" : "Loop Off"}>
<i data-lucide="repeat" className="w-4 h-4"></i>
</button>
<button onClick={onRecord} className={`w-8 h-8 flex items-center justify-center rounded border transition ${
isRecording ? 'bg-red-600 text-white border-red-500' : 'bg-zinc-800 text-zinc-200 border-zinc-700 hover:bg-zinc-700'
}`} title={isRecording ? "Recording" : "Record"}>
<i data-lucide="circle" className="w-4 h-4"></i>
</button>
</div>
{/* Rate Control */}
<div className="flex items-center space-x-1">
<span className="text-[10px] text-zinc-400">Rate:</span>
<select value={st.playbackRate || 1} onChange={(e) => onRateChange(parseFloat(e.target.value))} className="bg-zinc-700 text-zinc-100 text-[10px] px-1 py-0.5 rounded border border-zinc-600">
<option value="0.5">0.5x</option>
<option value="0.75">0.75x</option>
<option value="1">1x</option>
<option value="1.25">1.25x</option>
<option value="1.5">1.5x</option>
<option value="2">2x</option>
</select>
</div>
</div>
);
};
// ── Graph Editor Canvas for Volume/Pan/Fade Automation ──
const GraphEditorCanvas = ({ buffer, zoom, timelineWidth, volumeNodes, panningNodes, fadeInLen, fadeOutLen, onUpdateNodes, graphMode }) => {
const canvasRef = useRef(null);
const isDraggingNode = useRef(false);
const dragNodeIdx = useRef(-1);
const isCreatingNode = useRef(false);
const getNodes = () => graphMode === 'pan' ? panningNodes : volumeNodes;
const nodeLabel = (n) => graphMode === 'pan' ? `${n.pan.toFixed(2)}` : `${n.db.toFixed(1)}dB`;
const nodeY = (n, h) => {
if (graphMode === 'pan') return ((1 - (n.pan + 1) / 2) * h);
const zeroY = (2/3) * h;
return n.db >= 0 ? zeroY - (n.db / 3) * (2/3 * h) : zeroY + (-n.db / 30) * (1/3 * h);
};
const nodeValFromY = (y, h) => {
if (graphMode === 'pan') return -(y / h * 2 - 1);
const yOff = y / h;
return yOff <= 2/3 ? 3 * (1 - yOff * 3 / 2) : -30 * (yOff - 2/3) * 3;
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !buffer) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
const w = timelineWidth;
const h = rect.height;
const maxW = 10000;
const useW = Math.min(timelineWidth, maxW);
canvas.width = useW * dpr;
canvas.height = h * dpr;
ctx.scale(dpr * (useW / timelineWidth), dpr);
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = '#2a2a4e';
ctx.lineWidth = 0.5;
for (let t = 0; t <= buffer.duration; t += 0.5) {
const x = (t / buffer.duration) * w;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
}
for (let i = 0; i <= 10; i++) {
const y = (i / 10) * h;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
}
if (fadeInLen > 0) {
const fadeX = (fadeInLen / buffer.duration) * w;
ctx.fillStyle = 'rgba(16, 185, 129, 0.12)';
ctx.fillRect(0, 0, fadeX, h);
}
if (fadeOutLen > 0) {
const fadeX = ((buffer.duration - fadeOutLen) / buffer.duration) * w;
const fadeW = (fadeOutLen / buffer.duration) * w;
ctx.fillStyle = 'rgba(239, 68, 68, 0.12)';
ctx.fillRect(fadeX, 0, fadeW, h);
}
const nodes = getNodes();
if (nodes.length > 0) {
ctx.strokeStyle = graphMode === 'pan' ? '#a855f7' : '#06b6d4';
ctx.lineWidth = 2;
ctx.beginPath();
nodes.forEach((n, i) => {
const x = (n.time / buffer.duration) * w;
const y = nodeY(n, h);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.stroke();
nodes.forEach((n, i) => {
const x = (n.time / buffer.duration) * w;
const y = nodeY(n, h);
ctx.fillStyle = graphMode === 'pan' ? '#a855f7' : '#06b6d4';
ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#e4e4e7';
ctx.font = '9px monospace';
ctx.fillText(nodeLabel(n), x + 8, y + 3);
});
} else {
ctx.fillStyle = '#52525b';
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(graphMode === 'pan' ? 'Click to add Pan points' : 'Click to add Volume points', w / 2, h / 2);
ctx.textAlign = 'start';
}
const zeroY = graphMode === 'pan' ? h / 2 : nodeY({ db: 0 }, h);
ctx.strokeStyle = graphMode === 'pan' ? '#a855f744' : '#06b6d444';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(0, zeroY); ctx.lineTo(w, zeroY); ctx.stroke();
ctx.setLineDash([]);
}, [buffer, zoom, timelineWidth, volumeNodes, panningNodes, fadeInLen, fadeOutLen, graphMode]);
const handleMouseDown = (e) => {
const canvas = canvasRef.current;
if (!canvas || !buffer) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const time = (x / rect.width) * buffer.duration;
const val = nodeValFromY(y, rect.height);
const nodes = getNodes();
const snapped = Math.max(-30, Math.min(3, val));
const snappedPan = Math.max(-1, Math.min(1, val));
const threshold = 12 / rect.width * buffer.duration;
const nearIdx = nodes.findIndex(n => Math.abs(n.time - time) < threshold);
if (nearIdx >= 0) { isDraggingNode.current = true; dragNodeIdx.current = nearIdx; return; }
const newNode = graphMode === 'pan' ? { time: +time.toFixed(3), pan: +snappedPan.toFixed(2) } : { time: +time.toFixed(3), db: +snapped.toFixed(1) };
const sorted = [...nodes, newNode].sort((a, b) => a.time - b.time);
onUpdateNodes(sorted);
const newIdx = sorted.findIndex(n => n.time === newNode.time && (graphMode === 'pan' ? n.pan : n.db) === (graphMode === 'pan' ? newNode.pan : newNode.db));
isDraggingNode.current = true; dragNodeIdx.current = newIdx; isCreatingNode.current = true;
};
const handleMouseMove = (e) => {
if (!isDraggingNode.current || dragNodeIdx.current < 0) return;
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const time = Math.max(0, Math.min(buffer.duration, (x / rect.width) * buffer.duration));
const val = nodeValFromY(y, rect.height);
const nodes = [...getNodes()];
nodes[dragNodeIdx.current] = graphMode === 'pan'
? { time: +time.toFixed(3), pan: +Math.max(-1, Math.min(1, val)).toFixed(2) }
: { time: +time.toFixed(3), db: +Math.max(-30, Math.min(3, val)).toFixed(1) };
onUpdateNodes(nodes.sort((a, b) => a.time - b.time));
};
const handleMouseUp = () => { isDraggingNode.current = false; dragNodeIdx.current = -1; isCreatingNode.current = false; };
const handleContextMenu = (e) => {
e.preventDefault();
const canvas = canvasRef.current;
if (!canvas || !buffer) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const time = (x / rect.width) * buffer.duration;
const threshold = 12 / rect.width * buffer.duration;
const nodes = getNodes();
const nearIdx = nodes.findIndex(n => Math.abs(n.time - time) < threshold);
if (nearIdx >= 0) onUpdateNodes(nodes.filter((_, i) => i !== nearIdx));
};
return (
<canvas ref={canvasRef}
className="w-full h-20 rounded border border-zinc-700 cursor-crosshair"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onContextMenu={handleContextMenu}
/>
);
};
const 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: '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');
const [draggedClip, setDraggedClip] = useState(null); // { trackId, clickOffset, buffer, name, volume, color }
const [hoveredTrackId, setHoveredTrackId] = useState(null);
const [activeTool, setActiveTool] = useState('select'); // 'select' | 'grab' | 'razor'
const [snapValue, setSnapValue] = useState('free'); // 'free', '1', '1/2', '1/4', '1/8', '1/16', '1/32'
const snapTime = (time, snapVal, bpmVal) => {
if (snapVal === 'free') return time;
const beatDuration = 60 / parseFloat(bpmVal || 120);
let divisor = 1;
if (snapVal === '1') divisor = 1;
else if (snapVal === '1/2') divisor = 0.5;
else if (snapVal === '1/4') divisor = 0.25;
else if (snapVal === '1/8') divisor = 0.125;
else if (snapVal === '1/16') divisor = 0.0625;
else if (snapVal === '1/32') divisor = 0.03125;
const gridSpacing = beatDuration * divisor;
return Math.round(time / gridSpacing) * gridSpacing;
};
const snapValueRef = useRef(snapValue);
snapValueRef.current = snapValue;
const bpmRef = useRef(bpm);
bpmRef.current = bpm;
// BMP for Tempo Track - LOOP_EDITOR_2.md §6
const [selectedTrackId, setSelectedTrackId] = useState('1');
const [currentTime, setCurrentTime] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [selectionStart, setSelectionStart] = useState(null);
const [selectionEnd, setSelectionEnd] = useState(null);
const [selectionMode, setSelectionMode] = useState(null); // 'global' (from ruler) | 'local' (from track)
const [localSelectionTrackId, setLocalSelectionTrackId] = useState(null);
const [localSelectionStart, setLocalSelectionStart] = useState(null);
const [localSelectionEnd, setLocalSelectionEnd] = useState(null);
const [zoom, setZoom] = useState(100);
const [isLoopingSelection, setIsLoopingSelection] = useState(false);
const [beginBar, setBeginBar] = useState(1);
const [endBar, setEndBar] = useState(1);
const [numberBar, setNumberBar] = useState(1);
const [subTabHeight, setSubTabHeight] = useState(96);
const [isExporting, setIsExporting] = useState(false);
const [soloedTrackId, setSoloedTrackId] = useState(null);
const [toastMessage, setToastMessage] = useState(null);
const [showAIConfig, setShowAIConfig] = useState(false);
const [showExportPanel, setShowExportPanel] = useState(true);
const [showAIPanel, setShowAIPanel] = useState(true);
const [showSelectionPanel, setShowSelectionPanel] = useState(true);
const [panelPositions, setPanelPositions] = useState({ export: 'bottom', ai: 'bottom', selection: 'bottom' });
const [panelDropZone, setPanelDropZone] = useState(null);
const [dragGhostPos, setDragGhostPos] = useState(null);
const [dragGhostPanel, setDragGhostPanel] = useState(null);
const panelDragRef = useRef(null);
const workspaceRef = useRef(null);
const [aiConfig, setAiConfig] = useState({
baseUrl: localStorage.getItem('ai_base_url') || `${API_BASE_URL}`,
apiKey: localStorage.getItem('ai_api_key') || '',
model: localStorage.getItem('ai_model') || 'deepseek-chat',
});
const [analysisState, setAnalysisState] = useState({
status: 'Sẵn sàng. Chạy AI để phân tích nhịp.',
data: null,
isRunning: false,
});
const [exportSettings, setExportSettings] = useState({
sampleRate: '44100',
bitDepth: '16',
format: 'wav',
});
const [serverStatus, setServerStatus] = useState('checking...');
const [menuOpen, setMenuOpen] = useState(null);
const [selectedClipId, setSelectedClipId] = useState(null); // { trackId, clipId }
const [stretchedClip, setStretchedClip] = useState(null); // { trackId, clipId, originalDuration, startTime, originalSpeed, beforeSnap }
const [editingTrackName, setEditingTrackName] = useState(null); // trackId being edited
const [editNameInput, setEditNameInput] = useState('');
// ── Context Menu & Clipboard ──
const [contextMenu, setContextMenu] = useState(null); // { x, y, trackId }
const clipboardRef = useRef(null); // { buffer, name, volume, color } for copy/paste
// ── Undo/Redo Engine (LOOP_EDITOR.md §4) ──
const [undoStack, setUndoStack] = useState([]);
const [redoStack, setRedoStack] = useState([]);
const MAX_UNDO = 30;
const pushAction = (actionType, trackId, beforeState, afterState) => {
const node = {
action_type: actionType,
track_id: trackId,
timestamp: Date.now(),
before_state: beforeState,
after_state: afterState,
};
setUndoStack(prev => {
const next = [...prev, node];
if (next.length > MAX_UNDO) next.shift();
return next;
});
setRedoStack([]);
};
const handleUndo = () => {
if (undoStack.length === 0) return;
const last = undoStack[undoStack.length - 1];
setUndoStack(prev => prev.slice(0, -1));
setRedoStack(prev => [...prev, last]);
applyTrackState(last.track_id, last.before_state);
showToast(`Undo: ${last.action_type}`, 'info');
};
const handleRedo = () => {
if (redoStack.length === 0) return;
const last = redoStack[redoStack.length - 1];
setRedoStack(prev => prev.slice(0, -1));
setUndoStack(prev => [...prev, last]);
applyTrackState(last.track_id, last.after_state);
showToast(`Redo: ${last.action_type}`, 'info');
};
const applyTrackState = (trackId, state) => {
setTracks(prev => prev.map(t => {
if (t.id !== trackId) return t;
return { ...t, ...state };
}));
};
const captureTrackSnapshot = (trackId) => {
const track = tracks.find(t => t.id === trackId);
if (!track) return null;
return {
volumeDb: track.volumeDb,
pan: track.pan,
muted: track.muted,
name: track.name,
markers: JSON.parse(JSON.stringify(track.markers || [])),
// buffer is captured via reference copy for undo; we store a clone for redo
buffer: track.buffer,
startTime: track.startTime || 0,
clips: track.clips ? track.clips.map(c => ({
id: c.id,
buffer: c.buffer,
startTime: c.startTime,
name: c.name
})) : null
};
};
// ── Tab System (LOOP_EDITOR_2.md §1) ──
const [activeTab, setActiveTab] = useState('subtab_1');
const [subTabSelectedNodeTime, setSubTabSelectedNodeTime] = useState(null);
const [subTabNormVal, setSubTabNormVal] = useState(0);
const [subTabGainVal, setSubTabGainVal] = useState(100);
const [subTabPitchVal, setSubTabPitchVal] = useState(0);
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) ──
const [tempTabActive, setTempTabActive] = useState(false);
const [tempTabBuffer, setTempTabBuffer] = useState(null);
const [tempTabTrackId, setTempTabTrackId] = useState(null);
const [tempTabOrigStart, setTempTabOrigStart] = useState(0);
const [tempTabOrigEnd, setTempTabOrigEnd] = useState(0);
const tempTabCanvasRef = useRef(null);
// Effect parameters for temp tab
const [tempTabEffects, setTempTabEffects] = useState({
reverse: false,
gainDb: 0,
fadeInMs: 0,
fadeOutMs: 0,
});
// Lucide icons initialization
useEffect(() => {
setTimeout(() => {
if (window.lucide) {
window.lucide.createIcons();
}
}, 50);
}, [activeTool, activeTab]);
const timelineWrapperRef = useRef(null);
const tcpContainerRef = useRef(null);
const handleTimelineScroll = (e) => {
if (tcpContainerRef.current) {
tcpContainerRef.current.scrollTop = e.currentTarget.scrollTop;
}
};
const handleTCPScroll = (e) => {
if (timelineWrapperRef.current) {
timelineWrapperRef.current.scrollTop = e.currentTarget.scrollTop;
}
};
const panelDropZoneRef = useRef(null);
const startPanelDrag = (panelId, e) => {
panelDragRef.current = { panelId, startX: e.clientX, startY: e.clientY };
setPanelDropZone(null);
setDragGhostPanel(panelId);
setDragGhostPos({ x: e.clientX - 120, y: e.clientY - 20 });
const onMove = (ev) => {
if (!panelDragRef.current || !workspaceRef.current) return;
const rect = workspaceRef.current.getBoundingClientRect();
const x = ev.clientX - rect.left;
const y = ev.clientY - rect.top;
const w = rect.width;
const h = rect.height;
const margin = 60;
let zone = null;
if (x < margin && x > 10) zone = 'left';
else if (x > w - margin && x < w - 10) zone = 'right';
else if (y < margin && y > 10) zone = 'top';
else if (y > h - margin && y < h - 10) zone = 'bottom';
panelDropZoneRef.current = zone;
setPanelDropZone(zone);
setDragGhostPos({ x: ev.clientX - 120, y: ev.clientY - 20 });
};
const onUp = () => {
if (panelDragRef.current) {
const pid = panelDragRef.current.panelId;
const targetZone = panelDropZoneRef.current;
if (targetZone && targetZone !== panelPositions[pid]) {
setPanelPositions(prev => ({ ...prev, [pid]: targetZone }));
}
panelDragRef.current = null;
panelDropZoneRef.current = null;
setPanelDropZone(null);
setDragGhostPos(null);
setDragGhostPanel(null);
}
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
};
const rulerRef = useRef(null);
const activeSourcesRef = useRef([]);
const activeTrackNodesRef = useRef({}); // { [trackId]: { gainNode, pannerNode } }
const startOffsetTimeRef = useRef(0);
const startBufferOffsetRef = useRef(0);
const startAudioTimeRef = useRef(0);
const animationFrameIdRef = useRef(null);
const toastTimeoutRef = useRef(null);
const rulerDragStartRef = useRef(null);
const isDraggingRulerRef = useRef(false);
const handlePlayPauseRef = useRef(null);
// ── Keyboard Shortcuts ──
const handleUndoRef = useRef(handleUndo);
const handleRedoRef = useRef(handleRedo);
handleUndoRef.current = handleUndo;
handleRedoRef.current = handleRedo;
const selectedClipIdRef = useRef(null);
selectedClipIdRef.current = selectedClipId;
const activeTabRef = useRef(activeTab);
activeTabRef.current = activeTab;
const activePlaybackSpeedRef = useRef(1.0);
const subTabsRef = useRef(subTabs);
subTabsRef.current = subTabs;
const subTabSelectedNodeTimeRef = useRef(null);
subTabSelectedNodeTimeRef.current = subTabSelectedNodeTime;
const handleSubTabNormalize = (tabId) => {
setSubTabs(prev => prev.map(st => {
if (st.id !== tabId || !st.buffer) return st;
const ctx = getAudioContext();
const data = st.buffer.getChannelData(0);
const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : 0;
const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : st.buffer.duration;
const startSample = Math.floor(left * st.buffer.sampleRate);
const endSample = Math.floor(right * st.buffer.sampleRate);
let maxVal = 0;
for (let i = startSample; i < endSample; i++) {
const abs = Math.abs(data[i]);
if (abs > maxVal) maxVal = abs;
}
if (maxVal === 0) return st;
const scale = 1.0 / maxVal;
const clonedBuffer = ctx.createBuffer(1, data.length, st.buffer.sampleRate);
const clonedData = clonedBuffer.getChannelData(0);
clonedData.set(data);
for (let i = startSample; i < endSample; i++) {
clonedData[i] = Math.max(-1, Math.min(1, clonedData[i] * scale));
}
showToast('Đã Normalize vùng chọn.', 'success');
return { ...st, buffer: clonedBuffer };
}));
};
const handleSubTabGain = (tabId, gainDb) => {
setSubTabs(prev => prev.map(st => {
if (st.id !== tabId || !st.buffer) return st;
const ctx = getAudioContext();
const data = st.buffer.getChannelData(0);
const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : 0;
const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : st.buffer.duration;
const startSample = Math.floor(left * st.buffer.sampleRate);
const endSample = Math.floor(right * st.buffer.sampleRate);
const scale = Math.pow(10, gainDb / 20);
const clonedBuffer = ctx.createBuffer(1, data.length, st.buffer.sampleRate);
const clonedData = clonedBuffer.getChannelData(0);
clonedData.set(data);
for (let i = startSample; i < endSample; i++) {
clonedData[i] = Math.max(-1, Math.min(1, clonedData[i] * scale));
}
showToast(`Đã điều chỉnh Gain: ${gainDb} dB.`, 'success');
return { ...st, buffer: clonedBuffer };
}));
};
const handleSubTabFade = (tabId, type) => {
setSubTabs(prev => prev.map(st => {
if (st.id !== tabId || !st.buffer) return st;
const ctx = getAudioContext();
const data = st.buffer.getChannelData(0);
const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : 0;
const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : st.buffer.duration;
const startSample = Math.floor(left * st.buffer.sampleRate);
const endSample = Math.floor(right * st.buffer.sampleRate);
const durationSamples = endSample - startSample;
if (durationSamples <= 0) return st;
const clonedBuffer = ctx.createBuffer(1, data.length, st.buffer.sampleRate);
const clonedData = clonedBuffer.getChannelData(0);
clonedData.set(data);
if (type === 'in') {
for (let i = 0; i < durationSamples; i++) {
const alpha = i / durationSamples;
clonedData[startSample + i] *= alpha;
}
showToast('Đã áp dụng Fade In.', 'success');
} else if (type === 'out') {
for (let i = 0; i < durationSamples; i++) {
const alpha = (durationSamples - i) / durationSamples;
clonedData[startSample + i] *= alpha;
}
showToast('Đã áp dụng Fade Out.', 'success');
}
return { ...st, buffer: clonedBuffer };
}));
};
const handleSubTabCut = (tabId) => {
const st = subTabsRef.current.find(s => s.id === tabId);
if (!st || !st.buffer) return;
const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : null;
const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : null;
if (left === null || right === null || left === right) {
showToast('Vui lòng chọn vùng để Cut.', 'warning');
return;
}
const ctx = getAudioContext();
const sr = st.buffer.sampleRate;
const data = st.buffer.getChannelData(0);
const startSample = Math.floor(left * sr);
const endSample = Math.floor(right * sr);
const len = endSample - startSample;
const cutBuffer = ctx.createBuffer(1, len, sr);
cutBuffer.copyToChannel(data.subarray(startSample, endSample), 0);
clipboardRef.current = { buffer: cutBuffer, name: 'Subtab Clip' };
const newBuffer = ctx.createBuffer(1, data.length - len, sr);
const newData = newBuffer.getChannelData(0);
let idx = 0;
for (let i = 0; i < startSample; i++) newData[idx++] = data[i];
for (let i = endSample; i < data.length; i++) newData[idx++] = data[i];
setSubTabs(prev => prev.map(s => s.id === tabId ? {
...s,
buffer: newBuffer,
currentTime: left,
selectionStart: null,
selectionEnd: null
} : s));
showToast('Đã Cut vùng chọn.', 'success');
};
const handleSubTabCopy = (tabId) => {
const st = subTabsRef.current.find(s => s.id === tabId);
if (!st || !st.buffer) return;
const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : null;
const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : null;
if (left === null || right === null || left === right) {
showToast('Vui lòng chọn vùng để Copy.', 'warning');
return;
}
const ctx = getAudioContext();
const sr = st.buffer.sampleRate;
const data = st.buffer.getChannelData(0);
const startSample = Math.floor(left * sr);
const endSample = Math.floor(right * sr);
const len = endSample - startSample;
const copyBuffer = ctx.createBuffer(1, len, sr);
copyBuffer.copyToChannel(data.subarray(startSample, endSample), 0);
clipboardRef.current = { buffer: copyBuffer, name: 'Subtab Clip' };
showToast('Đã Copy vùng chọn.', 'success');
};
const handleSubTabPaste = (tabId) => {
const st = subTabsRef.current.find(s => s.id === tabId);
if (!st || !st.buffer) return;
if (!clipboardRef.current || !clipboardRef.current.buffer) {
showToast('Clipboard trống.', 'warning');
return;
}
const ctx = getAudioContext();
const clipBuf = clipboardRef.current.buffer;
const sr = st.buffer.sampleRate;
const data = st.buffer.getChannelData(0);
const insertTime = st.currentTime || 0;
const insertSample = Math.floor(insertTime * sr);
const newBuffer = ctx.createBuffer(1, data.length + clipBuf.length, sr);
const newData = newBuffer.getChannelData(0);
let idx = 0;
for (let i = 0; i < insertSample; i++) newData[idx++] = data[i];
const clipData = clipBuf.getChannelData(0);
for (let i = 0; i < clipBuf.length; i++) newData[idx++] = clipData[i];
for (let i = insertSample; i < data.length; i++) newData[idx++] = data[i];
setSubTabs(prev => prev.map(s => s.id === tabId ? {
...s,
buffer: newBuffer,
currentTime: insertTime + clipBuf.duration,
selectionStart: null,
selectionEnd: null
} : s));
showToast('Đã dán dữ liệu âm thanh.', 'success');
};
const handleSubTabDelete = (tabId) => {
const st = subTabsRef.current.find(s => s.id === tabId);
if (!st || !st.buffer) return;
const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : null;
const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : null;
if (left === null || right === null || left === right) {
showToast('Vui lòng chọn vùng để Delete.', 'warning');
return;
}
const ctx = getAudioContext();
const sr = st.buffer.sampleRate;
const data = st.buffer.getChannelData(0);
const startSample = Math.floor(left * sr);
const endSample = Math.floor(right * sr);
const len = endSample - startSample;
const newBuffer = ctx.createBuffer(1, data.length - len, sr);
const newData = newBuffer.getChannelData(0);
let idx = 0;
for (let i = 0; i < startSample; i++) newData[idx++] = data[i];
for (let i = endSample; i < data.length; i++) newData[idx++] = data[i];
setSubTabs(prev => prev.map(s => s.id === tabId ? {
...s,
buffer: newBuffer,
currentTime: left,
selectionStart: null,
selectionEnd: null
} : s));
showToast('Đã xóa vùng chọn.', 'success');
};
const handleSubTabLoop = (tabId, loopCount) => {
const st = subTabsRef.current.find(s => s.id === tabId);
if (!st || !st.buffer) return;
const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : null;
const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : null;
if (left === null || right === null || left === right) {
showToast('Vui lòng chọn vùng để Loop.', 'warning');
return;
}
const ctx = getAudioContext();
const sr = st.buffer.sampleRate;
const data = st.buffer.getChannelData(0);
const startSample = Math.floor(left * sr);
const endSample = Math.floor(right * sr);
const len = endSample - startSample;
// Loop payload N times
const segmentData = data.subarray(startSample, endSample);
const addedSamples = len * (loopCount - 1);
const newBuffer = ctx.createBuffer(1, data.length + addedSamples, sr);
const newData = newBuffer.getChannelData(0);
let idx = 0;
for (let i = 0; i < endSample; i++) newData[idx++] = data[i];
for (let n = 0; n < loopCount - 1; n++) {
for (let i = 0; i < len; i++) newData[idx++] = segmentData[i];
}
for (let i = endSample; i < data.length; i++) newData[idx++] = data[i];
setSubTabs(prev => prev.map(s => s.id === tabId ? {
...s,
buffer: newBuffer,
selectionStart: null,
selectionEnd: null
} : s));
showToast(`Đã lặp vùng chọn ${loopCount} lần.`, 'success');
};
useEffect(() => {
const handler = (e) => {
const ctrl = e.ctrlKey || e.metaKey;
const alt = e.altKey;
// Global space play/pause shortcut for transport
if (e.key === ' ' || e.code === 'Space') {
e.preventDefault();
if (handlePlayPauseRef.current) handlePlayPauseRef.current();
return;
}
if (activeTabRef.current !== 'main') {
// Sub-Tab keyboard shortcuts mapping
const curTabId = activeTabRef.current;
if (ctrl && alt && e.key === 'n') { e.preventDefault(); handleSubTabNormalize(curTabId); return; }
if (e.key === 'f' || e.key === 'F') { e.preventDefault(); handleSubTabFade(curTabId, 'in'); return; }
if (e.key === 'g' || e.key === 'G') { e.preventDefault(); handleSubTabFade(curTabId, 'out'); return; }
if (ctrl && e.key === 'l') { e.preventDefault(); handleSubTabLoop(curTabId, 4); return; }
if (e.key === 'v' || e.key === 'V') { e.preventDefault(); const val = prompt("Nhập Gain điều chỉnh (dB):", "0"); if(val) handleSubTabGain(curTabId, parseFloat(val) || 0); return; }
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();
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; }
if (ctrl && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); handleRedoRef.current(); return; }
if (ctrl && !alt && e.key === 'o') { e.preventDefault(); showToast('Open Project dialog','info'); return; }
if (ctrl && !alt && e.key === 'n') { e.preventDefault(); setTracks([{ 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:'2', name:'Track 02', buffer:null, startTime:0, height:96, volumeDb:0, pan:0, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','info'); return; }
if (ctrl && !alt && e.key === 's') { e.preventDefault(); showToast('Project saved','success'); return; }
if ((ctrl && alt && e.key === 's') || (ctrl && e.shiftKey && e.key === 's')) { e.preventDefault(); showToast('Save As dialog','info'); return; }
if (ctrl && !alt && e.key === 'i') { e.preventDefault(); addNewTrack(); return; }
if (ctrl && alt && e.key === 'i') { e.preventDefault(); showToast('Import audio','info'); return; }
if (ctrl && !alt && e.key === 'e') { e.preventDefault(); openTempTab(); return; }
if (ctrl && !alt && e.key === 'm') { e.preventDefault(); handleMergeTracks(); return; }
if (ctrl && !alt && e.key === 'c') { e.preventDefault(); handleCopyTrack(); return; }
if (ctrl && !alt && e.key === 'x') { e.preventDefault(); handleCutTrack(); return; }
if (ctrl && !alt && e.key === 'v') { e.preventDefault(); handlePasteTrack(); return; }
if (e.key === 'Delete' || e.key === 'Backspace' || e.key === 'Del') {
const selClip = selectedClipIdRef.current;
if (selClip) {
e.preventDefault();
const { trackId, clipId } = selClip;
setTracks(prev => {
const track = prev.find(t => t.id === trackId);
if (!track) return prev;
const beforeSnap = captureTrackSnapshotRef.current ? captureTrackSnapshotRef.current(trackId) : null;
const updatedClips = (track.clips || []).filter(c => c.id !== clipId);
const updatedTracks = prev.map(t => {
if (t.id === trackId) {
return {
...t,
clips: updatedClips,
buffer: updatedClips.length > 0 ? updatedClips[0].buffer : null,
startTime: updatedClips.length > 0 ? updatedClips[0].startTime : 0,
name: updatedClips.length > 0 ? updatedClips[0].name : `Track ${t.id}`
};
}
return t;
});
setTimeout(() => {
const afterSnap = captureTrackSnapshotRef.current ? captureTrackSnapshotRef.current(trackId) : null;
pushAction('DELETE_CLIP', trackId, beforeSnap, afterSnap);
}, 50);
return updatedTracks;
});
setSelectedClipId(null);
showToast('Đã xóa clip.', 'info');
return;
} else {
e.preventDefault();
handleDeleteTrack();
return;
}
}
if (!ctrl && !alt && e.key === 's') { e.preventDefault(); handleSplitTrack(selectedTrackId); return; }
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
// ── Temp Tab: draw isolated waveform ──
useEffect(() => {
if (!tempTabActive || !tempTabBuffer || !tempTabCanvasRef.current) return;
const canvas = tempTabCanvasRef.current;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const w = rect.width;
const h = rect.height;
ctx.fillStyle = '#181818';
ctx.fillRect(0, 0, w, h);
const data = tempTabBuffer.getChannelData(0);
const sr = tempTabBuffer.sampleRate;
const totalSamples = data.length;
if (totalSamples === 0) return;
ctx.strokeStyle = '#6ee7b7';
ctx.lineWidth = 1;
for (let px = 0; px < w; px++) {
const startSample = Math.floor((px / w) * totalSamples);
const endSample = Math.floor(((px + 1) / w) * totalSamples);
let maxVal = 0;
for (let i = startSample; i < endSample && i < totalSamples; i++) {
const abs = Math.abs(data[i]);
if (abs > maxVal) maxVal = abs;
}
const mid = h / 2;
const peakHeight = maxVal * (h * 0.4);
ctx.beginPath();
ctx.moveTo(px, mid - peakHeight);
ctx.lineTo(px, mid + peakHeight);
ctx.stroke();
}
}, [tempTabActive, tempTabBuffer]);
// ── Sub Tab: open as new tab instead of modal (LOOP_EDITOR_2.md §1) ──
const openTempTab = () => {
const useLocal = selectionMode === 'local' && localSelectionTrackId;
const trackId = useLocal ? localSelectionTrackId : selectedTrackId;
const t = tracks.find(x => x.id === trackId);
if (!t || !t.buffer) {
showToast('Vui lòng chọn track có dữ liệu âm thanh.', 'warning');
return;
}
if (selLeft === null || selRight === null || selRight <= selLeft) {
showToast('Vui lòng chọn một khoảng thời gian trên sóng âm.', 'warning');
return;
}
// Check if a subtab for this track+range already exists
const existing = subTabs.find(s => s.trackId === trackId && s.startTime === selLeft && s.endTime === selRight);
if (existing) {
setActiveTab(existing.id);
showToast(`Sub Tab already open.`, 'info');
return;
}
const sr = t.buffer.sampleRate;
const trackStart = t.startTime || 0;
const relSelLeft = Math.max(0, selLeft - trackStart);
const relSelRight = Math.max(0, selRight - trackStart);
const startSample = Math.max(0, Math.floor(relSelLeft * sr));
const endSample = Math.min(t.buffer.length, Math.floor(relSelRight * sr));
const len = endSample - startSample;
if (len < 100) {
showToast('Khoảng chọn quá ngắn.', 'warning');
return;
}
const ctx = getAudioContext();
const subBuffer = ctx.createBuffer(1, len, sr);
subBuffer.copyToChannel(t.buffer.getChannelData(0).subarray(startSample, endSample), 0);
const tabId = 'subtab_' + Date.now();
const tabLabel = `Edit_${t.name.replace('.wav','').slice(0,10)}_${selLeft.toFixed(1)}s`;
setSubTabs(prev => [...prev, {
id: tabId,
label: tabLabel,
trackId: trackId,
startTime: selLeft,
endTime: selRight,
buffer: subBuffer,
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0, normalizeDb: 0, pitch: 0, speedStretch: 100 },
currentTime: 0,
selectionStart: null,
selectionEnd: null,
isPlaying: false,
speed: 1.0,
volumeNodes: [],
panningNodes: [],
fadeInLen: 0,
fadeOutLen: 0,
graphMode: null,
isLooping: false,
loopCount: 0
}]);
setActiveTab(tabId);
};
const handleEditClipInSubTab = (trackId, clipId) => {
const track = tracks.find(t => t.id === trackId);
if (!track) return;
const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
id: 'default_' + track.id,
buffer: track.buffer,
startTime: track.startTime || 0,
name: track.name
}] : []);
const clip = clips.find(c => c.id === clipId || (clipId === 'default' && c.id === 'default_' + trackId));
if (!clip || !clip.buffer) return;
const resolvedClipId = clip.id === 'default' ? 'default_' + trackId : clip.id;
const existing = subTabs.find(s => s.clipId === resolvedClipId && s.trackId === trackId);
if (existing) {
setActiveTab(existing.id);
showToast(`Sub Tab for "${clip.name}" already open.`, 'info');
return;
}
const sr = clip.buffer.sampleRate;
const len = clip.buffer.length;
const ctx = getAudioContext();
const subBuffer = ctx.createBuffer(1, len, sr);
subBuffer.copyToChannel(clip.buffer.getChannelData(0), 0);
const tabId = 'subtab_' + Date.now();
const tabLabel = `Edit_${clip.name.replace('.wav','').slice(0,10)}`;
setSubTabs(prev => [...prev, {
id: tabId,
label: tabLabel,
trackId: trackId,
clipId: resolvedClipId,
startTime: clip.startTime,
endTime: clip.startTime + clip.buffer.duration,
buffer: subBuffer,
effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0, normalizeDb: 0, pitch: 0, speedStretch: 100 },
currentTime: 0,
selectionStart: null,
selectionEnd: null,
isPlaying: false,
speed: 1.0,
volumeNodes: [],
panningNodes: [],
fadeInLen: 0,
fadeOutLen: 0,
graphMode: null,
isLooping: false,
loopCount: 0
}]);
setActiveTab(tabId);
};
// ── Sub Tab: apply effects & merge back (LOOP_EDITOR_2.md §1.2) ──
const applySubTab = (tabId) => {
const subTab = subTabs.find(s => s.id === tabId);
if (!subTab || !subTab.buffer) return;
const track = tracks.find(t => t.id === subTab.trackId);
if (!track || !track.buffer) return;
const beforeSnap = captureTrackSnapshot(subTab.trackId);
// Clone buffer and apply effects
const ctx = getAudioContext();
const sr = subTab.buffer.sampleRate;
const eff = subTab.buffer.getChannelData(0);
let resultBuffer = ctx.createBuffer(1, eff.length, sr);
let resultData = resultBuffer.getChannelData(0);
resultData.set(eff);
// Apply effects inline
const fx = subTab.effects || {};
const applyResample = (data, ratio) => {
const newLen = Math.round(data.length * ratio);
const out = new Float32Array(newLen);
for (let i = 0; i < newLen; i++) {
const srcIdx = i / ratio;
const idx0 = Math.floor(srcIdx);
const idx1 = Math.min(idx0 + 1, data.length - 1);
const frac = srcIdx - idx0;
out[i] = data[idx0] * (1 - frac) + data[idx1] * frac;
}
return out;
};
if (fx.reverse) {
const rev = new Float32Array(resultData);
for (let i = 0; i < resultData.length; i++) rev[i] = resultData[resultData.length - 1 - i];
resultData.set(rev);
}
if (fx.gainDb !== 0) {
const gain = Math.pow(10, fx.gainDb / 20);
for (let i = 0; i < resultData.length; i++) resultData[i] = Math.max(-1, Math.min(1, resultData[i] * gain));
}
if (fx.fadeInMs > 0) {
const fadeSamples = Math.min(resultData.length, Math.floor(fx.fadeInMs / 1000 * sr));
for (let i = 0; i < fadeSamples; i++) resultData[i] *= i / fadeSamples;
}
// Graph Editor fade curves (trigonometric, 15_GRAPH_EDIT.md §2.3)
const gFadeIn = subTab.fadeInLen || 0;
const gFadeOut = subTab.fadeOutLen || 0;
if (gFadeIn > 0) {
const fadeSamples = Math.min(resultData.length, Math.floor(gFadeIn * sr));
for (let i = 0; i < fadeSamples; i++) {
resultData[i] *= (1 - Math.cos(Math.PI * i / fadeSamples)) / 2;
}
}
if (gFadeOut > 0) {
const fadeSamples = Math.min(resultData.length, Math.floor(gFadeOut * sr));
for (let i = resultData.length - fadeSamples; i < resultData.length; i++) {
const t = i - (resultData.length - fadeSamples);
resultData[i] *= (1 + Math.cos(Math.PI * t / fadeSamples)) / 2;
}
}
// 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; }
if (maxVal > 0) {
const targetAmp = Math.pow(10, fx.normalizeDb / 20);
const scale = targetAmp / maxVal;
for (let i = 0; i < resultData.length; i++) resultData[i] = Math.max(-1, Math.min(1, resultData[i] * scale));
}
}
if (fx.pitch !== 0) {
const ratio = Math.pow(2, fx.pitch / 12);
const newData = applyResample(resultData, 1 / ratio);
resultBuffer = ctx.createBuffer(1, newData.length, sr);
resultData = resultBuffer.getChannelData(0);
resultData.set(newData);
}
const stretchSpeed = subTab.speed || 1.0;
if (stretchSpeed !== 1.0) {
const ratio = 1.0 / stretchSpeed;
const newData = applyResample(resultData, ratio);
resultBuffer = ctx.createBuffer(1, newData.length, sr);
resultData = resultBuffer.getChannelData(0);
resultData.set(newData);
}
if (fx.speedStretch !== 100) {
const ratio = fx.speedStretch / 100;
const newData = applyResample(resultData, ratio);
resultBuffer = ctx.createBuffer(1, newData.length, sr);
resultData = resultBuffer.getChannelData(0);
resultData.set(newData);
}
if (subTab.clipId) {
// Clip-based merge
setTracks(prev => prev.map(t => {
if (t.id !== subTab.trackId) return t;
const updatedClips = (t.clips || []).map(c => {
if (c.id === subTab.clipId) {
return {
...c,
buffer: resultBuffer,
name: c.name.endsWith('(edited)') ? c.name : c.name + ' (edited)'
};
}
return c;
});
return {
...t,
clips: updatedClips,
buffer: updatedClips[0]?.buffer,
startTime: updatedClips[0]?.startTime || 0,
name: updatedClips[0]?.name || t.name
};
}));
} else {
// Crossfade merge into original track (§2.2)
const sr = track.buffer.sampleRate;
const origData = track.buffer.getChannelData(0);
const trackStart = track.startTime || 0;
const startSample = Math.floor((subTab.startTime - trackStart) * sr);
const endSample = Math.floor((subTab.endTime - trackStart) * sr);
const crossfadeLen = Math.min(100, Math.floor(0.01 * sr)); // 10ms
const mergedBuffer = ctx.createBuffer(1, track.buffer.length, sr);
const mergedData = mergedBuffer.getChannelData(0);
for (let i = 0; i < startSample; i++) mergedData[i] = origData[i];
for (let i = endSample; i < track.buffer.length; i++) mergedData[i] = origData[i];
for (let i = 0; i < resultData.length; i++) {
const globalIdx = startSample + i;
let val = resultData[i];
if (i < crossfadeLen) {
const alpha = i / crossfadeLen;
val = (1 - alpha) * (origData[globalIdx] || 0) + alpha * resultData[i];
} else if (i > resultData.length - crossfadeLen) {
const distFromEnd = resultData.length - 1 - i;
const alpha = distFromEnd / crossfadeLen;
const origEndIdx = endSample - (resultData.length - i);
val = alpha * (origEndIdx >= 0 ? origData[origEndIdx] : 0) + (1 - alpha) * resultData[i];
}
mergedData[globalIdx] = val;
}
setTracks(prev => prev.map(t => {
if (t.id !== subTab.trackId) return t;
return { ...t, buffer: mergedBuffer, name: t.name + ' (edited)' };
}));
}
const afterSnap = captureTrackSnapshot(subTab.trackId);
pushAction('EDIT_TAB', subTab.trackId, beforeSnap, afterSnap);
// Tab Lifetime: Engaging the Apply trigger propagates data back to the primary environment but does not close down the active sub-tab view.
// closeSubTab(tabId);
showToast('Đã áp dụng chỉnh sửa vào track chính.', 'success');
};
const applySubTabEffect = (tabId, effectType, value) => {
const subTab = subTabs.find(s => s.id === tabId);
if (!subTab || !subTab.buffer) return;
const ctx = getAudioContext();
const sr = subTab.buffer.sampleRate;
const eff = subTab.buffer.getChannelData(0);
// Clone buffer
let resultBuffer = ctx.createBuffer(1, eff.length, sr);
let resultData = resultBuffer.getChannelData(0);
resultData.set(eff);
// Calculate selection start/end in unstretched sample indices
const speed = subTab.speed || 1.0;
const hasSelection = subTab.selectionStart !== null && subTab.selectionEnd !== null && subTab.selectionStart !== subTab.selectionEnd;
const tStart = hasSelection ? Math.min(subTab.selectionStart, subTab.selectionEnd) * speed : 0;
const tEnd = hasSelection ? Math.max(subTab.selectionStart, subTab.selectionEnd) * speed : subTab.buffer.duration;
const startSample = Math.max(0, Math.min(eff.length, Math.floor(tStart * sr)));
const endSample = Math.max(0, Math.min(eff.length, Math.floor(tEnd * sr)));
if (effectType === 'normalize') {
let maxVal = 0;
for (let i = startSample; i < endSample; i++) {
const abs = Math.abs(resultData[i]);
if (abs > maxVal) maxVal = abs;
}
if (maxVal > 0) {
const targetAmp = Math.pow(10, value / 20);
const scale = targetAmp / maxVal;
for (let i = startSample; i < endSample; i++) {
resultData[i] = Math.max(-1, Math.min(1, resultData[i] * scale));
}
}
} else if (effectType === 'gain') {
const gainScale = value / 100;
for (let i = startSample; i < endSample; i++) {
resultData[i] = Math.max(-1, Math.min(1, resultData[i] * gainScale));
}
} else if (effectType === 'pitch') {
const ratio = Math.pow(2, value / 12);
const applyResample = (data, r) => {
const newLen = Math.round(data.length * r);
const out = new Float32Array(newLen);
for (let i = 0; i < newLen; i++) {
const srcIdx = i / r;
const idx0 = Math.floor(srcIdx);
const idx1 = Math.min(idx0 + 1, data.length - 1);
const frac = srcIdx - idx0;
out[i] = data[idx0] * (1 - frac) + data[idx1] * frac;
}
return out;
};
const sub = resultData.slice(startSample, endSample);
const subResampled = applyResample(sub, 1 / ratio);
for (let i = 0; i < endSample - startSample; i++) {
resultData[startSample + i] = i < subResampled.length ? subResampled[i] : 0.0;
}
}
// Update subTab buffer state
setSubTabs(prev => prev.map(s => {
if (s.id !== tabId) return s;
return {
...s,
buffer: resultBuffer,
selectionStart: null,
selectionEnd: null
};
}));
showToast(`Đã áp dụng ${effectType === 'normalize' ? 'Normalize' : effectType === 'gain' ? 'Gain' : 'Pitch'} cho ${hasSelection ? 'vùng chọn' : 'toàn bộ clip'}.`, 'success');
};
const exportSubTabBuffer = async (tabId) => {
const subTab = subTabs.find(s => s.id === tabId);
if (!subTab || !subTab.buffer) return;
try {
const buffer = subTab.buffer;
const sr = buffer.sampleRate;
const monoData = buffer.getChannelData(0);
const bufferLength = monoData.length;
const bitDepth = 16;
const bytesPerSample = 2;
const headerSize = 44;
const fileSizeBytes = headerSize + (bufferLength * bytesPerSample);
const fileBuffer = new ArrayBuffer(fileSizeBytes);
const view = new DataView(fileBuffer);
const writeString = (offset, string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
};
writeString(0, 'RIFF');
view.setUint32(4, fileSizeBytes - 8, true);
writeString(8, 'WAVE');
writeString(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, sr, true);
view.setUint32(28, sr * bytesPerSample, true);
view.setUint16(32, bytesPerSample, true);
view.setUint16(34, bitDepth, true);
writeString(36, 'data');
view.setUint32(40, bufferLength * bytesPerSample, true);
let offset = 44;
for (let i = 0; i < bufferLength; i++) {
const sample = Math.max(-1, Math.min(1, monoData[i]));
view.setInt16(offset, Math.floor(sample < 0 ? sample * 0x8000 : sample * 0x7FFF), true);
offset += bytesPerSample;
}
const blob = new Blob([view], { type: 'audio/wav' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${subTab.label || 'subtab-export'}.wav`;
a.click();
URL.revokeObjectURL(url);
showToast("Xuất bản sub-tab hoàn tất!", "success");
} catch (err) {
showToast("Lỗi xuất âm thanh: " + err.message, "error");
}
};
const closeSubTab = (tabId) => {
setSubTabs(prev => prev.filter(s => s.id !== tabId));
if (activeTab === tabId) setActiveTab('main');
};
const updateSubTabEffects = (tabId, effects) => {
setSubTabs(prev => prev.map(s => s.id === tabId ? { ...s, effects: { ...s.effects, ...effects } } : s));
};
const handleSubTabNormalizeWithValue = (tabId, normalizeDb) => {
updateSubTabEffects(tabId, { normalizeDb });
};
const handleSubTabGainWithValue = (tabId, gainDb) => {
updateSubTabEffects(tabId, { gainDb });
};
const handleSubTabPitch = (tabId, pitch) => {
updateSubTabEffects(tabId, { pitch });
};
const handleSubTabStretch = (tabId, speedStretch) => {
updateSubTabEffects(tabId, { speedStretch });
};
const handleSubTabFadeState = (tabId, type) => {
const st = subTabs.find(s => s.id === tabId);
if (!st) return;
const fx = st.effects || {};
if (type === 'in') {
updateSubTabEffects(tabId, { fadeInMs: (fx.fadeInMs || 0) + 10 });
} else if (type === 'out') {
updateSubTabEffects(tabId, { fadeOutMs: (fx.fadeOutMs || 0) + 10 });
}
};
// ── Context Menu Handlers ──
const handleContextMenu = (e, trackId, clickTime) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, trackId, time: clickTime || currentTime });
};
const closeContextMenu = () => setContextMenu(null);
// Close context menu on any click outside
useEffect(() => {
const handler = () => { if (contextMenu) closeContextMenu(); };
if (contextMenu) {
window.addEventListener('click', handler);
return () => window.removeEventListener('click', handler);
}
}, [contextMenu]);
const contextMenuEdit = () => {
const track = tracks.find(t => t.id === contextMenu.trackId);
if (track) setSelectedTrackId(contextMenu.trackId);
closeContextMenu();
const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
id: 'default',
buffer: track.buffer,
startTime: track.startTime || 0,
name: track.name,
speed: track.speed || 1.0
}] : []);
const clickedClip = clips.find(c => contextMenu.time >= c.startTime && contextMenu.time < c.startTime + c.buffer.duration / (c.speed || 1.0));
if (clickedClip) {
handleEditClipInSubTab(contextMenu.trackId, clickedClip.id);
} else {
openTempTab();
}
};
const contextMenuSplit = () => {
closeContextMenu();
handleSplitTrack(contextMenu.trackId);
};
const contextMenuDelete = () => {
const tid = contextMenu.trackId;
const beforeSnap = captureTrackSnapshot(tid);
setTracks(prev => prev.filter(t => t.id !== tid));
const afterSnap = captureTrackSnapshot(tid);
pushAction('DELETE', tid, beforeSnap, afterSnap);
if (selectedTrackId === tid) {
setSelectedTrackId(tracks.filter(t => t.id !== tid)[0]?.id || '1');
}
closeContextMenu();
showToast('Đã xoá track.', 'info');
};
const contextMenuCopy = () => {
const track = tracks.find(t => t.id === contextMenu.trackId);
if (!track || !track.buffer) return;
const sr = track.buffer.sampleRate;
const data = track.buffer.getChannelData(0);
// Copy selected region if selection exists
if (selLeft !== null && selRight !== null && selRight > selLeft) {
const trackStart = track.startTime || 0;
const relSelLeft = Math.max(0, selLeft - trackStart);
const relSelRight = Math.max(0, selRight - trackStart);
const startSample = Math.floor(relSelLeft * sr);
const endSample = Math.min(data.length, Math.floor(relSelRight * sr));
const len = endSample - startSample;
if (len > 0) {
const ctx = getAudioContext();
const clipBuffer = ctx.createBuffer(1, len, sr);
clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0);
clipboardRef.current = { buffer: clipBuffer, name: track.name, volumeDb: track.volumeDb, pan: track.pan, color: track.color };
closeContextMenu();
showToast('Đã sao chép vùng chọn.', 'info');
return;
}
}
// No selection: copy entire track
clipboardRef.current = {
buffer: track.buffer,
name: track.name,
volumeDb: track.volumeDb,
pan: track.pan,
color: track.color,
};
closeContextMenu();
showToast('Đã sao chép toàn bộ track.', 'info');
};
const contextMenuCut = () => {
const t = tracks.find(x => x.id === contextMenu.trackId);
if (!t || !t.buffer) { contextMenuDelete(); return; }
const beforeSnap = captureTrackSnapshot(contextMenu.trackId);
const sr = t.buffer.sampleRate;
const data = t.buffer.getChannelData(0);
if (selLeft !== null && selRight !== null && selRight > selLeft) {
const trackStart = t.startTime || 0;
const relSelLeft = Math.max(0, selLeft - trackStart);
const relSelRight = Math.max(0, selRight - trackStart);
const startSample = Math.floor(relSelLeft * sr);
const endSample = Math.min(data.length, Math.floor(relSelRight * sr));
const len = endSample - startSample;
if (len > 0) {
const ctx = getAudioContext();
const clipBuffer = ctx.createBuffer(1, len, sr);
clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0);
clipboardRef.current = { buffer: clipBuffer, name: t.name, volumeDb: t.volumeDb, color: t.color };
const newLen = data.length - len;
const newBuffer = ctx.createBuffer(1, newLen, sr);
const newData = newBuffer.getChannelData(0);
let idx = 0;
for (let i = 0; i < startSample; i++) newData[idx++] = data[i];
for (let i = endSample; i < data.length; i++) newData[idx++] = data[i];
setTracks(p => p.map(tr => tr.id === contextMenu.trackId ? { ...tr, buffer: newBuffer } : tr));
const afterSnap = captureTrackSnapshot(contextMenu.trackId);
pushAction('CUT', contextMenu.trackId, beforeSnap, afterSnap);
closeContextMenu();
showToast('Đã cắt vùng chọn vào clipboard.', 'info');
return;
}
}
contextMenuCopy();
contextMenuDelete();
};
const doPaste = (targetTrackId, pasteTime) => {
if (!clipboardRef.current || !clipboardRef.current.buffer) {
showToast('Clipboard trống.', 'warning');
return null;
}
const { buffer: clipBuffer, name, volumeDb, pan, color } = clipboardRef.current;
const ctx = getAudioContext();
const targetTrack = tracks.find(t => t.id === targetTrackId);
const newClip = {
id: 'clip_' + Date.now() + '_' + Math.floor(Math.random() * 1000),
startTime: pasteTime,
buffer: clipBuffer,
name: name || 'Pasted Clip'
};
if (targetTrack) {
setTracks(p => p.map(t => {
if (t.id === targetTrackId) {
const existingClips = t.clips && t.clips.length > 0 ? t.clips : (t.buffer ? [{
id: 'default_' + t.id,
buffer: t.buffer,
startTime: t.startTime || 0,
name: t.name
}] : []);
const updatedClips = [...existingClips, newClip];
return {
...t,
clips: updatedClips,
buffer: updatedClips[0].buffer,
startTime: updatedClips[0].startTime,
name: name || t.name,
volumeDb: volumeDb ?? t.volumeDb,
pan: pan ?? t.pan,
color: color || t.color
};
}
return t;
}));
setCurrentTime(pasteTime);
showToast('Đã dán clip vào track.', 'success');
return targetTrackId;
}
// No matching track — create a new one
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
const newId = 'track_pasted_' + Date.now();
setTracks(prev => [...prev, {
id: newId,
name: `Pasted_${name || 'track'}`,
buffer: clipBuffer,
startTime: pasteTime,
clips: [newClip],
volumeDb: volumeDb ?? 0, pan: pan ?? 0, muted: false, solo: false,
color: color || colors[prev.length % colors.length],
markers: [], serverFileId: null,
}]);
setSelectedTrackId(newId);
setCurrentTime(pasteTime);
showToast('Đã dán track mới từ clipboard.', 'success');
return newId;
};
const handlePasteTrack = () => doPaste(selectedTrackId, currentTime);
const contextMenuPaste = () => {
const result = doPaste(contextMenu.trackId, contextMenu.time || currentTime);
closeContextMenu();
};
const contextMenuMerge = () => {
const activeTracks = tracks.filter(t => t.buffer && !t.muted);
if (activeTracks.length < 2) {
showToast('Cần ít nhất 2 track có dữ liệu để merge.', 'warning');
closeContextMenu();
return;
}
const ctx = getAudioContext();
const maxDur = Math.max(...activeTracks.map(t => (t.startTime || 0) + t.buffer.duration));
const sr = activeTracks[0].buffer.sampleRate;
const merged = ctx.createBuffer(1, Math.ceil(maxDur * sr), sr);
const mergedData = merged.getChannelData(0);
activeTracks.forEach(t => {
const data = t.buffer.getChannelData(0);
const startSample = Math.floor((t.startTime || 0) * sr);
const volLinear = (t.volumeDb ?? 0) <= -50 ? 0 : Math.pow(10, (t.volumeDb ?? 0) / 20);
for (let i = 0; i < data.length; i++) {
if (startSample + i < mergedData.length) {
mergedData[startSample + i] += data[i] * volLinear;
}
}
});
let maxPeak = 0;
for (let i = 0; i < mergedData.length; i++) {
const abs = Math.abs(mergedData[i]);
if (abs > maxPeak) maxPeak = abs;
}
if (maxPeak > 1.0) {
for (let i = 0; i < mergedData.length; i++) mergedData[i] /= maxPeak;
}
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
const newId = 'track_merged_' + Date.now();
const names = activeTracks.map(t => t.name).join('+').slice(0, 30);
setTracks(prev => [...prev, {
id: newId, name: `Merged_${names}.wav`, buffer: merged, startTime: 0,
volumeDb: 0, pan: 0, muted: false, solo: false,
color: colors[prev.length % colors.length], markers: [], serverFileId: null,
}]);
setSelectedTrackId(newId);
closeContextMenu();
showToast(`Đã merge ${activeTracks.length} tracks.`, 'success');
};
// Menu bar direct handlers (don't rely on contextMenu state)
const handleMergeTracks = () => {
const at = tracks.filter(t => t.buffer && !t.muted);
if (at.length < 2) { showToast('Cần 2+ tracks để merge.','warning'); return; }
const actx = getAudioContext();
const maxDur = Math.max(...at.map(t => (t.startTime || 0) + t.buffer.duration));
const sr = at[0].buffer.sampleRate;
const mb = actx.createBuffer(1, Math.ceil(maxDur * sr), sr);
const mdata = mb.getChannelData(0);
at.forEach(t => {
const d = t.buffer.getChannelData(0);
const startSample = Math.floor((t.startTime || 0) * sr);
const volLinear = (t.volumeDb ?? 0) <= -50 ? 0 : Math.pow(10, (t.volumeDb ?? 0) / 20);
for (let i = 0; i < d.length; i++) {
if (startSample + i < mdata.length) {
mdata[startSample + i] += d[i] * volLinear;
}
}
});
let mp = 0; for (let i=0;i<mdata.length;i++) { const a=Math.abs(mdata[i]); if (a>mp) mp=a; }
if (mp > 1.0) for (let i=0;i<mdata.length;i++) mdata[i] /= mp;
setTracks(p => [...p, { id:'merged_'+Date.now(), name:'Merged_mix.wav', buffer:mb, startTime: 0, height: 96, volumeDb:0, pan:0, muted:false, solo:false, color:['#0f766e','#1d4ed8'][p.length%2], markers:[], serverFileId:null }]);
showToast('Merged all unmuted tracks.','success');
};
const handleCopyTrack = () => {
const t = tracks.find(x => x.id === selectedTrackId);
if (!t || !t.buffer) return;
const sr = t.buffer.sampleRate;
const data = t.buffer.getChannelData(0);
// If selection exists, copy only the selected region
if (selLeft !== null && selRight !== null && selRight > selLeft) {
const trackStart = t.startTime || 0;
const relSelLeft = Math.max(0, selLeft - trackStart);
const relSelRight = Math.max(0, selRight - trackStart);
const startSample = Math.floor(relSelLeft * sr);
const endSample = Math.min(data.length, Math.floor(relSelRight * sr));
const len = endSample - startSample;
if (len > 0) {
const ctx = getAudioContext();
const clipBuffer = ctx.createBuffer(1, len, sr);
clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0);
clipboardRef.current = { buffer: clipBuffer, name: t.name, volumeDb: t.volumeDb, color: t.color };
showToast('Copied selection to clipboard.', 'info');
return;
}
}
// No selection: copy entire track
clipboardRef.current = { buffer: t.buffer, name: t.name, volumeDb: t.volumeDb, color: t.color };
showToast('Copied track to clipboard.', 'info');
};
const handleCutTrack = () => {
const t = tracks.find(x => x.id === selectedTrackId);
if (!t || !t.buffer) return;
const beforeSnap = captureTrackSnapshot(selectedTrackId);
const sr = t.buffer.sampleRate;
const data = t.buffer.getChannelData(0);
// If selection exists, cut only the selected region
if (selLeft !== null && selRight !== null && selRight > selLeft) {
const trackStart = t.startTime || 0;
const relSelLeft = Math.max(0, selLeft - trackStart);
const relSelRight = Math.max(0, selRight - trackStart);
const startSample = Math.floor(relSelLeft * sr);
const endSample = Math.min(data.length, Math.floor(relSelRight * sr));
const len = endSample - startSample;
if (len > 0) {
// Copy selection to clipboard
const ctx = getAudioContext();
const clipBuffer = ctx.createBuffer(1, len, sr);
clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0);
clipboardRef.current = { buffer: clipBuffer, name: t.name, volumeDb: t.volumeDb, color: t.color };
// Remove selection from track, glue the two remaining parts
const newLen = data.length - len;
const newBuffer = ctx.createBuffer(1, newLen, sr);
const newData = newBuffer.getChannelData(0);
let idx = 0;
for (let i = 0; i < startSample; i++) newData[idx++] = data[i];
for (let i = endSample; i < data.length; i++) newData[idx++] = data[i];
setTracks(p => p.map(tr => tr.id === selectedTrackId ? { ...tr, buffer: newBuffer } : tr));
const afterSnap = captureTrackSnapshot(selectedTrackId);
pushAction('CUT', selectedTrackId, beforeSnap, afterSnap);
showToast('Cut selection to clipboard.', 'info');
return;
}
}
// No selection: cut entire track (copy + delete)
handleCopyTrack();
handleDeleteTrack();
};
const handleDeleteTrack = () => {
const tid = selectedTrackId;
setTracks(p => p.filter(t => t.id !== tid));
setSelectedTrackId(tracks.filter(t => t.id !== tid)[0]?.id || '1');
showToast('Deleted track.','info');
};
// ── Server Health Check ──
const [viewportWidth, setViewportWidth] = useState(1200);
useEffect(() => {
const wrapper = timelineWrapperRef.current;
if (!wrapper) return;
const observer = new ResizeObserver(entries => {
for (let entry of entries) {
setViewportWidth(entry.contentRect.width);
}
});
observer.observe(wrapper);
return () => observer.disconnect();
}, []);
useEffect(() => {
fetch(API_BASE_URL)
.then(r => { if (r.ok) setServerStatus('connected'); else setServerStatus('error'); })
.catch(() => setServerStatus('offline'));
}, []);
// ── Computed Values ──
const maxDuration = useMemo(() => {
let max = 10;
tracks.forEach(t => {
const clips = t.clips && t.clips.length > 0 ? t.clips : (t.buffer ? [{
id: 'default',
buffer: t.buffer,
startTime: t.startTime || 0,
name: t.name,
speed: t.speed || 1.0
}] : []);
clips.forEach(c => {
if (c.buffer) {
const cStart = c.startTime || 0;
const cDur = c.buffer.duration / (c.speed || 1.0);
max = Math.max(max, cStart + cDur);
}
});
});
return max;
}, [tracks]);
const maxDurationRef = useRef(maxDuration);
maxDurationRef.current = maxDuration;
const minZoom = useMemo(() => {
return viewportWidth / maxDuration;
}, [viewportWidth, maxDuration]);
const timelineWidth = useMemo(() => {
return Math.max(zoom * maxDuration, viewportWidth);
}, [zoom, maxDuration, viewportWidth]);
useEffect(() => {
if (zoom < minZoom) {
setZoom(minZoom);
}
}, [minZoom]);
const playheadLeftPos = useMemo(() => currentTime * zoom, [currentTime, zoom]);
const selLeft = useMemo(() => {
if (selectionMode === 'local' && localSelectionStart !== null && localSelectionEnd !== null) {
return Math.min(localSelectionStart, localSelectionEnd);
}
if (selectionStart === null || selectionEnd === null) return null;
return Math.min(selectionStart, selectionEnd);
}, [selectionStart, selectionEnd, selectionMode, localSelectionStart, localSelectionEnd]);
const selRight = useMemo(() => {
if (selectionMode === 'local' && localSelectionStart !== null && localSelectionEnd !== null) {
return Math.max(localSelectionStart, localSelectionEnd);
}
if (selectionStart === null || selectionEnd === null) return null;
return Math.max(selectionStart, selectionEnd);
}, [selectionStart, selectionEnd, selectionMode, localSelectionStart, localSelectionEnd]);
// ── Toast helper ──
const showToast = (text, type = 'info') => {
if (toastTimeoutRef.current) clearTimeout(toastTimeoutRef.current);
setToastMessage({ text, type });
toastTimeoutRef.current = setTimeout(() => setToastMessage(null), 3500);
};
// ── Server-side upload ──
const uploadToServer = async (file, trackId) => {
const formData = new FormData();
formData.append('file', file);
try {
const resp = await fetch(`${API_AUDIO}/upload`, {
method: 'POST',
body: formData,
});
if (!resp.ok) throw new Error(`Upload failed: ${resp.status}`);
const data = await resp.json();
serverFileIdMap[trackId] = data.file_id;
return data;
} catch (err) {
console.warn('Server upload failed, using client-side only:', err.message);
return null;
}
};
// ── Server-side waveform loading ──
const loadServerWaveform = async (fileId) => {
try {
const resp = await fetch(`${API_AUDIO}/waveform/${fileId}?num_peaks=800`);
if (!resp.ok) return null;
return await resp.json();
} catch {
return null;
}
};
// ── Check Celery task result ──
const pollTaskResult = async (taskId, maxPoll = 10) => {
for (let i = 0; i < maxPoll; i++) {
await new Promise(r => setTimeout(r, 1500));
try {
const resp = await fetch(`${API_TASKS}/${taskId}`);
if (!resp.ok) continue;
const data = await resp.json();
if (data.status === 'SUCCESS') return data.result;
if (data.status === 'FAILURE') throw new Error(data.error || 'Task failed');
} catch (err) {
throw err;
}
}
throw new Error('Task polling timeout');
};
// ── Wheel Zoom ──
useEffect(() => {
const timeline = timelineWrapperRef.current;
if (!timeline) return;
const handleWheel = (e) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const rect = timeline.getBoundingClientRect();
const mouseXInViewport = e.clientX - rect.left;
const mouseXInCanvas = mouseXInViewport + timeline.scrollLeft;
const anchorTime = mouseXInCanvas / zoom;
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
setZoom(prevZoom => {
let newZoom = prevZoom * zoomFactor;
if (newZoom < minZoom) newZoom = minZoom;
if (newZoom > 2000) newZoom = 2000;
const newMouseXInCanvas = anchorTime * newZoom;
requestAnimationFrame(() => {
timeline.scrollLeft = newMouseXInCanvas - mouseXInViewport;
});
return newZoom;
});
} else if (e.shiftKey) {
e.preventDefault();
timeline.scrollLeft += e.deltaY;
}
};
timeline.addEventListener('wheel', handleWheel, { passive: false });
return () => timeline.removeEventListener('wheel', handleWheel);
}, [zoom, minZoom, maxDuration, activeTab]);
// ── Update Playhead ──
const startSubTabPlayback = (st, offsetWallTime) => {
const context = getAudioContext();
const speed = st.speed || 1.0;
const offsetBuffer = offsetWallTime * speed;
const eff = st.buffer.getChannelData(0);
const edBuffer = context.createBuffer(1, eff.length, st.buffer.sampleRate);
const edData = edBuffer.getChannelData(0);
edData.set(eff);
const fx = st.effects || {};
if (fx.reverse) {
const reversed = new Float32Array(edData);
for (let i = 0; i < edData.length; i++) reversed[i] = edData[edData.length - 1 - i];
edBuffer.copyToChannel(reversed, 0);
}
if (fx.gainDb !== 0) {
const gain = Math.pow(10, fx.gainDb / 20);
for (let i = 0; i < edData.length; i++) edData[i] = Math.max(-1, Math.min(1, edData[i] * gain));
}
if (fx.fadeInMs > 0) {
const sr = edBuffer.sampleRate;
const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeInMs / 1000 * sr));
for (let i = 0; i < fadeSamples; i++) edData[i] = edData[i] * (i / fadeSamples);
}
if (fx.fadeOutMs > 0) {
const sr = edBuffer.sampleRate;
const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeOutMs / 1000 * sr));
for (let i = edData.length - fadeSamples; i < edData.length; i++) {
edData[i] = edData[i] * ((edData.length - 1 - i) / fadeSamples);
}
}
const source = context.createBufferSource();
source.buffer = edBuffer;
source.playbackRate.value = speed;
activePlaybackSpeedRef.current = speed;
// Graph Editor Automation Node Chain (15_GRAPH_EDIT.md §3)
// source → volumeGainNode → pannerNode → fadeGainNode → destination
const volumeGainNode = context.createGain();
volumeGainNode.gain.setValueAtTime(1.0, context.currentTime);
const pannerNode = context.createStereoPanner();
pannerNode.pan.setValueAtTime(0.0, context.currentTime);
const fadeGainNode = context.createGain();
fadeGainNode.gain.setValueAtTime(1.0, context.currentTime);
// Schedule volume automation nodes
const volNodes = st.volumeNodes || [];
if (volNodes.length > 0) {
volumeGainNode.gain.cancelScheduledValues(context.currentTime);
volNodes.forEach((n, i) => {
const t = context.currentTime + (n.time / speed);
const linearGain = Math.pow(10, n.db / 20);
if (i === 0) volumeGainNode.gain.setValueAtTime(linearGain, t);
else volumeGainNode.gain.linearRampToValueAtTime(linearGain, t);
});
}
// Schedule panning automation nodes
const panNodes = st.panningNodes || [];
if (panNodes.length > 0) {
pannerNode.pan.cancelScheduledValues(context.currentTime);
panNodes.forEach((n, i) => {
const t = context.currentTime + (n.time / speed);
const clamped = Math.max(-1, Math.min(1, n.pan));
if (i === 0) pannerNode.pan.setValueAtTime(clamped, t);
else pannerNode.pan.linearRampToValueAtTime(clamped, t);
});
}
// Schedule fade curves
const duration = st.buffer.duration;
const fIn = st.fadeInLen || 0;
const fOut = st.fadeOutLen || 0;
if (fIn > 0) {
fadeGainNode.gain.setValueAtTime(0.0, context.currentTime);
fadeGainNode.gain.linearRampToValueAtTime(1.0, context.currentTime + fIn / speed);
}
if (fOut > 0) {
const fadeOutStart = (duration - fOut) / speed;
fadeGainNode.gain.setValueAtTime(1.0, context.currentTime + Math.max(0, fadeOutStart));
fadeGainNode.gain.linearRampToValueAtTime(0.0, context.currentTime + duration / speed);
}
source.connect(volumeGainNode);
volumeGainNode.connect(pannerNode);
pannerNode.connect(fadeGainNode);
fadeGainNode.connect(context.destination);
source.start(context.currentTime, offsetBuffer);
activeSourcesRef.current = [source];
activeTrackNodesRef.current[st.trackId] = { gainNode: volumeGainNode, pannerNode, source };
startOffsetTimeRef.current = offsetWallTime;
startBufferOffsetRef.current = offsetBuffer;
startAudioTimeRef.current = context.currentTime;
};
const updatePlayhead = () => {
if (activeTabRef.current !== 'main') {
const st = subTabsRef.current.find(s => s.id === activeTabRef.current);
if (!st || !st.isPlaying || !st.buffer) return;
const context = getAudioContext();
const speedFactor = st.speed || 1.0;
const elapsed = context.currentTime - startAudioTimeRef.current;
const wallTime = startOffsetTimeRef.current + elapsed;
const bufferPos = startBufferOffsetRef.current + elapsed * speedFactor;
// Loop sub-tab selection (bufferPos is buffer-time)
if (st.selectionStart !== null && st.selectionEnd !== null && st.selectionStart !== st.selectionEnd) {
const start = Math.min(st.selectionStart, st.selectionEnd);
const end = Math.max(st.selectionStart, st.selectionEnd);
if (bufferPos >= end) {
stopAllPlayback();
setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? {
...s,
currentTime: wallTime,
isPlaying: true
} : s));
startSubTabPlayback(st, wallTime);
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
return;
}
}
if (bufferPos >= st.buffer.duration) {
stopAllPlayback();
if (isLoopingSelection) {
setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? {
...s,
currentTime: 0,
isPlaying: true
} : s));
startSubTabPlayback(st, 0);
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
} else {
setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? { ...s, currentTime: 0, isPlaying: false } : s));
}
return;
}
setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? { ...s, currentTime: wallTime } : s));
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
return;
}
if (!isPlaying) return;
const context = getAudioContext();
const elapsed = context.currentTime - startAudioTimeRef.current;
const updatedTime = startOffsetTimeRef.current + elapsed;
// Selection Loop - LOOP_MAKER.md + LOOP_EDITOR_2.md §4.2
// If selection cleared by user, play linearly (don't loop)
if (!selectionCleared && isLoopingSelection && selLeft !== null && selRight !== null) {
if (selRight > selLeft && updatedTime >= selRight) {
if (selectionMode === 'local') {
// Local Solo Loop: only restart the selected track
stopAllPlayback();
startOffsetTimeRef.current = selLeft;
startAudioTimeRef.current = context.currentTime;
startLocalTrackPlayback(localSelectionTrackId, selLeft);
setCurrentTime(selLeft);
setIsPlaying(true);
} else {
// Global Master Loop: restart all tracks
stopAllPlayback();
startOffsetTimeRef.current = selLeft;
startAudioTimeRef.current = context.currentTime;
startTrackPlayback(selLeft);
setCurrentTime(selLeft);
setIsPlaying(true);
}
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
return;
}
}
if (updatedTime >= maxDurationRef.current) {
stopAllPlayback();
setCurrentTime(0);
return;
}
setCurrentTime(updatedTime);
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
};
useEffect(() => {
if (isPlaying || subTabs.some(s => s.isPlaying)) {
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
} else {
cancelAnimationFrame(animationFrameIdRef.current);
}
return () => cancelAnimationFrame(animationFrameIdRef.current);
}, [isPlaying, subTabs, isLoopingSelection, selLeft, selRight, selectionMode, localSelectionTrackId, selectionCleared, activeTab]);
// ── Playback ──
const startTrackPlayback = (offsetTime) => {
const context = getAudioContext();
const hasSolo = tracks.some(t => t.solo) || soloedTrackId !== null;
tracks.forEach(track => {
const isPlayable = hasSolo
? (track.id === soloedTrackId || track.solo)
: !track.muted;
if (!isPlayable) return;
// Create persistent gain & panner per track for real-time control
const gainNode = context.createGain();
const volDb = track.volumeDb ?? 0;
const volLinear = volDb <= -50 ? 0 : Math.pow(10, volDb / 20);
gainNode.gain.setValueAtTime(volLinear, context.currentTime);
const pannerNode = context.createStereoPanner();
pannerNode.pan.setValueAtTime((track.pan ?? 0) / 100, context.currentTime);
pannerNode.connect(context.destination);
activeTrackNodesRef.current[track.id] = { gainNode, pannerNode };
const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
id: 'default',
buffer: track.buffer,
startTime: track.startTime || 0,
name: track.name,
speed: track.speed || 1.0
}] : []);
clips.forEach(clip => {
if (!clip.buffer) return;
const source = context.createBufferSource();
source.buffer = clip.buffer;
source.playbackRate.value = clip.speed || 1.0;
source.connect(gainNode);
gainNode.connect(pannerNode);
const clipStart = clip.startTime || 0;
const clipDuration = clip.buffer.duration / (clip.speed || 1.0);
const clipEnd = clipStart + clipDuration;
if (offsetTime < clipStart) {
const delay = clipStart - offsetTime;
source.start(context.currentTime + delay, 0);
activeSourcesRef.current.push(source);
} else if (offsetTime < clipEnd) {
const playOffset = offsetTime - clipStart;
source.start(context.currentTime, playOffset * (clip.speed || 1.0));
activeSourcesRef.current.push(source);
}
});
});
};
// Solo playback for Local Selection Loop (LOOP_MAKER.md §2.2)
const startLocalTrackPlayback = (trackId, offsetTime) => {
const context = getAudioContext();
const track = tracks.find(t => t.id === trackId);
if (!track) return;
const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
id: 'default',
buffer: track.buffer,
startTime: track.startTime || 0,
name: track.name,
speed: track.speed || 1.0
}] : []);
// Create persistent gain & panner for real-time control
const gainNode = context.createGain();
const volDb = track.volumeDb ?? 0;
const volLinear = volDb <= -50 ? 0 : Math.pow(10, volDb / 20);
gainNode.gain.setValueAtTime(volLinear, context.currentTime);
const pannerNode = context.createStereoPanner();
pannerNode.pan.setValueAtTime((track.pan ?? 0) / 100, context.currentTime);
pannerNode.connect(context.destination);
activeTrackNodesRef.current[trackId] = { gainNode, pannerNode };
clips.forEach(clip => {
if (!clip.buffer) return;
const source = context.createBufferSource();
source.buffer = clip.buffer;
source.playbackRate.value = clip.speed || 1.0;
source.connect(gainNode);
gainNode.connect(pannerNode);
const clipStart = clip.startTime || 0;
const clipDuration = clip.buffer.duration / (clip.speed || 1.0);
const clipEnd = clipStart + clipDuration;
if (offsetTime < clipStart) {
const delay = clipStart - offsetTime;
source.start(context.currentTime + delay, 0);
activeSourcesRef.current.push(source);
} else if (offsetTime < clipEnd) {
const playOffset = offsetTime - clipStart;
source.start(context.currentTime, playOffset * (clip.speed || 1.0));
activeSourcesRef.current.push(source);
}
});
};
const handlePlayPause = () => {
if (activeTab !== 'main') {
// Sub-tab playback transport
const st = subTabs.find(s => s.id === activeTab);
if (!st || !st.buffer) return;
if (st.isPlaying) {
stopAllPlayback();
setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, isPlaying: false } : s));
} else {
stopAllPlayback();
const startOffset = st.currentTime || 0;
startSubTabPlayback(st, startOffset);
setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, isPlaying: true, currentTime: startOffset } : s));
}
return;
}
const context = getAudioContext();
if (isPlaying) {
stopAllPlayback();
} else {
startOffsetTimeRef.current = currentTime;
startAudioTimeRef.current = context.currentTime;
startTrackPlayback(currentTime);
setIsPlaying(true);
}
};
handlePlayPauseRef.current = handlePlayPause;
const handlePause = () => {
if (isPlaying || subTabs.some(s => s.isPlaying)) stopAllPlayback();
};
const stopAllPlayback = () => {
activeSourcesRef.current.forEach(src => {
try { src.stop(); } catch(e) {}
});
activeSourcesRef.current = [];
activeTrackNodesRef.current = {};
setIsPlaying(false);
setSubTabs(prev => prev.map(s => ({ ...s, isPlaying: false })));
};
const handleStop = () => {
stopAllPlayback();
if (activeTab !== 'main') {
setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, currentTime: 0 } : s));
} else {
setCurrentTime(0);
}
};
const handleSubTabResizeMouseDown = (e) => {
e.preventDefault();
e.stopPropagation();
const startY = e.clientY;
const startHeight = subTabHeight;
const handleMouseMove = (moveEvent) => {
const deltaY = moveEvent.clientY - startY;
const newHeight = Math.max(48, Math.min(400, startHeight + deltaY));
setSubTabHeight(newHeight);
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
// ── Selection ──
const clearLocalSelection = () => {
setSelectionMode(null);
setLocalSelectionTrackId(null);
setLocalSelectionStart(null);
setLocalSelectionEnd(null);
};
const handleRulerMouseDown = (e) => {
const wrapper = timelineWrapperRef.current;
if (!wrapper) return;
const rect = wrapper.getBoundingClientRect();
const scrollLeft = wrapper.scrollLeft;
const mouseX = e.clientX - rect.left + scrollLeft;
const rawTime = mouseX / zoom;
const time = snapValue !== 'free' ? snapTime(rawTime, snapValue, bpm) : rawTime;
clearLocalSelection();
setSelectionMode('global');
rulerDragStartRef.current = time;
isDraggingRulerRef.current = true;
setSelectionStart(time);
setSelectionEnd(time);
setCurrentTime(time);
};
// Global Ruler mousemove is tracked via document listener set up in useEffect
useEffect(() => {
const handleMouseMove = (e) => {
if (!isDraggingRulerRef.current) return;
const wrapper = timelineWrapperRef.current;
if (!wrapper) return;
const rect = wrapper.getBoundingClientRect();
const scrollLeft = wrapper.scrollLeft;
const mouseX = e.clientX - rect.left + scrollLeft;
const rawTime = Math.max(0, Math.min(maxDuration, mouseX / zoom));
const time = snapValueRef.current !== 'free' ? snapTime(rawTime, snapValueRef.current, bpmRef.current) : rawTime;
setSelectionEnd(time);
};
const handleMouseUp = () => {
if (isDraggingRulerRef.current) {
isDraggingRulerRef.current = false;
rulerDragStartRef.current = null;
}
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [zoom, maxDuration]);
// ── Track Lane Local Selection Drag ──
const localDragInProgressRef = useRef(false);
const localDragTrackRef = useRef(null);
const localDragStartTimeRef = useRef(0);
const handleTrackLaneMouseDown = (trackId, time) => {
clearLocalSelection();
setSelectionMode('local');
setLocalSelectionTrackId(trackId);
setLocalSelectionStart(time);
setLocalSelectionEnd(time);
localDragInProgressRef.current = true;
localDragTrackRef.current = trackId;
localDragStartTimeRef.current = time;
};
// Document-level mousemove/mouseup for local selection drag
useEffect(() => {
const handleMouseMove = (e) => {
if (!localDragInProgressRef.current) return;
const wrapper = timelineWrapperRef.current;
if (!wrapper) return;
const rect = wrapper.getBoundingClientRect();
const scrollLeft = wrapper.scrollLeft;
const mouseX = e.clientX - rect.left + scrollLeft;
const time = Math.max(0, Math.min(maxDuration, mouseX / zoom));
setLocalSelectionEnd(time);
};
const handleMouseUp = () => {
if (localDragInProgressRef.current) {
localDragInProgressRef.current = false;
localDragTrackRef.current = null;
localDragStartTimeRef.current = 0;
}
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [zoom, maxDuration]);
const draggedClipRef = useRef(null);
draggedClipRef.current = draggedClip;
const hoveredTrackIdRef = useRef(null);
hoveredTrackIdRef.current = hoveredTrackId;
const captureTrackSnapshotRef = useRef(null);
captureTrackSnapshotRef.current = captureTrackSnapshot;
const handleClipDragStart = (trackId, clipId, clickOffset, isDuplicate = false) => {
const track = tracks.find(t => t.id === trackId);
if (!track) return;
const existingClips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
id: 'default_' + track.id,
buffer: track.buffer,
startTime: track.startTime || 0,
name: track.name
}] : []);
const clip = existingClips.find(c => c.id === clipId || (clipId === 'default' && c.id === 'default_' + track.id));
if (!clip) return;
const beforeSnap = captureTrackSnapshot(trackId);
let targetClipId = clip.id;
if (isDuplicate) {
const cloneId = clip.id + '_copy_' + Date.now();
const clone = {
...clip,
id: cloneId,
name: clip.name + ' (Copy)'
};
setTracks(prev => prev.map(t => {
if (t.id === trackId) {
const newClips = [...existingClips, clone];
return {
...t,
clips: newClips,
buffer: newClips[0].buffer,
startTime: newClips[0].startTime,
name: newClips[0].name
};
}
return t;
}));
targetClipId = cloneId;
} else {
if (!track.clips || track.clips.length === 0) {
setTracks(prev => prev.map(t => {
if (t.id === trackId) {
return {
...t,
clips: existingClips
};
}
return t;
}));
}
}
setDraggedClip({
trackId: trackId,
clipId: targetClipId,
clickOffset: clickOffset,
buffer: clip.buffer,
name: clip.name,
beforeSnap: beforeSnap
});
};
const stretchedClipRef = useRef(null);
stretchedClipRef.current = stretchedClip;
const handleClipStretchStart = (trackId, clipId, clickTime) => {
const track = tracks.find(t => t.id === trackId);
if (!track) return;
const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
id: 'default_' + track.id,
buffer: track.buffer,
startTime: track.startTime || 0,
name: track.name,
speed: track.speed || 1.0
}] : []);
const clip = clips.find(c => c.id === clipId || (clipId === 'default' && c.id === 'default_' + trackId));
if (!clip || !clip.buffer) return;
const beforeSnap = captureTrackSnapshot(trackId);
if (!track.clips || track.clips.length === 0) {
setTracks(prev => prev.map(t => t.id === trackId ? { ...t, clips } : t));
}
setStretchedClip({
trackId,
clipId: clip.id === 'default' ? 'default_' + trackId : clip.id,
originalDuration: clip.buffer.duration,
startTime: clip.startTime,
originalSpeed: clip.speed || 1.0,
beforeSnap
});
};
const handleSelectionEdgeDragStart = (e, trackId, side) => {
const startX = e.clientX;
const initialLeft = Math.min(localSelectionStart, localSelectionEnd);
const initialRight = Math.max(localSelectionStart, localSelectionEnd);
const handleMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - startX;
const deltaSec = deltaX / zoom;
if (side === 'left') {
const newLeft = Math.max(0, Math.min(initialRight - 0.05, initialLeft + deltaSec));
setLocalSelectionStart(newLeft);
setLocalSelectionEnd(initialRight);
} else {
const newRight = Math.max(initialLeft + 0.05, Math.min(maxDuration, initialRight + deltaSec));
setLocalSelectionStart(initialLeft);
setLocalSelectionEnd(newRight);
}
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
const handleTrackResizeMouseDown = (e, trackId) => {
e.preventDefault();
e.stopPropagation();
const startY = e.clientY;
const track = tracks.find(t => t.id === trackId);
const startHeight = track ? (track.height || 96) : 96;
const handleMouseMove = (moveEvent) => {
const deltaY = moveEvent.clientY - startY;
const newHeight = Math.max(48, Math.min(200, startHeight + deltaY));
setTracks(prev => prev.map(t => t.id === trackId ? { ...t, height: newHeight } : t));
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
const deleteTrack = (trackId) => {
const beforeSnap = captureTrackSnapshot(trackId);
setTracks(prev => {
const filtered = prev.filter(t => t.id !== trackId);
if (filtered.length > 0) {
setSelectedTrackId(filtered[0].id);
}
return filtered;
});
showToast('Đã xóa track.', 'info');
};
useEffect(() => {
const handleMouseMove = (e) => {
const drag = draggedClipRef.current;
if (!drag) return;
const wrapper = timelineWrapperRef.current;
if (!wrapper) return;
const rect = wrapper.getBoundingClientRect();
const scrollLeft = wrapper.scrollLeft;
const mouseX = e.clientX - rect.left + scrollLeft;
const time = mouseX / zoom;
const rawStart = Math.max(0, time - drag.clickOffset);
const newStart = snapTime(rawStart, snapValueRef.current, bpmRef.current);
const targetTrackId = hoveredTrackIdRef.current || drag.trackId;
setTracks(prev => prev.map(t => {
// Clear the clip from its previous track if it moved to a new track
if (t.id === drag.trackId && drag.trackId !== targetTrackId) {
const updatedClips = (t.clips || []).filter(c => c.id !== drag.clipId);
return {
...t,
clips: updatedClips,
buffer: updatedClips.length > 0 ? updatedClips[0].buffer : null,
startTime: updatedClips.length > 0 ? updatedClips[0].startTime : 0,
name: updatedClips.length > 0 ? updatedClips[0].name : `Track ${t.id}`
};
}
// Update/set clip on target track
if (t.id === targetTrackId) {
const existingClips = t.clips && t.clips.length > 0 ? t.clips : (t.buffer ? [{
id: 'default_' + t.id,
buffer: t.buffer,
startTime: t.startTime || 0,
name: t.name
}] : []);
const hasClip = existingClips.some(c => c.id === drag.clipId);
let updatedClips;
if (hasClip) {
updatedClips = existingClips.map(c => c.id === drag.clipId ? { ...c, startTime: newStart } : c);
} else {
updatedClips = [...existingClips, {
id: drag.clipId,
buffer: drag.buffer,
startTime: newStart,
name: drag.name
}];
}
return {
...t,
clips: updatedClips,
buffer: updatedClips[0].buffer,
startTime: updatedClips[0].startTime,
name: updatedClips[0].name
};
}
return t;
}));
if (drag.trackId !== targetTrackId) {
setDraggedClip(prev => ({ ...prev, trackId: targetTrackId }));
}
};
const handleMouseUp = () => {
const drag = draggedClipRef.current;
if (!drag) return;
const afterSnap = captureTrackSnapshotRef.current(drag.trackId);
pushAction('MOVE_CLIP', drag.trackId, drag.beforeSnap, afterSnap);
setDraggedClip(null);
showToast('Đã di chuyển clip.', 'success');
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [zoom]);
// Document-level mousemove/mouseup for clip stretching
useEffect(() => {
const handleMouseMove = (e) => {
const stretch = stretchedClipRef.current;
if (!stretch) return;
const wrapper = timelineWrapperRef.current;
if (!wrapper) return;
const rect = wrapper.getBoundingClientRect();
const scrollLeft = wrapper.scrollLeft;
const mouseX = e.clientX - rect.left + scrollLeft;
const time = mouseX / zoom;
const newDuration = Math.max(0.1, time - stretch.startTime);
const speedRatio = stretch.originalDuration / newDuration;
setTracks(prev => prev.map(t => {
if (t.id === stretch.trackId) {
const updatedClips = (t.clips || []).map(c => {
if (c.id === stretch.clipId) {
return {
...c,
speed: speedRatio
};
}
return c;
});
return {
...t,
clips: updatedClips,
buffer: updatedClips[0]?.buffer,
startTime: updatedClips[0]?.startTime || 0,
speed: updatedClips[0]?.speed || 1.0
};
}
return t;
}));
};
const handleMouseUp = () => {
const stretch = stretchedClipRef.current;
if (!stretch) return;
const afterSnap = captureTrackSnapshotRef.current(stretch.trackId);
pushAction('STRETCH_CLIP', stretch.trackId, stretch.beforeSnap, afterSnap);
setStretchedClip(null);
showToast('Đã giãn thời gian clip.', 'success');
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [zoom]);
const handleSelectRange = (start, end, reset) => {
const maxLen = maxDuration;
const cleanStart = Math.max(0, Math.min(maxLen, start));
const cleanEnd = Math.max(0, Math.min(maxLen, end));
if (reset) {
setSelectionStart(cleanStart);
setSelectionEnd(cleanEnd);
} else {
setSelectionEnd(cleanEnd);
}
// LOOP_EDITOR_2.md §4.2: new selection = enable looping
setSelectionCleared(false);
};
const handleSelectionInputChange = (field, val) => {
const numericVal = Math.max(0, parseFloat(val) || 0);
if (selectionMode === 'local') {
// Editing local selection directly
if (field === 'start') {
setLocalSelectionStart(numericVal);
} else {
setLocalSelectionEnd(numericVal);
}
} else {
if (field === 'start') {
setSelectionStart(numericVal);
} else {
setSelectionEnd(numericVal);
}
}
};
const selectionStats = useMemo(() => {
if (selLeft === null || selRight === null) {
return { start: 0, end: 0, length: 0 };
}
const s = Math.min(selLeft, selRight);
const e = Math.max(selLeft, selRight);
return {
start: parseFloat(s.toFixed(3)),
end: parseFloat(e.toFixed(3)),
length: parseFloat((e - s).toFixed(3))
};
}, [selLeft, selRight]);
// ── Handle Drag (selection resize) ──
const handleHandleDragStart = (e, side) => {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const useLocal = selectionMode === 'local';
const currentStart = useLocal ? localSelectionStart : selectionStart;
const currentEnd = useLocal ? localSelectionEnd : selectionEnd;
const initialLeft = Math.min(currentStart, currentEnd);
const initialRight = Math.max(currentStart, currentEnd);
const setStart = useLocal ? setLocalSelectionStart : setSelectionStart;
const setEnd = useLocal ? setLocalSelectionEnd : setSelectionEnd;
const handleMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - startX;
const deltaSec = deltaX / zoom;
if (side === 'left') {
const newLeft = Math.max(0, Math.min(initialRight - 0.05, initialLeft + deltaSec));
setStart(newLeft);
setEnd(initialRight);
} else {
const newRight = Math.max(initialLeft + 0.05, Math.min(maxDuration, initialRight + deltaSec));
setStart(initialLeft);
setEnd(newRight);
}
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
const handleSelectionBodyDragStart = (e) => {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const useLocal = selectionMode === 'local';
const currentStart = useLocal ? localSelectionStart : selectionStart;
const currentEnd = useLocal ? localSelectionEnd : selectionEnd;
const initialLeft = Math.min(currentStart, currentEnd);
const initialRight = Math.max(currentStart, currentEnd);
const widthSec = initialRight - initialLeft;
const setStart = useLocal ? setLocalSelectionStart : setSelectionStart;
const setEnd = useLocal ? setLocalSelectionEnd : setSelectionEnd;
const handleMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - startX;
const deltaSec = deltaX / zoom;
let newLeft = initialLeft + deltaSec;
let newRight = initialRight + deltaSec;
if (newLeft < 0) {
newLeft = 0;
newRight = widthSec;
}
if (newRight > maxDuration) {
newRight = maxDuration;
newLeft = maxDuration - widthSec;
}
setStart(newLeft);
setEnd(newRight);
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
// ── Track Controls ──
const toggleTrackSoloEvaluate = (trackId) => {
const wasPlaying = isPlaying;
stopAllPlayback();
setTracks(prev => prev.map(t => {
if (t.id === trackId) {
return { ...t, solo: !t.solo, muted: false };
}
return { ...t, solo: false };
}));
setSoloedTrackId(prev => prev === trackId ? null : trackId);
if (wasPlaying) {
setTimeout(() => {
startOffsetTimeRef.current = currentTime;
startAudioTimeRef.current = getAudioContext().currentTime;
setIsPlaying(true);
}, 50);
}
};
const toggleTrackMute = (trackId) => {
const beforeSnap = captureTrackSnapshot(trackId);
setTracks(prev => prev.map(t => t.id === trackId ? { ...t, muted: !t.muted } : t));
setUndoStack(prev => {
const next = [...prev, {
action_type: 'MUTE',
track_id: trackId,
timestamp: Date.now(),
before_state: beforeSnap,
after_state: captureTrackSnapshot(trackId),
}];
if (next.length > MAX_UNDO) next.shift();
return next;
});
};
const updateTrackVolumeDb = (trackId, val) => {
const beforeSnap = captureTrackSnapshot(trackId);
setTracks(prev => prev.map(t => t.id === trackId ? { ...t, volumeDb: val } : t));
setUndoStack(prev => {
const next = [...prev, {
action_type: 'VOLUME_CHANGE',
track_id: trackId,
timestamp: Date.now(),
before_state: beforeSnap,
after_state: captureTrackSnapshot(trackId),
}];
if (next.length > MAX_UNDO) next.shift();
return next;
});
// Real-time update during playback
const nodes = activeTrackNodesRef.current[trackId];
if (nodes) {
const volLinear = val <= -50 ? 0 : Math.pow(10, val / 20);
nodes.gainNode.gain.setValueAtTime(volLinear, getAudioContext().currentTime);
}
};
const updateTrackPan = (trackId, val) => {
const beforeSnap = captureTrackSnapshot(trackId);
setTracks(prev => prev.map(t => t.id === trackId ? { ...t, pan: val } : t));
setUndoStack(prev => {
const next = [...prev, {
action_type: 'PAN_CHANGE',
track_id: trackId,
timestamp: Date.now(),
before_state: beforeSnap,
after_state: captureTrackSnapshot(trackId),
}];
if (next.length > MAX_UNDO) next.shift();
return next;
});
// Real-time update during playback
const nodes = activeTrackNodesRef.current[trackId];
if (nodes) {
nodes.pannerNode.pan.setValueAtTime(val / 100, getAudioContext().currentTime);
}
};
const updateTrackName = (trackId, name) => {
const beforeSnap = captureTrackSnapshot(trackId);
setTracks(prev => prev.map(t => t.id === trackId ? { ...t, name } : t));
setUndoStack(prev => {
const next = [...prev, {
action_type: 'RENAME',
track_id: trackId,
timestamp: Date.now(),
before_state: beforeSnap,
after_state: captureTrackSnapshot(trackId),
}];
if (next.length > MAX_UNDO) next.shift();
return next;
});
};
const updateTrackColor = (trackId, color) => {
const beforeSnap = captureTrackSnapshot(trackId);
setTracks(prev => prev.map(t => t.id === trackId ? { ...t, color } : t));
setUndoStack(prev => {
const next = [...prev, {
action_type: 'RECOLOR',
track_id: trackId,
timestamp: Date.now(),
before_state: beforeSnap,
after_state: captureTrackSnapshot(trackId),
}];
if (next.length > MAX_UNDO) next.shift();
return next;
});
};
// ── Load File on Track (with server upload) ──
const loadFileOnTrack = async (trackId, file) => {
if (!file) return;
const context = getAudioContext();
showToast(`Đang nạp file ${file.name}...`, 'info');
try {
// Upload to server
uploadToServer(file, trackId);
// Decode locally for playback
const reader = new FileReader();
reader.onload = async (e) => {
try {
const decodedBuffer = await context.decodeAudioData(e.target.result);
setTracks(prev => prev.map(t => t.id === trackId ? {
...t,
name: file.name,
buffer: decodedBuffer
} : t));
showToast(`Nạp file thành công: ${file.name}`, 'success');
} catch (err) {
showToast("Lỗi giải mã âm thanh. Định dạng file không tương thích.", 'error');
}
};
reader.readAsArrayBuffer(file);
} catch (err) {
showToast("Lỗi: " + err.message, 'error');
}
};
// ── Synth Generators ──
const generateSynthToTrack = (trackId, type) => {
const sampleRate = 44100;
const duration = 12.0;
const context = getAudioContext();
const frameCount = sampleRate * duration;
const newBuffer = context.createBuffer(1, frameCount, sampleRate);
const channelData = newBuffer.getChannelData(0);
if (type === 'kick') {
for (let i = 0; i < frameCount; i++) {
const t = i / sampleRate;
const beatTime = t % 0.5;
const freq = 120 * Math.exp(-35 * beatTime);
channelData[i] = Math.sin(2 * Math.PI * freq * beatTime) * Math.exp(-6 * beatTime);
}
} else {
const notes = [220.00, 261.63, 293.66, 329.63, 392.00];
for (let i = 0; i < frameCount; i++) {
const t = i / sampleRate;
const noteIdx = Math.floor(t * 2) % notes.length;
const freq = notes[noteIdx];
channelData[i] = Math.sin(2 * Math.PI * freq * t) * 0.25 * (1.0 - (t % 0.5) / 0.5);
}
}
setTracks(prev => prev.map(t => t.id === trackId ? {
...t, name: `Demo_${type.toUpperCase()}.wav`, buffer: newBuffer
} : t));
showToast(`Đã nạp sóng âm tổng hợp: ${type.toUpperCase()}`, 'success');
};
// ── Add Track ──
const addNewTrack = () => {
const newId = (tracks.length + 1).toString();
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
const selectColor = colors[tracks.length % colors.length];
setTracks(prev => [...prev, {
id: newId, name: `Track ${newId}`, buffer: null, startTime: 0, height: 96,
volumeDb: 0, pan: 0, muted: false, solo: false,
color: selectColor, markers: [], serverFileId: null
}]);
showToast(`Đã thêm Track ${newId}.`, 'info');
setTimeout(() => lucide.createIcons(), 200);
return newId;
};
// ── Server-side Export ──
const triggerWavExport = async () => {
const activeTracks = tracks.filter(t => t.buffer && !t.muted);
if (activeTracks.length === 0) {
showToast("Không tìm thấy dữ liệu âm thanh hợp lệ để xuất.", "warning");
return;
}
// Check if all active tracks have server file IDs
const allOnServer = activeTracks.every(t => serverFileIdMap[t.id]);
if (allOnServer && serverStatus === 'connected') {
// Use server-side export
setIsExporting(true);
showToast("Đang gửi yêu cầu xuất âm thanh đến máy chủ...", "info");
try {
const sessionId = `session_${Date.now()}`;
const tracksMeta = activeTracks.map(t => ({
track_id: t.id,
file_id: serverFileIdMap[t.id],
volume_db: t.volumeDb,
muted: false,
clips: [{
clip_id: `clip_${t.id}`,
start_time_seconds: t.startTime || 0,
end_time_seconds: (t.startTime || 0) + t.buffer.duration,
loop_count: 1,
apply_zero_crossing: true,
fade_in_ms: 0,
fade_out_ms: 0,
}]
}));
const resp = await fetch(`${API_MULTITRACK}/mix`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
export_settings: {
sample_rate: parseInt(exportSettings.sampleRate),
bit_depth: parseInt(exportSettings.bitDepth),
format: exportSettings.format,
},
tracks: tracksMeta,
}),
});
if (!resp.ok) throw new Error(`Server export failed: ${resp.status}`);
const data = await resp.json();
showToast("Đang xử lý trên máy chủ...", "info");
// Poll for result
const result = await pollTaskResult(data.task_id, 20);
if (result.success) {
// Get the uploaded file from server
const fileId = result.output_file_id || result.output_path?.split('/').pop();
if (fileId) {
const downloadUrl = `${API_AUDIO}/download/${fileId}`;
const a = document.createElement('a');
a.href = downloadUrl;
a.download = fileId;
a.click();
showToast("Xuất bản âm thanh từ máy chủ hoàn tất!", "success");
}
} else {
throw new Error(result.error || 'Server processing failed');
}
} catch (err) {
showToast("Lỗi xuất máy chủ: " + err.message + ". Chuyển sang xuất client.", "warning");
// Fall back to client-side export
clientSideExport(activeTracks);
} finally {
setIsExporting(false);
}
} else {
// Client-side export (existing working code)
clientSideExport(activeTracks);
}
};
const clientSideExport = async (activeTracks) => {
setIsExporting(true);
showToast("Đang trộn âm thanh đa kênh (Offline Mixdown)...", "info");
try {
const targetRate = parseInt(exportSettings.sampleRate);
const bitDepth = parseInt(exportSettings.bitDepth);
const durationLimit = Math.max(...activeTracks.map(t => {
const clips = t.clips && t.clips.length > 0 ? t.clips : (t.buffer ? [{
id: 'default',
buffer: t.buffer,
startTime: t.startTime || 0,
name: t.name,
speed: t.speed || 1.0
}] : []);
if (clips.length === 0) return 0;
return Math.max(...clips.map(c => (c.startTime || 0) + c.buffer.duration / (c.speed || 1.0)));
}));
const offlineCtx = new OfflineAudioContext(1, Math.ceil(targetRate * Math.max(0.1, durationLimit)), targetRate);
activeTracks.forEach(t => {
const clips = t.clips && t.clips.length > 0 ? t.clips : (t.buffer ? [{
id: 'default',
buffer: t.buffer,
startTime: t.startTime || 0,
name: t.name,
speed: t.speed || 1.0
}] : []);
clips.forEach(clip => {
if (!clip.buffer) return;
const source = offlineCtx.createBufferSource();
source.buffer = clip.buffer;
source.playbackRate.value = clip.speed || 1.0;
const gain = offlineCtx.createGain();
const volDb = t.volumeDb ?? 0;
const volLinear = volDb <= -50 ? 0 : Math.pow(10, volDb / 20);
gain.gain.setValueAtTime(volLinear, 0);
const panner = offlineCtx.createStereoPanner();
panner.pan.setValueAtTime((t.pan ?? 0) / 100, 0);
source.connect(gain);
gain.connect(panner);
panner.connect(offlineCtx.destination);
const clipStart = clip.startTime || 0;
source.start(clipStart);
});
});
const renderedBuffer = await offlineCtx.startRendering();
const monoData = renderedBuffer.getChannelData(0);
const bufferLength = monoData.length;
const bytesPerSample = bitDepth / 8;
const headerSize = 44;
const fileSizeBytes = headerSize + (bufferLength * bytesPerSample);
const fileBuffer = new ArrayBuffer(fileSizeBytes);
const view = new DataView(fileBuffer);
const writeString = (offset, string) => {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
};
writeString(0, 'RIFF');
view.setUint32(4, fileSizeBytes - 8, true);
writeString(8, 'WAVE');
writeString(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, targetRate, true);
view.setUint32(28, targetRate * bytesPerSample, true);
view.setUint16(32, bytesPerSample, true);
view.setUint16(34, bitDepth, true);
writeString(36, 'data');
view.setUint32(40, bufferLength * bytesPerSample, true);
let offset = 44;
for (let i = 0; i < bufferLength; i++) {
const sample = Math.max(-1, Math.min(1, monoData[i]));
if (bitDepth === 8) {
view.setUint8(offset, Math.floor((sample + 1.0) * 127.5));
} else if (bitDepth === 16) {
view.setInt16(offset, Math.floor(sample < 0 ? sample * 0x8000 : sample * 0x7FFF), true);
} else if (bitDepth === 24) {
const val24 = Math.floor(sample < 0 ? sample * 0x800000 : sample * 0x7FFFFF);
view.setUint8(offset, val24 & 0xFF);
view.setUint8(offset + 1, (val24 >> 8) & 0xFF);
view.setUint8(offset + 2, (val24 >> 16) & 0xFF);
}
offset += bytesPerSample;
}
const blob = new Blob([view], { type: 'audio/wav' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `sonicforge-session-${targetRate}hz-${bitDepth}bit.wav`;
a.click();
URL.revokeObjectURL(url);
showToast("Xuất bản âm thanh hoàn tất!", "success");
} catch (err) {
showToast("Lỗi xuất âm thanh: " + err.message, "error");
} finally {
setIsExporting(false);
}
};
// ── AI Analysis (server-side with fallback) ──
const triggerAIAnalysis = async () => {
setAnalysisState({ status: 'Connecting to AI Engine...', data: null, isRunning: true });
// Check if we have a file on the server to analyze
const activeTrack = tracks.find(t => t.id === selectedTrackId);
const serverFileId = serverFileIdMap[selectedTrackId];
if (serverFileId && serverStatus === 'connected') {
try {
const resp = await fetch(`${API_AUDIO}/analyze-ai`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
file_id: serverFileId,
api_base_url: aiConfig.baseUrl !== API_BASE_URL ? aiConfig.baseUrl : null,
model: aiConfig.model,
}),
});
if (resp.ok) {
const data = await resp.json();
showToast("Đang phân tích cấu trúc trên máy chủ...", "info");
try {
const result = await pollTaskResult(data.task_id, 15);
if (result.bpm) {
if (selLeft !== null && selRight !== null && activeTrack && activeTrack.buffer) {
const snapStart = findZeroCrossing(activeTrack.buffer, selLeft);
const snapEnd = findZeroCrossing(activeTrack.buffer, selRight);
if (selectionMode === 'local') {
setLocalSelectionStart(snapStart);
setLocalSelectionEnd(snapEnd);
} else {
setSelectionStart(snapStart);
setSelectionEnd(snapEnd);
}
}
setAnalysisState({
status: 'Hoàn thành phân tích (Server)',
data: result,
isRunning: false,
});
showToast(`AI Server: ${result.bpm} BPM | ${result.beats?.length || 0} beats detected (Zero-Crossing Aligned)`, 'success');
return;
}
} catch (e) {
console.warn('Server AI poll failed, using fallback:', e);
}
}
} catch (err) {
console.warn('Server AI analysis failed, using fallback:', err);
}
}
// Fallback: client-side analysis with simulated BPM
setAnalysisState({ status: 'Processing structural detection...', data: null, isRunning: true });
setTimeout(() => {
let bpm = 120;
// Try to detect BPM from buffer
if (activeTrack && activeTrack.buffer) {
const data = activeTrack.buffer.getChannelData(0);
const sr = activeTrack.buffer.sampleRate;
// Simple autocorrelation for BPM estimation
const windowSize = Math.min(sr * 3, data.length);
if (windowSize > sr) {
let maxCorr = 0;
let bestLag = Math.floor(sr * 0.25); // ~240 BPM max
for (let lag = Math.floor(sr * 0.25); lag < Math.floor(sr * 2); lag++) {
let corr = 0;
const n = Math.floor(windowSize / 2);
for (let i = 0; i < n; i++) {
corr += data[i] * data[i + lag];
}
corr /= n;
if (corr > maxCorr) {
maxCorr = corr;
bestLag = lag;
}
}
if (bestLag > 0) {
bpm = Math.round(60 * sr / bestLag);
bpm = Math.max(60, Math.min(200, bpm));
}
}
}
if (selLeft !== null && selRight !== null && activeTrack && activeTrack.buffer) {
const snapStart = findZeroCrossing(activeTrack.buffer, selLeft);
const snapEnd = findZeroCrossing(activeTrack.buffer, selRight);
if (selectionMode === 'local') {
setLocalSelectionStart(snapStart);
setLocalSelectionEnd(snapEnd);
} else {
setSelectionStart(snapStart);
setSelectionEnd(snapEnd);
}
}
setAnalysisState({
status: 'Hoàn thành phân tích (Client)',
data: { bpm, bars: Math.max(4, Math.round(bpm / 30)), timeSig: '4/4', detectedKey: 'Am' },
isRunning: false,
});
showToast(`Phân tích: ${bpm} BPM (Zero-Crossing Aligned)`, 'success');
}, 1500);
};
// ── Mark Selection ──
const handleMarkSelection = () => {
if (selLeft === null || selRight === null || selectionStats.length === 0) {
showToast("Vui lòng chọn một khoảng thời gian trên sóng âm trước.", "warning");
return;
}
const targetTrack = tracks.find(t => t.id === selectedTrackId);
if (!targetTrack || !targetTrack.buffer) {
showToast("Vui lòng nhấp chọn một Track có sóng âm để gán Marker.", "warning");
return;
}
// For local selection, force mark on the local-selected track
const markTrackId = selectionMode === 'local' && localSelectionTrackId
? localSelectionTrackId : selectedTrackId;
setTracks(prev => prev.map(t => {
if (t.id !== markTrackId) return t;
const snapStart = findZeroCrossing(t.buffer, selLeft);
const snapEnd = findZeroCrossing(t.buffer, selRight);
const newMarkers = [
...t.markers,
{ id: Date.now() + '_s', time: snapStart },
{ id: Date.now() + '_e', time: snapEnd }
].sort((a,b) => a.time - b.time);
return { ...t, markers: newMarkers };
}));
showToast(`Đã tạo 2 Markers tại đầu và cuối dải chọn (Snap Zero-Crossing)`, "success");
};
// ── AI Cut to New Track (server-side with client fallback) ──
const handleAICutToNewTrack = () => {
const activeTrack = tracks.find(t => t.id === selectedTrackId);
if (!activeTrack || !activeTrack.buffer) {
showToast("Vui lòng chọn một Track có dữ liệu âm thanh trước.", "warning");
return;
}
if (selectionStart === null || selectionEnd === null || selectionStats.length === 0) {
showToast("Vui lòng kéo chọn một khoảng thời gian trên sóng âm.", "warning");
return;
}
setAnalysisState({ status: 'AI đang phân tích điểm Zero-crossing...', data: null, isRunning: true });
showToast("AI đang dò tìm Zero-crossing...", "info");
const buffer = activeTrack.buffer;
const sampleRate = buffer.sampleRate;
const channelData = buffer.getChannelData(0);
const snapStart = findZeroCrossing(buffer, selectionStats.start);
const snapEnd = findZeroCrossing(buffer, selectionStats.end);
const startSample = Math.max(0, Math.min(channelData.length - 1, Math.floor(snapStart * sampleRate)));
const endSample = Math.max(0, Math.min(channelData.length, Math.floor(snapEnd * sampleRate)));
const sliceLength = endSample - startSample;
if (sliceLength <= 0) {
showToast("Dải cắt không hợp lệ hoặc khoảng thời gian quá ngắn.", "error");
setAnalysisState({ status: 'Thất bại', data: null, isRunning: false });
return;
}
setTimeout(() => {
try {
const context = getAudioContext();
const slicedBuffer = context.createBuffer(1, sliceLength, sampleRate);
const slicedData = slicedBuffer.getChannelData(0);
slicedData.set(channelData.subarray(startSample, endSample));
const newId = 'track_ai_cut_' + Date.now();
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
const selectColor = colors[tracks.length % colors.length];
const newTrack = {
id: newId,
name: `AI_Cut_${activeTrack.name.replace('.wav', '')}_${snapStart.toFixed(1)}s.wav`,
buffer: slicedBuffer,
startTime: snapStart,
clips: [{
id: 'clip_' + newId,
buffer: slicedBuffer,
startTime: snapStart,
name: `AI_Cut_${activeTrack.name.replace('.wav', '')}_${snapStart.toFixed(1)}s`
}],
volumeDb: 0,
pan: 0,
muted: false,
solo: false,
color: selectColor,
markers: [
{ id: Date.now() + '_s', time: 0 },
{ id: Date.now() + '_e', time: snapEnd - snapStart }
],
serverFileId: null,
};
setTracks(prev => {
const idx = prev.findIndex(t => t.id === selectedTrackId);
const updated = [...prev];
if (idx !== -1) {
updated.splice(idx + 1, 0, newTrack);
} else {
updated.push(newTrack);
}
return updated;
});
setSelectedTrackId(newId);
setAnalysisState({
status: 'Phân đoạn AI hoàn tất!',
data: { bpm: 120, bars: 4, timeSig: '4/4', detectedKey: 'Am' },
isRunning: false
});
showToast(`AI đã cắt & gộp thành công vào Track mới (Zero-Crossing aligned)`, "success");
setTimeout(() => lucide.createIcons(), 200);
} catch (err) {
showToast("Lỗi giải mã dải cắt: " + err.message, "error");
setAnalysisState({ status: 'Lỗi biên tập', data: null, isRunning: false });
}
}, 1000);
};
// ── Split Track at Playhead ──
const handleSplitTrackAtTime = (trackId, clipId, time) => {
const track = tracks.find(t => t.id === trackId);
if (!track) return;
const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
id: 'default',
buffer: track.buffer,
startTime: track.startTime || 0,
name: track.name
}] : []);
const targetClipId = clipId || (clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration)?.id);
if (!targetClipId) return;
const clip = clips.find(c => c.id === targetClipId);
if (!clip || !clip.buffer) return;
const relTime = Math.max(0, time - clip.startTime);
const sr = clip.buffer.sampleRate;
const cutSample = Math.floor(relTime * sr);
const originalData = clip.buffer.getChannelData(0);
if (cutSample <= 0 || cutSample >= originalData.length) {
showToast("Vị trí cắt nằm ngoài dải âm thanh của clip.", "warning");
return;
}
const beforeSnap = captureTrackSnapshot(trackId);
const ctx = getAudioContext();
const b1 = ctx.createBuffer(1, cutSample, sr);
b1.copyToChannel(originalData.subarray(0, cutSample), 0);
const b2 = ctx.createBuffer(1, originalData.length - cutSample, sr);
b2.copyToChannel(originalData.subarray(cutSample), 0);
const clip1 = {
id: 'clip_' + Date.now() + '_p1',
name: `${clip.name.replace(' (Part 1)', '').replace(' (Part 2)', '')} (Part 1)`,
buffer: b1,
startTime: clip.startTime
};
const clip2 = {
id: 'clip_' + Date.now() + '_p2',
name: `${clip.name.replace(' (Part 1)', '').replace(' (Part 2)', '')} (Part 2)`,
buffer: b2,
startTime: clip.startTime + (cutSample / sr)
};
setTracks(prev => prev.map(t => {
if (t.id === trackId) {
const remainingClips = clips.filter(c => c.id !== targetClipId);
const updatedClips = [...remainingClips, clip1, clip2];
return {
...t,
clips: updatedClips,
buffer: updatedClips[0]?.buffer || null,
startTime: updatedClips[0]?.startTime || 0,
name: updatedClips[0]?.name || t.name
};
}
return t;
}));
setTimeout(() => {
const afterSnap = captureTrackSnapshot(trackId);
pushAction('SPLIT_CLIP', trackId, beforeSnap, afterSnap);
}, 50);
showToast(`Đã chia nhỏ clip tại ${formatTime(time)}.`, "info");
};
const handleSplitTrack = (trackId) => {
handleSplitTrackAtTime(trackId, null, currentTime);
};
// ── Glue (Merge) Clips on Selected Track ──
const handleGlueTracks = () => {
const track = tracks.find(t => t.id === selectedTrackId);
if (!track) {
showToast('Vui lòng chọn một track để thực hiện gộp (glue).', 'warning');
return;
}
const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
id: 'default',
buffer: track.buffer,
startTime: track.startTime || 0,
name: track.name
}] : []);
if (clips.length < 2) {
showToast('Cần ít nhất 2 clip trên track này để gộp (glue).', 'warning');
return;
}
const beforeSnap = captureTrackSnapshot(track.id);
const ctx = getAudioContext();
const sr = clips[0].buffer.sampleRate;
let minStart = Infinity;
let maxEnd = -Infinity;
clips.forEach(c => {
const start = c.startTime || 0;
const end = start + c.buffer.duration;
minStart = Math.min(minStart, start);
maxEnd = Math.max(maxEnd, end);
});
const newDur = maxEnd - minStart;
const newBuffer = ctx.createBuffer(1, Math.ceil(newDur * sr), sr);
const newData = newBuffer.getChannelData(0);
clips.forEach(c => {
const data = c.buffer.getChannelData(0);
const offset = Math.floor(((c.startTime || 0) - minStart) * sr);
for (let i = 0; i < data.length; i++) {
if (offset + i < newData.length) {
newData[offset + i] += data[i];
}
}
});
let maxPeak = 0;
for (let i = 0; i < newData.length; i++) {
const abs = Math.abs(newData[i]);
if (abs > maxPeak) maxPeak = abs;
}
if (maxPeak > 1.0) {
for (let i = 0; i < newData.length; i++) newData[i] /= maxPeak;
}
const mergedClip = {
id: 'clip_merged_' + Date.now(),
name: `${track.name.replace(' (Part 1)', '').replace(' (Part 2)', '')} (Glued)`,
buffer: newBuffer,
startTime: minStart
};
setTracks(prev => prev.map(t => {
if (t.id === track.id) {
return {
...t,
clips: [mergedClip],
buffer: newBuffer,
startTime: minStart,
name: mergedClip.name
};
}
return t;
}));
setTimeout(() => {
const afterSnap = captureTrackSnapshot(track.id);
pushAction('GLUE', track.id, beforeSnap, afterSnap);
}, 50);
showToast(`Đã gộp ${clips.length} clips thành công.`, 'success');
};
// ── Save AI config to localStorage ──
useEffect(() => {
localStorage.setItem('ai_base_url', aiConfig.baseUrl);
localStorage.setItem('ai_api_key', aiConfig.apiKey);
localStorage.setItem('ai_model', aiConfig.model);
}, [aiConfig]);
return (
<div className="h-full w-full flex flex-col bg-[#1e1e1e]">
{/* ── Header ── */}
{/* ── Menu Bar ── */}
<header className="h-9 bg-[#2e2e2e] border-b border-[#181818] flex items-center px-1 shrink-0 select-none">
{[
{ label: 'File', items: [
{ label: 'New Project', icon: 'file-plus', shortcut: 'Ctrl+N', action: () => { setTracks([{ 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:'2', name:'Track 02', buffer:null, startTime:0, height:96, volumeDb:0, pan:0, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','info'); } },
{ label: 'Open Project...', icon: 'folder-open', shortcut: 'Ctrl+O', action: () => showToast('Open project dialog','info') },
{ label: 'Save Project', icon: 'save', shortcut: 'Ctrl+S', action: () => showToast('Project saved','success') },
{ label: 'Save As...', icon: 'save', shortcut: 'Ctrl+Alt+S', action: () => showToast('Save as dialog','info') },
{ label: 'Save to Cloud', icon: 'upload-cloud', action: () => showToast('Saving to cloud...','info') },
{ sep: true },
{ label: 'Import Audio...', icon: 'file-input', shortcut: 'Ctrl+Alt+I', action: () => { const input = document.createElement('input'); input.type='file'; input.accept='audio/*'; input.onchange=async (e)=>{ if(e.target.files[0]){ addNewTrack(); const newId=(tracks.length+1).toString(); setTimeout(()=>loadFileOnTrack(newId,e.target.files[0]),100); } }; input.click(); showToast('Import audio','info'); } },
{ label: 'Export Mix...', icon: 'file-output', action: () => triggerWavExport() },
{ sep: true },
{ label: 'Logout', icon: 'log-out', action: () => showToast('Logged out','info') },
]},
{ label: 'Edit', items: [
{ label: 'Insert New Track', icon: 'plus', shortcut: 'Ctrl+I', action: addNewTrack },
{ label: 'Insert Music to Track', icon: 'music', shortcut: 'Ctrl+Alt+I', action: () => showToast('Select music file to insert','info') },
{ sep: true },
{ label: 'Edit in New Tab', icon: 'file-edit', shortcut: 'Ctrl+E', action: () => openTempTab() },
{ label: 'Split at Playhead', icon: 'scissors', shortcut: 'S', action: () => handleSplitTrack(selectedTrackId) },
{ label: 'Merge Tracks', icon: 'combine', shortcut: 'Ctrl+M', action: () => { handleMergeTracks(); } },
{ sep: true },
{ label: 'Undo', icon: 'undo', shortcut: 'Ctrl+Z', action: () => { handleUndo(); } },
{ label: 'Redo', icon: 'redo', shortcut: 'Ctrl+Y', action: () => { handleRedo(); } },
{ sep: true },
{ label: 'Copy', icon: 'copy', shortcut: 'Ctrl+C', action: () => { handleCopyTrack(); } },
{ label: 'Cut', icon: 'scissors', shortcut: 'Ctrl+X', action: () => { handleCutTrack(); } },
{ label: 'Paste', icon: 'clipboard', shortcut: 'Ctrl+V', action: handlePasteTrack },
{ sep: true },
{ label: 'Delete Track', icon: 'trash-2', shortcut: 'Del', action: () => { handleDeleteTrack(); } },
]},
{ label: 'View', items: [
{ label: 'Master Track', icon: 'disc', action: () => showToast('Master track view','info') },
{ label: 'Maker View', icon: 'layout', action: () => showToast('Maker view','info') },
{ label: 'Mixer', icon: 'sliders', action: () => showToast('Mixer panel','info') },
{ label: 'Tempo Track', icon: 'timer', action: () => showToast('Tempo track','info') },
{ label: 'Video', icon: 'film', action: () => showToast('Video panel','info') },
{ label: 'Media Explorer', icon: 'folder-search', action: () => showToast('Media explorer','info') },
]},
{ label: 'Tools', items: [
{ label: 'Config', icon: 'settings', action: () => setShowAIConfig(true) },
]},
{ label: 'Help', items: [
{ label: 'About SonicForge', icon: 'info', action: () => showToast('SonicForge Studio v1.0 - Professional DAW','info') },
]},
].map(menu => (
<div key={menu.label} className="relative">
<button
onClick={() => setMenuOpen(menuOpen === menu.label ? null : menu.label)}
className={`px-3 py-1 text-[11px] font-medium transition rounded ${
menuOpen === menu.label ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'
}`}
>
{menu.label}
</button>
{menuOpen === menu.label && (
<div className="absolute top-full left-0 mt-0.5 bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-52 z-50"
onClick={() => setMenuOpen(null)}>
{menu.items.map((item, i) => item.sep ? (
<div key={i} className="h-px bg-zinc-700 my-1"></div>
) : (
<button key={item.label} onClick={(e) => { e.stopPropagation(); item.action(); setMenuOpen(null); }}
className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
<i data-lucide={item.icon} className="w-3.5 h-3.5 text-zinc-500 shrink-0"></i>
<span className="flex-1">{item.label}</span>
{item.shortcut && <span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">{item.shortcut}</span>}
</button>
))}
</div>
)}
</div>
))}
<div className="flex-1"></div>
<div className="flex items-center gap-2 px-2">
<span className={`text-[9px] font-bold uppercase px-1.5 py-0.5 rounded ${
serverStatus === 'connected' ? 'bg-emerald-950 text-emerald-400' :
serverStatus === 'checking' ? 'bg-amber-950 text-amber-400' :
'bg-red-950 text-red-400'
}`}>Server: {serverStatus}</span>
<button onClick={() => setShowAIConfig(!showAIConfig)}
className={`px-1.5 py-0.5 rounded text-[10px] border transition ${
showAIConfig ? 'bg-purple-900 text-purple-200 border-purple-700' : 'bg-zinc-800 text-zinc-400 border-zinc-700 hover:text-zinc-200'
}`}>
<i data-lucide="cpu" className="w-3 h-3"></i>
</button>
</div>
</header>
{/* Close menu on outside click */}
{menuOpen && <div className="fixed inset-0 z-40" onClick={() => setMenuOpen(null)}></div>}
{/* ── Tab Bar (LOOP_EDITOR_2.md §1) ── */}
<div className="h-7 bg-[#222] border-b border-zinc-800 flex items-stretch px-2 gap-0.5 shrink-0 overflow-x-auto">
<button onClick={() => setActiveTab('main')}
className={`px-3 text-[10px] font-bold uppercase tracking-wider border-b-2 transition flex items-center gap-1 ${
activeTab === 'main'
? 'text-cyan-400 border-cyan-500 bg-zinc-800/50'
: 'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'
}`}>
<i data-lucide="layout-dashboard" className="w-3 h-3"></i> Main Session
</button>
{subTabs.map(st => (
<div key={st.id} className="flex items-stretch">
<button onClick={() => setActiveTab(st.id)}
className={`px-2 text-[10px] font-medium border-b-2 transition flex items-center gap-1 ${
activeTab === st.id
? 'text-amber-400 border-amber-500 bg-zinc-800/50'
: 'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'
}`}>
<i data-lucide="file-edit" className="w-3 h-3"></i>
<span className="max-w-[100px] truncate">{st.label}</span>
</button>
<button onClick={() => closeSubTab(st.id)}
className="px-1 text-zinc-600 hover:text-red-400 transition text-[9px]"
title="Close tab">
<i data-lucide="x" className="w-3 h-3"></i>
</button>
</div>
))}
</div>
{/* ── AI Config Drawer ── */}
{showAIConfig && (
<div className="bg-zinc-900 border-b border-purple-900 p-3 flex flex-col gap-2 transition-all">
<div className="text-xs font-bold text-purple-400 uppercase tracking-wider flex items-center gap-1">
<i data-lucide="cpu" className="w-4 h-4"></i> Cấu hình cổng kết nối API
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 text-xs">
<div className="flex flex-col gap-1">
<span className="text-[10px] text-zinc-500 font-bold uppercase">Endpoint Base URL</span>
<input
type="text"
value={aiConfig.baseUrl}
onChange={(e) => setAiConfig(prev => ({ ...prev, baseUrl: e.target.value }))}
className="bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-[11px]"
placeholder="https://api.openai.com/v1"
/>
</div>
<div className="flex flex-col gap-1">
<span className="text-[10px] text-zinc-500 font-bold uppercase">API Token Key</span>
<input
type="password"
value={aiConfig.apiKey}
onChange={(e) => setAiConfig(prev => ({ ...prev, apiKey: e.target.value }))}
className="bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-[11px]"
placeholder="sk-..."
/>
</div>
<div className="flex flex-col gap-1">
<span className="text-[10px] text-zinc-500 font-bold uppercase">Model Name</span>
<input
type="text"
value={aiConfig.model}
onChange={(e) => setAiConfig(prev => ({ ...prev, model: e.target.value }))}
className="bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-[11px]"
placeholder="gpt-4o-mini"
/>
</div>
</div>
</div>
)}
{/* ── Transport Bar + Toolbar Overlay ── */}
<div className="h-9 bg-[#222] border-b border-zinc-800 flex items-center px-3 gap-2 shrink-0 select-none">
{/* Toolbar Overlay Panel */}
<div className="flex items-center gap-1 bg-zinc-900 border border-zinc-700 rounded px-1.5 py-0.5 shadow-lg"
title="Kéo để di chuyển toolbar" style={{ cursor: 'grab' }}>
<div className="flex items-center gap-0.5 mr-1 text-zinc-600">
<i data-lucide="grip-vertical" className="w-3 h-3"></i>
</div>
<button onClick={() => { setActiveTool('select'); showToast('Select Tool', 'info'); }}
className={`w-7 h-7 flex items-center justify-center rounded ${activeTool === 'select' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`}
title="Select Tool (V)">
<i data-lucide="mouse-pointer" className="w-3.5 h-3.5"></i>
</button>
<button onClick={() => { setActiveTool('grab'); showToast('Grab Tool', 'info'); }}
className={`w-7 h-7 flex items-center justify-center rounded ${activeTool === 'grab' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`}
title="Grab Tool (H)">
<i data-lucide="hand" className="w-3.5 h-3.5"></i>
</button>
<div className="flex items-center gap-0.5 border border-zinc-700 rounded bg-zinc-850 px-0.5">
<button onClick={() => { setActiveTool('razor'); showToast('Razor Tool', 'info'); }}
className={`w-7 h-7 flex items-center justify-center rounded ${activeTool === 'razor' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`}
title="Razor Tool (C)">
<svg className="w-3.5 h-3.5 text-orange-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 3h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/>
<path d="M4 9h16l-3 9H7z"/>
<circle cx="12" cy="6" r="1"/>
</svg>
</button>
<button onClick={handleGlueTracks}
className="w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-purple-400 hover:bg-zinc-800"
title="Glue Clips">
<i data-lucide="link" className="w-3.5 h-3.5"></i>
</button>
</div>
<button onClick={() => { setActiveTool('pen'); showToast('Pen Tool', 'info'); }}
className={`w-7 h-7 flex items-center justify-center rounded ${activeTool === 'pen' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`}
title="Pen Tool (P)">
<i data-lucide="pen-tool" className="w-3.5 h-3.5"></i>
</button>
<div className="w-[1px] h-5 bg-zinc-700 mx-0.5"></div>
<button onClick={handleCutTrack}
className="w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-red-400 hover:bg-zinc-800"
title="Cut (Ctrl+X)">
<i data-lucide="scissors" className="w-3.5 h-3.5"></i>
</button>
<button onClick={handleCopyTrack}
className="w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-blue-400 hover:bg-zinc-800"
title="Copy (Ctrl+C)">
<i data-lucide="copy" className="w-3.5 h-3.5"></i>
</button>
<button onClick={handlePasteTrack}
className="w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-emerald-400 hover:bg-zinc-800"
title="Paste (Ctrl+V)">
<i data-lucide="clipboard" className="w-3.5 h-3.5"></i>
</button>
<div className="w-[1px] h-5 bg-zinc-700 mx-0.5"></div>
<button onClick={handleUndo} disabled={undoStack.length === 0}
className="w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 disabled:opacity-30"
title="Undo (Ctrl+Z)">
<i data-lucide="undo" className="w-3.5 h-3.5"></i>
</button>
<button onClick={handleRedo} disabled={redoStack.length === 0}
className="w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 disabled:opacity-30"
title="Redo (Ctrl+Y)">
<i data-lucide="redo" className="w-3.5 h-3.5"></i>
</button>
</div>
<div className="w-[1px] h-5 bg-zinc-800 mx-0.5"></div>
<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 (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}
className={`w-7 h-7 flex items-center justify-center rounded border transition ${
isPlaying
? 'bg-emerald-600 text-black border-emerald-500 hover:bg-emerald-500'
: 'bg-cyan-600 text-white border-cyan-500 hover:bg-cyan-500'
}`}
title={isPlaying ? "Tạm dừng" : "Play"}>
<i data-lucide={isPlaying ? "pause" : "play"} className="w-3.5 h-3.5 fill-current"></i>
</button>
<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 (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={() => {
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>
<button onClick={() => setIsLoopingSelection(prev => !prev)}
className={`w-7 h-7 flex items-center justify-center rounded border transition ${
isLoopingSelection
? 'bg-amber-600 text-black border-amber-500 hover:bg-amber-500'
: 'bg-zinc-800 text-zinc-200 border-zinc-700 hover:bg-zinc-700'
}`} title="Bật/Tắt Lặp vùng chọn">
<i data-lucide="repeat" className="w-3.5 h-3.5"></i>
</button>
<span className="text-[14px] text-zinc-500 font-bold uppercase ml-2">Snap</span>
<select value={snapValue} onChange={e => setSnapValue(e.target.value)}
className="bg-black text-white text-[14px] px-1.5 py-0.5 rounded border border-zinc-700 focus:outline-none focus:border-cyan-500 font-mono cursor-pointer">
<option value="free">Free</option><option value="1">1</option>
<option value="1/2">1/2</option><option value="1/4">1/4</option>
<option value="1/8">1/8</option><option value="1/16">1/16</option><option value="1/32">1/32</option>
</select>
<div className="w-[1px] h-6 bg-zinc-800 mx-1.5"></div>
<span className="text-[14px] text-zinc-500 font-bold">Bars:</span>
<input type="number" min="1" value={beginBar} onChange={e => {
const b = parseInt(e.target.value) || 1;
setBeginBar(b);
const beatDuration = 60 / parseInt(bpm || 120);
const t = (b - 1) * beatDuration * 4;
clearLocalSelection();
setSelectionMode('global');
setSelectionStart(t);
setSelectionEnd(t + beatDuration * 4);
}} className="w-14 bg-black text-white text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" />
<span className="text-[14px] text-zinc-500">-</span>
<input type="number" min="1" value={endBar} onChange={e => {
const b = parseInt(e.target.value) || 1;
setEndBar(b);
const beatDuration = 60 / parseInt(bpm || 120);
const t = (b - 1) * beatDuration * 4;
setSelectionEnd(t + beatDuration * 4);
setNumberBar(b - beginBar + 1);
}} className="w-14 bg-black text-white text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" />
<span className="text-[14px] text-zinc-500">#</span>
<input type="number" min="1" value={numberBar} readOnly
className="w-14 bg-black text-zinc-400 text-[14px] px-1 py-0.5 rounded border border-zinc-800 text-center font-mono" />
<div className="w-[1px] h-6 bg-zinc-800 mx-1.5"></div>
<span className="text-[14px] text-zinc-500">Start:</span>
<input type="text" value={selLeft !== null ? formatTime(selLeft) : ''}
onChange={e => {
const parts = e.target.value.split(/[:.]/);
if (parts.length === 3) {
const secs = parseInt(parts[0]) * 60 + parseInt(parts[1]) + parseFloat('0.' + (parts[2] || '0'));
clearLocalSelection();
setSelectionMode('global');
setSelectionStart(secs);
}
}}
className="w-20 bg-black text-zinc-200 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" />
<span className="text-[14px] text-zinc-500">End:</span>
<input type="text" value={selRight !== null ? formatTime(selRight) : ''}
onChange={e => {
const parts = e.target.value.split(/[:.]/);
if (parts.length === 3) {
const secs = parseInt(parts[0]) * 60 + parseInt(parts[1]) + parseFloat('0.' + (parts[2] || '0'));
setSelectionEnd(secs);
}
}}
className="w-20 bg-black text-zinc-200 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" />
<span className="text-[14px] text-zinc-500">Len:</span>
<input type="text" value={selLeft !== null && selRight !== null ? formatTime(Math.abs(selRight - selLeft)) : ''}
onChange={e => {
const parts = e.target.value.split(/[:.]/);
if (parts.length === 3 && selLeft !== null) {
const secs = parseInt(parts[0]) * 60 + parseInt(parts[1]) + parseFloat('0.' + (parts[2] || '0'));
setSelectionEnd(selLeft + secs);
}
}}
className="w-20 bg-black text-amber-300 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" />
<div className="flex-1"></div>
<div className="text-xs font-bold font-mono text-zinc-100">{formatTime(currentTime)}</div>
</div>
{/* ── Dock-Aware Workspace ── */}
{(() => {
const dockPanels = { top: [], right: [], bottom: [], left: [] };
const addPanel = (id, pos, visible) => { if (visible) dockPanels[pos].push(id); };
addPanel('export', panelPositions.export, showExportPanel);
addPanel('ai', panelPositions.ai, showAIPanel);
addPanel('selection', panelPositions.selection, showSelectionPanel);
const closePanel = (id) => {
if (id === 'export') setShowExportPanel(false);
else if (id === 'ai') setShowAIPanel(false);
else if (id === 'selection') setShowSelectionPanel(false);
};
const renderPanelContent = (panelId) => {
const h = (id) => (e) => { startPanelDrag(id, e); };
if (panelId === 'export') return (
<div className="flex flex-col h-full gap-1.5">
<div className="flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none" onMouseDown={e => startPanelDrag('export', e)}>
<h3 className="font-bold text-[10px] text-zinc-200 flex items-center gap-1">
<i data-lucide="grip-vertical" className="w-3 h-3 text-zinc-500"></i>
<i data-lucide="save" className="w-3.5 h-3.5 text-cyan-400"></i> Export
</h3>
<button onClick={() => closePanel('export')} className="text-zinc-600 hover:text-zinc-300"><i data-lucide="x" className="w-3 h-3"></i></button>
</div>
<div className="grid grid-cols-3 gap-1">
<div>
<label className="block text-[7px] text-zinc-500 font-bold uppercase mb-0.5">SR</label>
<select value={exportSettings.sampleRate} onChange={e => setExportSettings(p => ({...p, sampleRate: e.target.value}))}
className="w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-[9px] text-zinc-300 focus:outline-none">
<option value="44100">44.1k</option><option value="48000">48k</option>
</select>
</div>
<div>
<label className="block text-[7px] text-zinc-500 font-bold uppercase mb-0.5">Bit</label>
<select value={exportSettings.bitDepth} onChange={e => setExportSettings(p => ({...p, bitDepth: e.target.value}))}
className="w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-[9px] text-zinc-300 focus:outline-none">
<option value="16">16</option><option value="24">24</option>
</select>
</div>
<div>
<label className="block text-[7px] text-zinc-500 font-bold uppercase mb-0.5">Fmt</label>
<select value={exportSettings.format} onChange={e => setExportSettings(p => ({...p, format: e.target.value}))}
className="w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-[9px] text-zinc-300 focus:outline-none">
<option value="wav">WAV</option>
</select>
</div>
</div>
<button onClick={triggerWavExport} disabled={isExporting}
className="w-full py-1 bg-cyan-700 hover:bg-cyan-600 text-zinc-100 font-bold rounded text-[10px] flex items-center justify-center gap-1">
<i data-lucide="download-cloud" className="w-3 h-3"></i>
{isExporting ? '...' : 'Export'}
</button>
</div>
);
if (panelId === 'ai') return (
<div className="flex flex-col h-full gap-1.5">
<div className="flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none" onMouseDown={e => startPanelDrag('ai', e)}>
<h3 className="font-bold text-[10px] text-zinc-200 flex items-center gap-1">
<i data-lucide="grip-vertical" className="w-3 h-3 text-zinc-500"></i>
<i data-lucide="cpu" className="w-3.5 h-3.5 text-purple-400"></i> AI
</h3>
<button onClick={() => closePanel('ai')} className="text-zinc-600 hover:text-zinc-300"><i data-lucide="x" className="w-3 h-3"></i></button>
</div>
<div className="p-1.5 bg-[#141414] rounded border border-zinc-800 text-[9px] font-mono min-h-[24px]">
<div className="text-zinc-500">// <span className="text-zinc-300">{analysisState.status}</span></div>
{analysisState.data && <div className="text-emerald-500 font-semibold">BPM: {analysisState.data.bpm}</div>}
</div>
<div className="grid grid-cols-3 gap-1">
<button onClick={handleMarkSelection} className="py-1 bg-purple-900 hover:bg-purple-800 text-purple-100 font-bold rounded text-[9px] border border-purple-700 flex items-center justify-center gap-1">
<i data-lucide="map-pin" className="w-3 h-3"></i> Mark
</button>
<button onClick={handleAICutToNewTrack} disabled={analysisState.isRunning} className="py-1 bg-purple-700 hover:bg-purple-600 text-white font-bold rounded text-[9px] flex items-center justify-center gap-1">
<i data-lucide="scissors" className="w-3 h-3"></i> AI Cut
</button>
<button onClick={triggerAIAnalysis} disabled={analysisState.isRunning} className="py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-200 font-bold rounded text-[9px] border border-zinc-600 flex items-center justify-center gap-1">
<i data-lucide="sparkles" className="w-3 h-3"></i> Analyze
</button>
</div>
</div>
);
if (panelId === 'selection') return (
<div className="flex flex-col h-full gap-1.5">
<div className="flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none" onMouseDown={e => startPanelDrag('selection', e)}>
<span className="text-[10px] text-zinc-500 font-bold uppercase flex items-center gap-1">
<i data-lucide="grip-vertical" className="w-3 h-3 text-zinc-500"></i> Selection
</span>
<button onClick={() => closePanel('selection')} className="text-zinc-600 hover:text-zinc-300"><i data-lucide="x" className="w-3 h-3"></i></button>
</div>
<div className="grid grid-cols-3 gap-1 bg-[#141414] p-1.5 rounded border border-zinc-800">
<div>
<span className="block text-[7px] text-zinc-500 font-bold uppercase mb-0.5">Start</span>
<input type="number" step="0.01" value={selectionStats.start} onChange={e => handleSelectionInputChange('start', e.target.value)}
className="w-full bg-[#242424] text-amber-400 text-center font-mono text-[9px] rounded py-0.5 border border-zinc-700 focus:outline-none" />
</div>
<div>
<span className="block text-[7px] text-zinc-500 font-bold uppercase mb-0.5">End</span>
<input type="number" step="0.01" value={selectionStats.end} onChange={e => handleSelectionInputChange('end', e.target.value)}
className="w-full bg-[#242424] text-amber-400 text-center font-mono text-[9px] rounded py-0.5 border border-zinc-700 focus:outline-none" />
</div>
<div className="flex flex-col justify-center">
<span className="text-[7px] text-zinc-500 font-bold uppercase">Len</span>
<span className="text-zinc-200 font-mono text-[9px] font-semibold mt-0.5">{selectionStats.length}s</span>
</div>
</div>
<div className="grid grid-cols-3 gap-1 bg-[#141414] p-1.5 rounded border border-zinc-800 mt-1">
<div>
<span className="block text-[7px] text-zinc-500 font-bold uppercase mb-0.5">Begin Bar</span>
<input type="number" min="1" value={beginBar} onChange={e => {
const b = parseInt(e.target.value) || 1;
setBeginBar(b);
const beatDuration = 60 / parseInt(bpm || 120);
const t = (b - 1) * beatDuration * 4;
clearLocalSelection();
setSelectionMode('global');
setSelectionStart(t);
setSelectionEnd(t + beatDuration * 4);
}} className="w-full bg-[#242424] text-white text-center font-mono text-[9px] rounded py-0.5 border border-zinc-700 focus:outline-none" />
</div>
<div>
<span className="block text-[7px] text-zinc-500 font-bold uppercase mb-0.5">End Bar</span>
<input type="number" min="1" value={endBar} onChange={e => {
const b = parseInt(e.target.value) || 1;
setEndBar(b);
const beatDuration = 60 / parseInt(bpm || 120);
const t = (b - 1) * beatDuration * 4;
setSelectionEnd(t + beatDuration * 4);
setNumberBar(b - beginBar + 1);
}} className="w-full bg-[#242424] text-white text-center font-mono text-[9px] rounded py-0.5 border border-zinc-700 focus:outline-none" />
</div>
<div className="flex flex-col justify-center">
<span className="text-[7px] text-zinc-500 font-bold uppercase"># Bars</span>
<span className="text-zinc-200 font-mono text-[9px] font-semibold mt-0.5">{numberBar}</span>
</div>
</div>
</div>
);
return null;
};
const renderDock = (pos, title) => {
const panels = dockPanels[pos];
if (panels.length === 0) return null;
const isSide = pos === 'left' || pos === 'right';
const sideClass = isSide ? 'w-56 shrink-0 overflow-y-auto border-zinc-800' : 'shrink-0';
const borderClass = pos === 'left' ? 'border-r' : pos === 'right' ? 'border-l' : pos === 'top' ? 'border-b' : 'border-t';
const bgClass = 'bg-[#1e1e1e]';
const highlight = panelDragRef.current && panelDropZone === pos;
return (
<div className={`${sideClass} ${borderClass} ${bgClass} flex ${isSide ? 'flex-col p-2 gap-2' : 'flex-row p-1.5 gap-3'} select-none ${highlight ? 'ring-2 ring-cyan-500 ring-inset' : ''}`}>
{panels.map(p => (
<div key={p} className={`${isSide ? 'w-full' : 'flex-1 min-w-0'} border border-zinc-700 rounded-lg bg-[#262626] shadow-sm p-2`}>
{renderPanelContent(p)}
</div>
))}
</div>
);
};
return (
<div ref={workspaceRef} className="flex-1 flex flex-col overflow-hidden select-none daw-bg relative">
{panelDragRef.current && panelDropZone && (
<div className="absolute inset-0 z-50 pointer-events-none">
{panelDropZone === 'top' && <div className="absolute top-0 left-0 right-0 h-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]"></div>}
{panelDropZone === 'bottom' && <div className="absolute bottom-0 left-0 right-0 h-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]"></div>}
{panelDropZone === 'left' && <div className="absolute top-0 bottom-0 left-0 w-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]"></div>}
{panelDropZone === 'right' && <div className="absolute top-0 bottom-0 right-0 w-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]"></div>}
</div>
)}
{/* Drag Ghost */}
{dragGhostPanel && dragGhostPos && (
<div className="fixed z-[100] pointer-events-none opacity-80 border border-cyan-500 rounded-lg bg-[#262626] shadow-2xl p-3 w-56"
style={{ left: dragGhostPos.x, top: dragGhostPos.y }}>
<div className="flex items-center gap-2 text-[10px] text-zinc-200 font-bold">
<i data-lucide="move" className="w-3.5 h-3.5 text-cyan-400"></i>
{dragGhostPanel === 'export' ? 'Export Panel' : dragGhostPanel === 'ai' ? 'AI Panel' : 'Selection Panel'}
</div>
<div className="text-[8px] text-zinc-500 mt-1">Drop at edge to dock</div>
</div>
)}
{renderDock('top', 'Top')}
<div className="flex-1 flex overflow-hidden">
{renderDock('left', 'Left')}
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
<div className="flex-1 flex overflow-hidden">
{activeTab === 'main' ? (
<>
{/* ══ LEFT COLUMN: TCP PANEL (main session, all tracks) ══ */}
<div ref={tcpContainerRef}
onScroll={handleTCPScroll}
className="w-[300px] shrink-0 z-30 bg-[#262626] overflow-y-scroll no-scrollbar flex flex-col border-r border-zinc-900"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
<div className="sticky top-0 z-50 flex items-center h-10 border-b border-zinc-900 bg-[#242424] px-2 shrink-0">
</div>
<div className="sticky top-10 z-40 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] p-2 border-l-4 border-purple-500 shrink-0">
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-purple-400 font-mono">TM</span>
<span className="text-xs font-semibold text-zinc-300">Tempo</span>
</div>
<div className="flex items-center gap-1">
<input type="number" value={bpm} onChange={e => setBpm(e.target.value)}
onBlur={() => localStorage.setItem('studio_bpm', bpm)}
className="w-12 bg-zinc-800 border border-zinc-700 rounded text-[10px] text-zinc-300 text-center font-mono focus:outline-none focus:border-purple-500" min="40" max="300" />
<span className="text-[9px] text-zinc-500">BPM</span>
</div>
</div>
</div>
<div className="flex-1 flex flex-col divide-y divide-[#141414] bg-[#262626]">
{tracks.map((track, idx) => {
const isSelected = selectedTrackId === track.id;
return (
<div key={track.id} style={{ height: `${track.height || 96}px` }}
className={`shrink-0 relative flex flex-col justify-between p-2.5 bg-[#1e1e1e] border-r border-zinc-900 cursor-pointer border-l-4 border-b border-[#141414] overflow-hidden ${isSelected ? 'border-cyan-500 bg-[#252525]' : 'border-transparent hover:bg-zinc-800/20'}`}
onClick={() => setSelectedTrackId(track.id)}>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-zinc-500 font-mono">{(idx+1).toString().padStart(2, '0')}</span>
<label onClick={e => { e.stopPropagation(); const el = e.currentTarget.querySelector('input'); if (el) el.click(); }} className="cursor-pointer">
<input type="color" value={track.color || '#0f766e'} onChange={e => { e.stopPropagation(); updateTrackColor(track.id, e.target.value); }} className="w-0 h-0 opacity-0 absolute pointer-events-none" />
<div className="w-3 h-3 rounded-full border border-zinc-600 hover:scale-110 transition-transform" style={{ backgroundColor: track.color }} />
</label>
{editingTrackName === track.id ? (
<input type="text" value={editNameInput} autoFocus
onChange={e => setEditNameInput(e.target.value)}
onBlur={() => { updateTrackName(track.id, editNameInput || track.name); setEditingTrackName(null); }}
onKeyDown={e => { if (e.key === 'Enter') { updateTrackName(track.id, editNameInput || track.name); setEditingTrackName(null); } if (e.key === 'Escape') setEditingTrackName(null); }}
onClick={e => e.stopPropagation()}
className="text-xs font-semibold bg-black text-zinc-200 border border-cyan-500 rounded px-1 py-0 w-24 outline-none" />
) : (
<span className="text-xs font-semibold text-zinc-300 truncate max-w-[80px] cursor-text hover:text-cyan-300 transition-colors"
title="Click to rename"
onClick={e => { e.stopPropagation(); setEditingTrackName(track.id); setEditNameInput(track.name); }}>
{track.name}
</span>
)}
</div>
<div className="flex items-center gap-1">
<button onClick={e => { e.stopPropagation(); toggleTrackMute(track.id); }}
className={`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition ${track.muted ? 'bg-red-950 text-red-400 border-red-700' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`}>M</button>
<button onClick={e => { e.stopPropagation(); toggleTrackSoloEvaluate(track.id); }}
className={`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition ${soloedTrackId === track.id || track.solo ? 'bg-amber-950 text-amber-400 border-amber-600' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`}>S</button>
<button onClick={e => { e.stopPropagation(); deleteTrack(track.id); }}
className="p-0.5 rounded text-red-500 hover:bg-red-950/40 hover:text-red-400 transition"><i data-lucide="trash-2" className="w-3.5 h-3.5"></i></button>
</div>
</div>
<div className="flex flex-col gap-0.5 text-[10px]" onClick={e => e.stopPropagation()}>
<div className="flex items-center gap-1">
<span className="w-8 text-right text-zinc-500 text-[9px]">Vol:</span>
<input type="range" min="-50" max="7" step="0.5" value={track.volumeDb ?? 0} onChange={e => updateTrackVolumeDb(track.id, parseFloat(e.target.value))} className="flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-cyan-500" style={{ height: '4px' }} />
<span className="w-12 text-right font-mono text-zinc-300 text-[9px]">{track.volumeDb ?? 0}dB</span>
</div>
<div className="flex items-center gap-1">
<span className="w-8 text-right text-zinc-500 text-[9px]">Pan:</span>
<input type="range" min="-100" max="100" step="1" value={track.pan ?? 0} onChange={e => updateTrackPan(track.id, parseInt(e.target.value))} className="flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-purple-500" style={{ height: '4px' }} />
<span className="w-12 text-right font-mono text-zinc-300 text-[9px]">{track.pan > 0 ? 'R' + track.pan : track.pan < 0 ? 'L' + Math.abs(track.pan) : 'C'}</span>
</div>
</div>
<div className="flex items-center gap-1.5 mt-1" onClick={e => e.stopPropagation()}>
<input type="file" id={`upload-${track.id}`} accept="audio/*" className="hidden" onChange={e => loadFileOnTrack(track.id, e.target.files[0])} />
<label htmlFor={`upload-${track.id}`} className="px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded text-[10px] flex items-center gap-1 cursor-pointer border border-zinc-700 font-medium"><i data-lucide="upload" className="w-3 h-3"></i> File</label>
<button onClick={e => { e.stopPropagation(); showToast('FX panel for track ' + track.id, 'info'); }} className="px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-purple-400 border border-purple-900 rounded text-[10px] font-bold flex items-center gap-1"><i data-lucide="wand-2" className="w-3 h-3"></i> FX: <span className="text-zinc-500 font-normal">None</span></button>
<button onClick={() => generateSynthToTrack(track.id, 'synth')} className="px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-amber-400 border border-amber-800 rounded text-[10px] font-bold flex items-center gap-1"><i data-lucide="music" className="w-3 h-3"></i> Synth: <span className="text-zinc-500 font-normal">None</span></button>
</div>
<div onMouseDown={e => handleTrackResizeMouseDown(e, track.id)}
className="absolute bottom-0 left-0 right-0 h-1 cursor-ns-resize z-30 hover:bg-cyan-500/50 transition-colors" onClick={e => e.stopPropagation()} />
</div>
);
})}
</div>
<div className="h-[48px] border-r border-zinc-900 bg-[#1e1e1e]/50 shrink-0" />
</div>
{/* ══ RIGHT COLUMN: TIMELINE (main session, all tracks) ══ */}
<div ref={timelineWrapperRef} onScroll={handleTimelineScroll}
className="flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0">
<div style={{ width: `${timelineWidth}px` }} className="relative flex flex-col min-h-full">
<div className="sticky top-0 z-45 flex h-10 border-b border-zinc-900 bg-[#242424] shrink-0">
<div ref={rulerRef} className="flex-1 relative h-full flex items-center select-none cursor-ew-resize overflow-hidden" onMouseDown={handleRulerMouseDown}>
{Array.from({ length: Math.ceil(maxDuration) }).map((_, i) => {
const sec = i; const x = sec * zoom;
return (<div key={i} className="absolute h-full border-l border-zinc-700 pl-1 pt-1 text-[14px] font-mono text-zinc-300 pointer-events-none" style={{ left: `${x}px` }}>{formatTimeSimple(sec)}</div>);
})}
</div>
</div>
<div className="sticky top-10 z-35 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] shrink-0">
<div className="flex-1 relative h-full overflow-hidden bg-[#1a1a2e]">
<TempoTrackLane bpm={parseInt(bpm) || 120} zoom={zoom} timelineWidth={timelineWidth}
onPlayheadSet={setCurrentTime} snapValue={snapValue} />
</div>
</div>
<div className="flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full">
{tracks.map((track, idx) => {
const isSelected = selectedTrackId === track.id;
return (
<div key={track.id} style={{ height: `${track.height || 96}px` }}
className={`shrink-0 relative overflow-hidden border-b border-[#141414] hover:bg-zinc-850/5 transition-colors ${isSelected ? 'bg-zinc-800/10' : ''}`}
onDragOver={e => e.preventDefault()}
onDrop={e => { e.preventDefault(); if (e.dataTransfer.files[0]) loadFileOnTrack(track.id, e.dataTransfer.files[0]); }}
onMouseEnter={() => setHoveredTrackId(track.id)}>
<WaveformLane track={track} zoom={zoom} timelineWidth={timelineWidth}
onSelectRange={handleSelectRange} onPlayheadSet={setCurrentTime}
isSelected={isSelected} onSelectTrack={setSelectedTrackId} markers={track.markers}
onTrackLaneMouseDown={handleTrackLaneMouseDown}
onContextMenu={handleContextMenu}
onClipDragStart={handleClipDragStart}
onClipStretchStart={handleClipStretchStart}
onSelectionEdgeDragStart={handleSelectionEdgeDragStart}
setSelectedClipId={setSelectedClipId}
activeTool={activeTool}
onSplitTrackAtTime={handleSplitTrackAtTime}
onEditClipInSubTab={handleEditClipInSubTab}
snapValue={snapValue} bpm={bpm}
selectionMode={selectionMode}
localSelectionTrackId={localSelectionTrackId}
localSelLeft={localSelectionStart !== null && localSelectionEnd !== null ? Math.min(localSelectionStart, localSelectionEnd) : null}
localSelRight={localSelectionStart !== null && localSelectionEnd !== null ? Math.max(localSelectionStart, localSelectionEnd) : null} />
{track.buffer && (
<div className="absolute right-2 top-2 flex items-center gap-1 z-10 opacity-70 hover:opacity-100">
<button onClick={() => handleSplitTrack(track.id)}
className="px-1.5 py-0.5 bg-zinc-900/95 text-zinc-300 rounded text-[9px] flex items-center gap-1 border border-zinc-700/50"><i data-lucide="scissors" className="w-2.5 h-2.5 text-cyan-400"></i> Cắt</button>
</div>
)}
<div onMouseDown={e => handleTrackResizeMouseDown(e, track.id)}
className="absolute bottom-0 left-0 right-0 h-1 cursor-ns-resize z-30 hover:bg-cyan-500/50 transition-colors" onClick={e => e.stopPropagation()} />
</div>
);
})}
<div className="h-[48px] flex items-center justify-center text-xs text-zinc-500 hover:bg-zinc-900/20 cursor-pointer select-none border-t border-dashed border-zinc-800"
onMouseEnter={() => { if (draggedClipRef.current) { setHoveredTrackId(addNewTrack()); } }}
onClick={addNewTrack}>
<span className="flex items-center gap-1 text-zinc-400"><i data-lucide="plus" className="w-3.5 h-3.5"></i> Kéo clip xuống hoặc Click tạo Track</span>
</div>
{selectionMode !== 'local' && selLeft !== null && selRight !== null && selRight > selLeft && (
<div className="absolute top-0 bottom-0 border-l border-r border-amber-500 bg-amber-500/10 z-20 selection-interactive-box cursor-grab active:cursor-grabbing"
style={{ left: `${selLeft * zoom}px`, width: `${(selRight - selLeft) * zoom}px` }}
onMouseDown={handleSelectionBodyDragStart}>
<div className="absolute -left-1.5 top-0 bottom-0 w-3 bg-amber-500 hover:bg-amber-400 cursor-ew-resize flex items-center justify-center z-30"
onMouseDown={e => handleHandleDragStart(e, 'left')}><div className="w-[1.5px] h-4 bg-zinc-950/70 rounded"></div></div>
<div className="absolute -right-1.5 top-0 bottom-0 w-3 bg-amber-500 hover:bg-amber-400 cursor-ew-resize flex items-center justify-center z-30"
onMouseDown={e => handleHandleDragStart(e, 'right')}><div className="w-[1.5px] h-4 bg-zinc-950/70 rounded"></div></div>
</div>
)}
<div className="absolute top-0 bottom-0 w-[2px] bg-[#ef4444] z-20 pointer-events-none" style={{ left: `${playheadLeftPos}px` }}>
<div className="w-3 h-3 bg-[#ef4444] rotate-45 transform -translate-x-1/2 -translate-y-1/2 absolute top-0"></div>
</div>
</div>
</div>
</div>
</>
) : (() => {
const st = subTabs.find(s => s.id === activeTab);
if (!st) return null;
const subTrack = tracks.find(t => t.id === st.trackId);
const vTrack = subTrack ? { ...subTrack, buffer: st.buffer, isSubTab: true } : null;
const subTabTimelineWidth = Math.max(zoom * (st.buffer ? st.buffer.duration : 0), viewportWidth);
return (
<>
{/* ══ TCP PANEL (sub-tab, single track) ══ */}
<div className="w-[300px] shrink-0 z-30 bg-[#262626] overflow-y-scroll no-scrollbar flex flex-col border-r border-zinc-900"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
<div className="sticky top-0 z-50 flex items-center h-10 border-b border-zinc-900 bg-[#242424] px-2 justify-between shrink-0">
<span className="text-[10px] font-bold text-zinc-500 uppercase">Sub-Tab</span>
<button onClick={() => closeSubTab(st.id)}
className="px-1.5 py-0.5 text-[9px] bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded border border-zinc-700 flex items-center gap-1">
<i data-lucide="x" className="w-3 h-3"></i> Close
</button>
</div>
{vTrack ? (
<div key={vTrack.id} className="flex-1 flex flex-col p-2.5 bg-[#1e1e1e] border-r border-zinc-900 border-l-4 border-l-cyan-500">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<label onClick={e => { e.stopPropagation(); const el = e.currentTarget.querySelector('input'); if (el) el.click(); }} className="cursor-pointer">
<input type="color" value={vTrack.color || '#0f766e'} onChange={e => { e.stopPropagation(); updateTrackColor(vTrack.id, e.target.value); }} className="w-0 h-0 opacity-0 absolute pointer-events-none" />
<div className="w-3 h-3 rounded-full border border-zinc-600 hover:scale-110 transition-transform" style={{ backgroundColor: vTrack.color }} />
</label>
{editingTrackName === vTrack.id ? (
<input type="text" value={editNameInput} autoFocus
onChange={e => setEditNameInput(e.target.value)}
onBlur={() => { updateTrackName(vTrack.id, editNameInput || vTrack.name); setEditingTrackName(null); }}
onKeyDown={e => { if (e.key === 'Enter') { updateTrackName(vTrack.id, editNameInput || vTrack.name); setEditingTrackName(null); } if (e.key === 'Escape') setEditingTrackName(null); }}
onClick={e => e.stopPropagation()}
className="text-xs font-semibold bg-black text-zinc-200 border border-cyan-500 rounded px-1 py-0 w-24 outline-none" />
) : (
<span className="text-xs font-semibold text-zinc-300 truncate max-w-[80px] cursor-text hover:text-cyan-300 transition-colors"
title="Click to rename"
onClick={e => { e.stopPropagation(); setEditingTrackName(vTrack.id); setEditNameInput(vTrack.name); }}>
{vTrack.name}
</span>
)}
</div>
<div className="flex items-center gap-1">
<button onClick={e => { e.stopPropagation(); toggleTrackMute(vTrack.id); }}
className={`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border ${vTrack.muted ? 'bg-red-950 text-red-400 border-red-700' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`}>M</button>
<button onClick={e => { e.stopPropagation(); toggleTrackSoloEvaluate(vTrack.id); }}
className={`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border ${soloedTrackId === vTrack.id || vTrack.solo ? 'bg-amber-950 text-amber-400 border-amber-600' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`}>S</button>
</div>
</div>
<div className="flex flex-col gap-2.5 text-[14px] mb-3">
<div className="flex items-center gap-1">
<span className="w-10 text-right text-zinc-500 text-[14px]">Vol:</span>
<input type="range" min="-50" max="7" step="0.5" id={`tcp-vol-${st.id}`} value={vTrack.volumeDb ?? 0} onChange={e => updateTrackVolumeDb(vTrack.id, parseFloat(e.target.value))} className="flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-cyan-500" style={{ height: '4px' }} />
<span className="w-16 text-right font-mono text-zinc-300 text-[14px]" id={`tcp-vol-label-${st.id}`}>{vTrack.volumeDb ?? 0}dB</span>
</div>
<div className="flex items-center gap-1">
<span className="w-10 text-right text-zinc-500 text-[14px]">Pan:</span>
<input type="range" min="-100" max="100" step="1" id={`tcp-pan-${st.id}`} value={vTrack.pan ?? 0} onChange={e => updateTrackPan(vTrack.id, parseInt(e.target.value))} className="flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-purple-500" style={{ height: '4px' }} />
<span className="w-16 text-right font-mono text-zinc-300 text-[14px]" id={`tcp-pan-label-detailed-${st.id}`}>{vTrack.pan > 0 ? 'R' + vTrack.pan : vTrack.pan < 0 ? 'L' + Math.abs(vTrack.pan) : 'C'}</span>
</div>
<div className="flex items-center gap-1.5 text-[14px]">
<button onClick={() => setSubTabs(prev => prev.map(s => s.id === st.id ? {...s, isLooping: !s.isLooping} : s))}
className={`px-2 py-0.5 text-[14px] rounded border font-bold ${st.isLooping ? 'bg-amber-700 text-white border-amber-500' : 'bg-zinc-800 text-zinc-400 border-zinc-700'}`}><i data-lucide="repeat" className="w-3.5 h-3.5"></i></button>
<button onClick={() => updateSubTabEffects(st.id, { reverse: !((st.effects || {}).reverse) })}
className={`px-2 py-0.5 rounded border text-[14px] font-bold ${(st.effects || {}).reverse ? 'bg-zinc-600 text-white border-zinc-500' : 'bg-zinc-800 text-zinc-400 border-zinc-700'} flex items-center justify-center`}
title="Reverse">
<i data-lucide="arrow-left-right" className="w-3.5 h-3.5"></i>
</button>
<span className="w-10 text-right text-zinc-500 text-[14px]">Loop:</span>
<input type="number" min="0" max="999" value={st.loopCount || 0} onChange={e => setSubTabs(prev => prev.map(s => s.id === st.id ? {...s, loopCount: Math.max(0, parseInt(e.target.value) || 0)} : s))} className="w-12 bg-black text-amber-300 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" title="Loop count" />
</div>
</div>
<div className="flex gap-1 justify-between my-2.5">
{/* Normalize Column */}
<div className="flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1">
<span className="text-[14px] font-bold text-zinc-400 uppercase tracking-tighter" title="Normalize">Norm</span>
<input type="range" min="-12" max="0" step="0.1" value={subTabNormVal} onChange={e => setSubTabNormVal(parseFloat(e.target.value))} style={{ writingMode: 'vertical-lr', direction: 'rtl', height: '120px', width: '20px', accentColor: '#a1a1aa' }} className="my-2 cursor-pointer" />
<span className="text-[14px] font-mono text-zinc-300 font-bold">{subTabNormVal}dB</span>
<button onClick={() => applySubTabEffect(st.id, 'normalize', subTabNormVal)} className="mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650">Apply</button>
</div>
{/* Pitch Shift Column */}
<div className="flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1">
<span className="text-[14px] font-bold text-zinc-400 uppercase tracking-tighter" title="Pitch Shift">Pitch</span>
<input type="range" min="-12" max="12" step="0.5" value={subTabPitchVal} onChange={e => setSubTabPitchVal(parseFloat(e.target.value))} style={{ writingMode: 'vertical-lr', direction: 'rtl', height: '120px', width: '20px', accentColor: '#a1a1aa' }} className="my-2 cursor-pointer" />
<span className="text-[14px] font-mono text-zinc-300 font-bold">{subTabPitchVal > 0 ? '+' : ''}{subTabPitchVal}st</span>
<button onClick={() => applySubTabEffect(st.id, 'pitch', subTabPitchVal)} className="mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650">Apply</button>
</div>
{/* Gain Column */}
<div className="flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1">
<span className="text-[14px] font-bold text-zinc-400 uppercase tracking-tighter" title="Gain Multiplier">Gain</span>
<input type="range" min="0" max="150" step="1" value={subTabGainVal} onChange={e => setSubTabGainVal(parseInt(e.target.value))} style={{ writingMode: 'vertical-lr', direction: 'rtl', height: '120px', width: '20px', accentColor: '#a1a1aa' }} className="my-2 cursor-pointer" />
<span className="text-[14px] font-mono text-zinc-300 font-bold">{subTabGainVal}%</span>
<button onClick={() => applySubTabEffect(st.id, 'gain', subTabGainVal)} className="mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650">Apply</button>
</div>
</div>
<div className="flex-1"></div>
<div className="mt-auto pt-2.5 border-t border-zinc-800">
<div className="flex items-center justify-between text-[14px] text-zinc-500 mb-1 font-semibold uppercase">
<span>Duration:</span>
<span className="font-mono text-zinc-300">{st.buffer ? formatTime(st.buffer.duration) : '0s'}</span>
</div>
<div className="flex items-center justify-between text-[14px] text-zinc-500 mb-2.5 font-semibold uppercase">
<span>SR:</span>
<span className="font-mono text-zinc-300">{st.buffer ? st.buffer.sampleRate : 0} Hz</span>
</div>
<button onClick={() => exportSubTabBuffer(st.id)}
className="w-full py-1.5 mb-1.5 bg-blue-700 hover:bg-blue-600 text-white font-bold rounded text-[14px] flex items-center justify-center gap-1 transition">
<i data-lucide="download" className="w-4 h-4"></i> Export
</button>
<button onClick={() => applySubTab(st.id)}
className="w-full py-1.5 bg-emerald-700 hover:bg-emerald-600 text-white font-bold rounded text-[14px] flex items-center justify-center gap-1 transition">
<i data-lucide="save" className="w-4 h-4"></i> Save
</button>
</div>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-[10px] text-zinc-500">Track not found</div>
)}
</div>
{/* ══ TIMELINE (sub-tab, single track) ══ */}
<div ref={timelineWrapperRef} className="flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0">
<div style={{ width: `${subTabTimelineWidth}px` }} className="relative flex flex-col min-h-full">
<div className="sticky top-0 z-45 flex h-10 border-b border-zinc-900 bg-[#242424] shrink-0">
<div className="flex-1 relative h-full flex items-center select-none cursor-ew-resize overflow-hidden" onMouseDown={handleRulerMouseDown}>
{Array.from({ length: Math.ceil(st.buffer ? st.buffer.duration : 0) }).map((_, i) => {
const sec = i; const x = sec * zoom;
return (<div key={i} className="absolute h-full border-l border-zinc-700 pl-1 pt-1 text-[14px] font-mono text-zinc-300 pointer-events-none" style={{ left: `${x}px` }}>{formatTimeSimple(sec)}</div>);
})}
</div>
</div>
<div className="sticky top-10 z-35 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] shrink-0">
<div className="flex-1 relative h-full overflow-hidden bg-[#1a1a2e]">
<TempoTrackLane bpm={parseInt(bpm) || 120} zoom={zoom} timelineWidth={subTabTimelineWidth}
onPlayheadSet={setCurrentTime} snapValue={snapValue} />
</div>
</div>
<div className="flex-1 flex flex-col relative bg-[#111111] min-h-full">
{vTrack && (
<div style={{ height: `${subTabHeight}px` }} className="relative overflow-hidden border-2 border-purple-500/40 rounded bg-[#151515] mx-1 mt-1 shrink-0">
<SubTabWaveform
buffer={st.buffer}
subTabId={st.id}
activeTab={activeTab}
activeTool={activeTool}
currentTime={st.currentTime}
selectionStart={st.selectionStart}
selectionEnd={st.selectionEnd}
onSelectRange={(start, end) => setSubTabs(prev => prev.map(s => s.id === st.id ? {...s, selectionStart: start, selectionEnd: end} : s))}
onPlayheadSet={(time) => setSubTabs(prev => prev.map(s => s.id === st.id ? {...s, currentTime: time} : s))}
onContextMenu={(e, clickTime) => setContextMenu({x: e.clientX, y: e.clientY, isSubTab: true, subTabId: st.id, time: clickTime})}
zoom={zoom}
timelineWidth={subTabTimelineWidth}
color={vTrack.color}
name={vTrack.name}
speed={st.speed || 1.0}
volumeNodes={st.volumeNodes || []}
panningNodes={st.panningNodes || []}
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 => {
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;
const oldSpeed = activePlaybackSpeedRef.current;
const ratio = oldSpeed / newSpeed;
startBufferOffsetRef.current = startBufferOffsetRef.current + elapsed * oldSpeed;
startOffsetTimeRef.current = (startOffsetTimeRef.current + elapsed) * ratio;
startAudioTimeRef.current = ctx.currentTime;
activePlaybackSpeedRef.current = newSpeed;}}
/>
<div onMouseDown={handleSubTabResizeMouseDown}
className="absolute bottom-0 left-0 right-0 h-1.5 cursor-ns-resize z-30 hover:bg-purple-500/50 transition-colors" />
</div>
)}
{st.selectionStart !== null && st.selectionEnd !== null && st.selectionEnd > st.selectionStart && (
<div className="absolute top-0 bottom-0 border-l border-r border-amber-500 bg-amber-500/10 z-20 pointer-events-none"
style={{ left: `${Math.min(st.selectionStart, st.selectionEnd) * zoom}px`, width: `${Math.abs(st.selectionEnd - st.selectionStart) * zoom}px` }} />
)}
</div>
</div>
</div>
</>
);
})()}
</div>
</div>
{renderDock('right', 'Right')}
</div>
{renderDock('bottom', 'Bottom')}
</div>
);
})()}
{/* ── Status Bar ── */}
<div className="h-6 bg-[#141414] border-t border-zinc-900 px-4 flex items-center justify-between text-[10px] text-zinc-500 select-none shrink-0">
<div className="flex items-center gap-4">
<span>Status: {isPlaying ? 'Playing' : 'Stopped'}</span>
{activeTab !== 'main' && <span className="text-amber-400 font-semibold uppercase">Sub-Tab</span>}
{activeTab === 'main' && <span className="text-cyan-400 font-semibold uppercase">Track: ID {selectedTrackId}</span>}
{selectionMode === 'local' && <span className="text-amber-400 font-semibold uppercase text-[9px]">Local Sel</span>}
{selectionMode === 'global' && <span className="text-purple-400 font-semibold uppercase text-[9px]">Global Sel</span>}
{soloedTrackId && <span className="text-amber-500 font-semibold">Solo: ID {soloedTrackId}</span>}
{isLoopingSelection && selectionMode === 'local' && <span className="text-emerald-400 font-semibold uppercase text-[9px]">Solo Loop</span>}
{isLoopingSelection && selectionMode !== 'local' && <span className="text-cyan-400 font-semibold uppercase text-[9px]">Master Loop</span>}
</div>
<div className="flex items-center gap-2">
<button onClick={() => setShowExportPanel(p => !p)}
className={`px-1.5 py-0.5 rounded text-[9px] font-semibold transition flex items-center gap-1 ${showExportPanel ? 'bg-cyan-900 text-cyan-300' : 'text-zinc-500 hover:text-zinc-300'}`}
title={`Export Panel (${panelPositions.export})`}>
<i data-lucide="save" className="w-3 h-3"></i>
<span className="text-[7px] opacity-60">{showExportPanel ? panelPositions.export[0].toUpperCase() : ''}</span>
</button>
<button onClick={() => setShowSelectionPanel(p => !p)}
className={`px-1.5 py-0.5 rounded text-[9px] font-semibold transition flex items-center gap-1 ${showSelectionPanel ? 'bg-amber-900 text-amber-300' : 'text-zinc-500 hover:text-zinc-300'}`}
title={`Selection Panel (${panelPositions.selection})`}>
<i data-lucide="sliders" className="w-3 h-3"></i>
<span className="text-[7px] opacity-60">{showSelectionPanel ? panelPositions.selection[0].toUpperCase() : ''}</span>
</button>
<button onClick={() => setShowAIPanel(p => !p)}
className={`px-1.5 py-0.5 rounded text-[9px] font-semibold transition flex items-center gap-1 ${showAIPanel ? 'bg-purple-900 text-purple-300' : 'text-zinc-500 hover:text-zinc-300'}`}
title={`AI Panel (${panelPositions.ai})`}>
<i data-lucide="cpu" className="w-3 h-3"></i>
<span className="text-[7px] opacity-60">{showAIPanel ? panelPositions.ai[0].toUpperCase() : ''}</span>
</button>
<span className="w-[1px] h-3 bg-zinc-800 mx-1"></span>
<span className="flex items-center gap-1">
<i data-lucide="info" className="w-3 h-3 text-zinc-600"></i> Scroll: Zoom
</span>
<span>|</span>
<span className="flex items-center gap-1">
<i data-lucide="keyboard" className="w-3 h-3 text-zinc-600"></i> Ctrl+Scroll: Playhead
</span>
</div>
</div>
{/* ── Context Menu ── */}
{contextMenu && (
contextMenu.isSubTab ? (
<div className="fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64" style={{ left: contextMenu.x, top: contextMenu.y }}
onClick={(e) => e.stopPropagation()}>
<div className="px-3 py-1 text-[10px] text-zinc-500 font-bold uppercase border-b border-zinc-800 pb-1 mb-1">
Selection: {formatTime(subTabs.find(s => s.id === contextMenu.subTabId)?.selectionStart || 0)} - {formatTime(subTabs.find(s => s.id === contextMenu.subTabId)?.selectionEnd || 0)}
</div>
<div className="h-px bg-zinc-700 my-1"></div>
<button onClick={() => { handleSubTabCut(contextMenu.subTabId); closeContextMenu(); }} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
<i data-lucide="scissors" className="w-3.5 h-3.5 text-rose-400 shrink-0"></i>
<span className="flex-1">Cut</span>
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto">Ctrl+X</span>
</button>
<button onClick={() => { handleSubTabCopy(contextMenu.subTabId); closeContextMenu(); }} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
<i data-lucide="copy" className="w-3.5 h-3.5 text-zinc-400 shrink-0"></i>
<span className="flex-1">Copy</span>
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto">Ctrl+C</span>
</button>
<button onClick={() => { handleSubTabPaste(contextMenu.subTabId); closeContextMenu(); }} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
<i data-lucide="clipboard" className="w-3.5 h-3.5 text-emerald-400 shrink-0"></i>
<span className="flex-1">Paste</span>
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto">Ctrl+V</span>
</button>
<button onClick={() => { handleSubTabDelete(contextMenu.subTabId); closeContextMenu(); }} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
<i data-lucide="trash-2" className="w-3.5 h-3.5 text-red-400 shrink-0"></i>
<span className="flex-1">Delete Selected Segment</span>
<span className="text-amber-400 text-[12px] font-semibold font-mono ml-auto">Del</span>
</button>
<div className="h-px bg-zinc-700 my-1"></div>
<button onClick={() => { handleSubTabLoop(contextMenu.subTabId, 4); closeContextMenu(); }} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
<i data-lucide="repeat" className="w-3.5 h-3.5 text-cyan-400 shrink-0"></i>
<span className="flex-1">Loop Selection 4 times</span>
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto">Ctrl+L</span>
</button>
</div>
) : (
<div className="fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64" style={{ left: contextMenu.x, top: contextMenu.y }}
onClick={(e) => e.stopPropagation()}>
<button onClick={contextMenuEdit} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
<i data-lucide="file-edit" className="w-3.5 h-3.5 text-amber-400 shrink-0"></i>
<span className="flex-1">Edit</span>
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">Ctrl+E</span>
</button>
<button onClick={contextMenuSplit} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
<i data-lucide="scissors" className="w-3.5 h-3.5 text-cyan-400 shrink-0"></i>
<span className="flex-1">Split</span>
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">S</span>
</button>
<button onClick={contextMenuMerge} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
<i data-lucide="combine" className="w-3.5 h-3.5 text-purple-400 shrink-0"></i>
<span className="flex-1">Merge</span>
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">Ctrl+M</span>
</button>
<div className="h-px bg-zinc-700 my-1"></div>
<button onClick={contextMenuCopy} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
<i data-lucide="copy" className="w-3.5 h-3.5 text-zinc-400 shrink-0"></i>
<span className="flex-1">Copy</span>
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">Ctrl+C</span>
</button>
<button onClick={contextMenuCut} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
<i data-lucide="scissors" className="w-3.5 h-3.5 text-rose-400 shrink-0"></i>
<span className="flex-1">Cut</span>
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">Ctrl+X</span>
</button>
<button onClick={contextMenuPaste} className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2">
<i data-lucide="clipboard" className="w-3.5 h-3.5 text-emerald-400 shrink-0"></i>
<span className="flex-1">Paste</span>
<span className="text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8">Ctrl+V</span>
</button>
<div className="h-px bg-zinc-700 my-1"></div>
<button onClick={contextMenuDelete} className="w-full px-3 py-1.5 text-xs text-red-300 hover:bg-red-950 text-left flex items-center gap-2">
<i data-lucide="trash-2" className="w-3.5 h-3.5 text-red-400 shrink-0"></i>
<span className="flex-1">Delete</span>
<span className="text-amber-400 text-[12px] font-semibold font-mono ml-auto pl-8">Del</span>
</button>
</div>
)
)}
{/* ── Toast ── */}
{toastMessage && (
<div className="absolute top-14 right-4 z-50 px-4 py-2.5 rounded shadow-lg text-xs font-semibold flex items-center gap-2 border bg-zinc-900 text-zinc-100 border-zinc-800">
<i data-lucide="bell" className={`w-4 h-4 ${
toastMessage.type === 'success' ? 'text-emerald-400' :
toastMessage.type === 'error' ? 'text-rose-400' :
toastMessage.type === 'warning' ? 'text-amber-400' : 'text-cyan-400'
}`}></i>
{toastMessage.text}
</div>
)}
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
setTimeout(() => lucide.createIcons(), 300);
</script>
</body>
</html>