9819 lines
403 KiB
React
9819 lines
403 KiB
React
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`;
|
|
|
|
// Handle ?sfs=<encoded> from double-clicking a .sfs file (opens domain -> loads project)
|
|
(function handleSfsDeepLink() {
|
|
try {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const sfsParam = params.get('sfs');
|
|
if (!sfsParam) return;
|
|
const decoded = JSON.parse(decodeURIComponent(sfsParam));
|
|
window.__pendingSfsProject = decoded; // consumed after auth in App
|
|
if (window.history.replaceState) {
|
|
window.history.replaceState({}, document.title, window.location.pathname);
|
|
}
|
|
} catch (e) {
|
|
window.__pendingSfsProject = null;
|
|
}
|
|
})();
|
|
|
|
// 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 (window.SonicAudio && window.SonicAudio.initAudioWorklet) {
|
|
window.SonicAudio.initAudioWorklet();
|
|
}
|
|
}
|
|
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 /*#__PURE__*/React.createElement("div", {
|
|
className: "knob-container cursor-ns-resize flex flex-col items-center",
|
|
onMouseDown: handleMouseDown,
|
|
title: `Volume: ${Math.round(value * 100)}%`
|
|
}, /*#__PURE__*/React.createElement("svg", {
|
|
className: "w-7 h-7",
|
|
viewBox: "0 0 40 40"
|
|
}, /*#__PURE__*/React.createElement("circle", {
|
|
cx: "20",
|
|
cy: "20",
|
|
r: "16",
|
|
fill: "#141414",
|
|
stroke: "#444",
|
|
strokeWidth: "2"
|
|
}), /*#__PURE__*/React.createElement("g", {
|
|
transform: `rotate(${rotation} 20 20)`,
|
|
className: "knob-dial"
|
|
}, /*#__PURE__*/React.createElement("line", {
|
|
x1: "20",
|
|
y1: "20",
|
|
x2: "20",
|
|
y2: "6",
|
|
stroke: "#ef4444",
|
|
strokeWidth: "3",
|
|
strokeLinecap: "round"
|
|
}))));
|
|
};
|
|
const WaveformLane = ({
|
|
track,
|
|
zoom,
|
|
timelineWidth,
|
|
viewportWidth,
|
|
onSelectRange,
|
|
onPlayheadSet,
|
|
isSelected,
|
|
onSelectTrack,
|
|
markers,
|
|
selectionMode,
|
|
localSelectionTrackId,
|
|
localSelectionStart,
|
|
currentTime,
|
|
getLocalAnchor,
|
|
onClearLocalSelection,
|
|
onSetSelectionMode,
|
|
onSetSelectionStart,
|
|
onSetSelectionEnd,
|
|
onSetCurrentTime,
|
|
onSetLocalSelectionTrackId,
|
|
onSetLocalSelectionStart,
|
|
onSetLocalSelectionEnd,
|
|
localSelLeft,
|
|
localSelRight,
|
|
onTrackLaneMouseDown,
|
|
onContextMenu,
|
|
onClipDragStart,
|
|
onClipStretchStart,
|
|
onSelectionEdgeDragStart,
|
|
setSelectedClipId,
|
|
selectedClipId,
|
|
activeTool,
|
|
onSplitTrackAtTime,
|
|
onEditClipInSubTab,
|
|
snapValue,
|
|
bpm,
|
|
scrollLeft
|
|
}) => {
|
|
const canvasRef = useRef(null);
|
|
const drawWidth = Math.min(timelineWidth, viewportWidth);
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext('2d');
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const scrollLeftVal = scrollLeft || 0;
|
|
const vWidth = viewportWidth || 1200;
|
|
const height = canvas.parentElement ? canvas.parentElement.clientHeight : 96;
|
|
canvas.width = Math.min(Math.round(drawWidth * dpr), 32768);
|
|
canvas.height = Math.min(Math.round(height * dpr), 32768);
|
|
ctx.scale(dpr, dpr);
|
|
ctx.imageSmoothingEnabled = false;
|
|
canvas.style.width = `${drawWidth}px`;
|
|
canvas.style.height = `${height}px`;
|
|
ctx.fillStyle = isSelected ? '#2a2a2a' : track.id % 2 === 0 ? '#181818' : '#1d1d1d';
|
|
ctx.fillRect(0, 0, drawWidth, height);
|
|
|
|
// Grid lines based on Snap value
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)';
|
|
ctx.lineWidth = 1;
|
|
const tStart = scrollLeft / zoom;
|
|
const tEnd = (scrollLeft + drawWidth) / zoom;
|
|
let gridSpacing = 1.0;
|
|
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);
|
|
}
|
|
let drawSpacing = gridSpacing;
|
|
while (drawSpacing * zoom < 10) {
|
|
drawSpacing *= 2;
|
|
}
|
|
const firstGridStep = Math.floor(tStart / drawSpacing) * drawSpacing;
|
|
for (let s = firstGridStep; s <= tEnd; s += drawSpacing) {
|
|
const localX = (s - tStart) * zoom;
|
|
ctx.beginPath();
|
|
ctx.moveTo(localX, 0);
|
|
ctx.lineTo(localX, 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 numChannels = clip.buffer.numberOfChannels || 1;
|
|
const dataL = clip.buffer.getChannelData(0);
|
|
const dataR = numChannels >= 2 ? clip.buffer.getChannelData(1) : dataL;
|
|
const sampleRate = clip.buffer.sampleRate;
|
|
const totalSamples = dataL.length;
|
|
const originalDuration = totalSamples / sampleRate;
|
|
const clipSpeed = clip.speed || 1.0;
|
|
const duration = originalDuration / clipSpeed;
|
|
const clipStartTime = clip.startTime || 0;
|
|
const clipEndTime = clipStartTime + duration;
|
|
|
|
// Culling: Skip rendering if clip is outside visible viewport window
|
|
if (clipEndTime < tStart || clipStartTime > tEnd) return;
|
|
const xStartGlobal = clipStartTime * zoom;
|
|
const wClip = duration * zoom;
|
|
const xStartLocal = xStartGlobal - scrollLeft;
|
|
const xEndLocal = xStartLocal + wClip;
|
|
|
|
// 1. Draw Clip Layer Background & Border
|
|
const clipIdentifier = clip.id === 'default' ? 'default_' + track.id : clip.id;
|
|
const isClipSelected = selectedClipId && selectedClipId.trackId === track.id && selectedClipId.clipId === clipIdentifier;
|
|
ctx.fillStyle = isClipSelected ? track.color ? track.color + '44' : 'rgba(6, 182, 212, 0.30)' : track.color ? track.color + '22' : 'rgba(6, 182, 212, 0.12)';
|
|
ctx.strokeStyle = isClipSelected ? '#fbbf24' : track.color || '#06b6d4';
|
|
ctx.lineWidth = isClipSelected ? 1 : 1.5;
|
|
const clipTop = 4;
|
|
const clipHeight = height - 8;
|
|
ctx.beginPath();
|
|
if (ctx.roundRect) {
|
|
ctx.roundRect(xStartLocal, clipTop, wClip, clipHeight, 4);
|
|
} else {
|
|
ctx.rect(xStartLocal, clipTop, wClip, clipHeight);
|
|
}
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
|
|
// 2. Draw Clip Label & Speed Label
|
|
ctx.fillStyle = '#e4e4e7';
|
|
ctx.font = 'bold 9px sans-serif';
|
|
ctx.fillText(clip.name || 'Clip', Math.max(xStartLocal + 8, 8), clipTop + 12);
|
|
if (clipSpeed !== 1.0) {
|
|
ctx.fillStyle = '#fbbf24';
|
|
ctx.font = 'bold 8px sans-serif';
|
|
ctx.fillText(`Speed: ${(clipSpeed * 100).toFixed(1)}%`, Math.max(xStartLocal + 8, 8), clipTop + 22);
|
|
}
|
|
|
|
// Draw markers
|
|
if (markers && markers.length > 0) {
|
|
markers.forEach(m => {
|
|
const mxLocal = m.time * zoom - scrollLeft;
|
|
if (mxLocal >= 0 && mxLocal <= drawWidth) {
|
|
ctx.fillStyle = '#fbbf24';
|
|
ctx.fillRect(mxLocal - 1, 0, 2, height);
|
|
}
|
|
});
|
|
}
|
|
|
|
// 3. Peak / Vector Waveform Drawing (Sound Forge Dual Stereo Channel Split Match)
|
|
const drawXStartLocal = Math.max(0, Math.floor(xStartLocal));
|
|
const drawXEndLocal = Math.min(drawWidth, Math.ceil(xEndLocal));
|
|
const samplesPerPixel = sampleRate / zoom * clipSpeed;
|
|
const isStereo = numChannels >= 2;
|
|
const channelConfigs = isStereo ? [{
|
|
data: dataL,
|
|
mid: height / 4,
|
|
chHeight: height / 2 - 8,
|
|
label: '1'
|
|
}, {
|
|
data: dataR,
|
|
mid: 3 * height / 4,
|
|
chHeight: height / 2 - 8,
|
|
label: '2'
|
|
}] : [{
|
|
data: dataL,
|
|
mid: height / 2,
|
|
chHeight: clipHeight - 12,
|
|
label: 'MONO'
|
|
}];
|
|
if (isStereo) {
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(xStartLocal, height / 2);
|
|
ctx.lineTo(xStartLocal + wClip, height / 2);
|
|
ctx.stroke();
|
|
}
|
|
channelConfigs.forEach(ch => {
|
|
const data = ch.data;
|
|
const mid = ch.mid;
|
|
const peakRatio = ch.chHeight * 0.42;
|
|
|
|
// Decibel Amplitude Grid Lines (+6.0dB, -Inf, -6.0dB)
|
|
const yPlus6 = mid - peakRatio * 0.8;
|
|
const yMinus6 = mid + peakRatio * 0.8;
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
|
|
ctx.lineWidth = 0.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(xStartLocal, yPlus6);
|
|
ctx.lineTo(xStartLocal + wClip, yPlus6);
|
|
ctx.stroke();
|
|
ctx.beginPath();
|
|
ctx.moveTo(xStartLocal, mid);
|
|
ctx.lineTo(xStartLocal + wClip, mid);
|
|
ctx.stroke();
|
|
ctx.beginPath();
|
|
ctx.moveTo(xStartLocal, yMinus6);
|
|
ctx.lineTo(xStartLocal + wClip, yMinus6);
|
|
ctx.stroke();
|
|
|
|
// Decibel Text Labels (Sound Forge Monospace)
|
|
ctx.fillStyle = '#71717a';
|
|
ctx.font = '8px monospace';
|
|
const labelX = Math.max(xStartLocal + 4, 4);
|
|
ctx.fillText('+6.0', labelX, yPlus6 - 2);
|
|
ctx.fillText('-Inf', labelX, mid - 2);
|
|
ctx.fillText('-6.0', labelX, yMinus6 + 8);
|
|
|
|
// Sound Forge Channel ID Badge (1 or 2)
|
|
if (isStereo) {
|
|
ctx.fillStyle = 'rgba(6, 182, 212, 0.85)';
|
|
ctx.font = 'bold 9px monospace';
|
|
ctx.fillText(ch.label, Math.min(xStartLocal + wClip - 12, drawWidth - 16), mid - ch.chHeight * 0.35);
|
|
}
|
|
ctx.strokeStyle = '#5bc0be'; // Cornflower Blue
|
|
ctx.lineWidth = 1.2;
|
|
if (samplesPerPixel < 4) {
|
|
// High-zoom continuous vector line rendering (#5bc0be Cornflower Blue)
|
|
const visibleTStart = (drawXStartLocal - xStartLocal) / zoom;
|
|
const visibleTEnd = (drawXEndLocal - xStartLocal) / zoom;
|
|
const startSample = Math.max(0, Math.floor(visibleTStart * clipSpeed * sampleRate));
|
|
const endSample = Math.min(totalSamples, Math.ceil(visibleTEnd * clipSpeed * sampleRate));
|
|
ctx.beginPath();
|
|
let first = true;
|
|
const maxSamples = 50000;
|
|
const vectorStep = Math.max(1, Math.floor((endSample - startSample) / maxSamples));
|
|
for (let i = startSample; i < endSample; i += vectorStep) {
|
|
const sampleTime = i / sampleRate / clipSpeed;
|
|
const pxLocal = xStartLocal + sampleTime * zoom;
|
|
const y = mid - data[i] * peakRatio;
|
|
if (first) {
|
|
ctx.moveTo(pxLocal, y);
|
|
first = false;
|
|
} else {
|
|
ctx.lineTo(pxLocal, y);
|
|
}
|
|
}
|
|
ctx.stroke();
|
|
|
|
// Draw granular sample nodes only at extreme zoom (samplesPerPixel < 0.3)
|
|
if (samplesPerPixel < 0.3) {
|
|
const maxNodes = 5000;
|
|
const step = Math.max(1, Math.floor((endSample - startSample) / maxNodes));
|
|
ctx.fillStyle = '#6ee7b7';
|
|
let drawn = 0;
|
|
for (let i = startSample; i < endSample && drawn < maxNodes; i += step) {
|
|
const sampleTime = i / sampleRate / clipSpeed;
|
|
const pxLocal = xStartLocal + sampleTime * zoom;
|
|
const y = mid - data[i] * peakRatio;
|
|
ctx.fillRect(pxLocal - 1, y - 1, 2, 2);
|
|
drawn++;
|
|
}
|
|
}
|
|
} else {
|
|
// Coarse view vertical peak min/max bars per pixel
|
|
for (let pxLocal = drawXStartLocal; pxLocal < drawXEndLocal; pxLocal++) {
|
|
const timeInClip = (pxLocal - xStartLocal) / 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 peakHeight = maxVal * peakRatio;
|
|
ctx.beginPath();
|
|
ctx.moveTo(pxLocal, mid - peakHeight);
|
|
ctx.lineTo(pxLocal, 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', drawWidth / 2, height / 2);
|
|
}
|
|
|
|
// Selection highlight - local selection on this track
|
|
if (selectionMode === 'local' && localSelectionTrackId === track.id && localSelLeft !== null && localSelRight !== null && localSelRight > localSelLeft) {
|
|
const hlLeftLocal = localSelLeft * zoom - scrollLeft;
|
|
const hlWidth = (localSelRight - localSelLeft) * zoom;
|
|
ctx.fillStyle = 'rgba(245, 158, 11, 0.15)';
|
|
ctx.fillRect(hlLeftLocal, 0, hlWidth, height);
|
|
ctx.strokeStyle = '#f59e0b';
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(hlLeftLocal, 0, hlWidth, height);
|
|
}
|
|
}, [track, zoom, timelineWidth, viewportWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight, snapValue, bpm, scrollLeft]);
|
|
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
|
|
key: "virtual-spacer",
|
|
style: {
|
|
width: `${timelineWidth}px`,
|
|
height: '1px',
|
|
pointerEvents: 'none'
|
|
}
|
|
}), /*#__PURE__*/React.createElement("canvas", {
|
|
ref: canvasRef,
|
|
style: {
|
|
position: 'sticky',
|
|
left: 0,
|
|
imageRendering: 'pixelated'
|
|
},
|
|
className: "cursor-crosshair",
|
|
onMouseMove: e => {
|
|
if (!canvasRef.current) return;
|
|
const rect = canvasRef.current.getBoundingClientRect();
|
|
const x = e.clientX - rect.left + (scrollLeft || 0);
|
|
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
|
|
}] : [];
|
|
|
|
// 1. Shift+Drag (TOP PRIORITY): Range selection on track waveform
|
|
if (e.shiftKey && e.buttons > 0) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const currentAnchor = getLocalAnchor ? getLocalAnchor() : null;
|
|
const anchor = currentAnchor !== null && currentAnchor !== undefined ? currentAnchor : localSelectionStart !== null ? localSelectionStart : currentTime;
|
|
const selS = Math.min(anchor, time);
|
|
const selE = Math.max(anchor, time);
|
|
if (onSetSelectionMode) onSetSelectionMode('local');
|
|
if (onSetLocalSelectionTrackId) onSetLocalSelectionTrackId(track.id);
|
|
if (onSetLocalSelectionStart) onSetLocalSelectionStart(selS);
|
|
if (onSetLocalSelectionEnd) onSetLocalSelectionEnd(selE);
|
|
if (onSetSelectionStart) onSetSelectionStart(selS);
|
|
if (onSetSelectionEnd) onSetSelectionEnd(selE);
|
|
if (onSetCurrentTime) onSetCurrentTime(time);
|
|
if (onSelectTrack) onSelectTrack(track.id);
|
|
return;
|
|
}
|
|
|
|
// 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 x = e.clientX - rect.left + (scrollLeft || 0);
|
|
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
|
|
}] : [];
|
|
|
|
// 1. Shift+Click (TOP PRIORITY): Range selection on track waveform
|
|
if (e.shiftKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const currentAnchor = getLocalAnchor ? getLocalAnchor() : null;
|
|
const anchor = currentAnchor !== null && currentAnchor !== undefined ? currentAnchor : localSelectionStart !== null ? localSelectionStart : currentTime;
|
|
const selS = Math.min(anchor, time);
|
|
const selE = Math.max(anchor, time);
|
|
if (onSetSelectionMode) onSetSelectionMode('local');
|
|
if (onSetLocalSelectionTrackId) onSetLocalSelectionTrackId(track.id);
|
|
if (onSetLocalSelectionStart) onSetLocalSelectionStart(selS);
|
|
if (onSetLocalSelectionEnd) onSetLocalSelectionEnd(selE);
|
|
if (onSetSelectionStart) onSetSelectionStart(selS);
|
|
if (onSetSelectionEnd) onSetSelectionEnd(selE);
|
|
if (onSetCurrentTime) onSetCurrentTime(time);
|
|
if (onSelectTrack) onSelectTrack(track.id);
|
|
return;
|
|
}
|
|
|
|
// 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' && !e.ctrlKey) {
|
|
if (clickedClip) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (onEditClipInSubTab) {
|
|
onEditClipInSubTab(track.id, clickedClip.id);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (activeTool === 'razor' && !e.ctrlKey) {
|
|
if (clickedClip) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (onSplitTrackAtTime) {
|
|
onSplitTrackAtTime(track.id, clickedClip.id, time);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (activeTool === 'grab') {
|
|
if (onTrackLaneMouseDown) {
|
|
onTrackLaneMouseDown(track.id, time, e);
|
|
}
|
|
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) {
|
|
if (onClearLocalSelection) onClearLocalSelection();
|
|
if (onSetSelectionMode) onSetSelectionMode(null);
|
|
if (onSetSelectionStart) onSetSelectionStart(null);
|
|
if (onSetSelectionEnd) onSetSelectionEnd(null);
|
|
return;
|
|
}
|
|
onPlayheadSet(time);
|
|
if (onTrackLaneMouseDown) {
|
|
onTrackLaneMouseDown(track.id, time, e);
|
|
}
|
|
e.stopPropagation();
|
|
},
|
|
onDoubleClick: e => {
|
|
const rect = canvasRef.current.getBoundingClientRect();
|
|
const x = e.clientX - rect.left + (scrollLeft || 0);
|
|
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 x = e.clientX - rect.left + (scrollLeft || 0);
|
|
const time = Math.max(0, x / zoom);
|
|
if (onContextMenu) onContextMenu(e, track.id, time);
|
|
}
|
|
}));
|
|
};
|
|
const TempoTrackLane = ({
|
|
bpm,
|
|
zoom,
|
|
timelineWidth,
|
|
viewportWidth,
|
|
onPlayheadSet,
|
|
snapValue,
|
|
onRulerMouseDown,
|
|
scrollLeft
|
|
}) => {
|
|
const canvasRef = useRef(null);
|
|
const drawWidth = Math.min(timelineWidth, viewportWidth);
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext('2d');
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const scrollLeftVal = scrollLeft || 0;
|
|
const height = canvas.parentElement ? canvas.parentElement.clientHeight : 40;
|
|
canvas.width = Math.min(Math.round(drawWidth * dpr), 32768);
|
|
canvas.height = Math.min(Math.round(height * dpr), 32768);
|
|
ctx.scale(dpr, dpr);
|
|
ctx.imageSmoothingEnabled = false;
|
|
canvas.style.width = `${drawWidth}px`;
|
|
canvas.style.height = `${height}px`;
|
|
ctx.fillStyle = '#1a1a2e';
|
|
ctx.fillRect(0, 0, drawWidth, height);
|
|
const beatDuration = 60 / bpm;
|
|
const barDuration = beatDuration * 4;
|
|
const tStart = scrollLeft / zoom;
|
|
const tEnd = (scrollLeft + drawWidth) / zoom;
|
|
const firstBeat = Math.floor(tStart / beatDuration) * beatDuration;
|
|
for (let t = firstBeat; t <= tEnd; t += beatDuration) {
|
|
const beatNum = Math.floor(t / beatDuration) + 1;
|
|
const isBar = beatNum % 4 === 1;
|
|
const localX = (t - tStart) * zoom;
|
|
if (isBar) {
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(localX, 0);
|
|
ctx.lineTo(localX, 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)}`, localX + 3, 11);
|
|
} else {
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(localX, 0);
|
|
ctx.lineTo(localX, 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) {
|
|
const firstSnap = Math.floor(tStart / snapInterval) * snapInterval;
|
|
for (let t = firstSnap; t <= tEnd; t += snapInterval) {
|
|
const onBeat = Math.abs(t / beatDuration - Math.round(t / beatDuration)) < 0.001;
|
|
if (!onBeat) {
|
|
const localX = (t - tStart) * zoom;
|
|
ctx.beginPath();
|
|
ctx.moveTo(localX, height - 6);
|
|
ctx.lineTo(localX, 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`, drawWidth - 6, 12);
|
|
}, [bpm, zoom, timelineWidth, viewportWidth, snapValue, scrollLeft]);
|
|
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
|
|
key: "virtual-spacer-tempo",
|
|
style: {
|
|
width: `${timelineWidth}px`,
|
|
height: '1px',
|
|
pointerEvents: 'none'
|
|
}
|
|
}), /*#__PURE__*/React.createElement("canvas", {
|
|
ref: canvasRef,
|
|
style: {
|
|
position: 'sticky',
|
|
left: 0,
|
|
imageRendering: 'pixelated'
|
|
},
|
|
className: "cursor-crosshair",
|
|
onMouseDown: e => {
|
|
const rect = canvasRef.current.getBoundingClientRect();
|
|
const x = e.clientX - rect.left + (scrollLeft || 0);
|
|
const time = Math.max(0, x / zoom);
|
|
if (e.shiftKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
if (onRulerMouseDown) {
|
|
onRulerMouseDown(e);
|
|
} else {
|
|
onPlayheadSet(time, e.shiftKey);
|
|
}
|
|
}
|
|
}));
|
|
};
|
|
|
|
// ── 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,
|
|
channelInfo = null
|
|
}) => {
|
|
const canvasRef = useRef(null);
|
|
const isStretchingRef = useRef(false);
|
|
const stretchStartRef = useRef({
|
|
mouseX: 0,
|
|
originalDuration: 0,
|
|
originalSpeed: 1.0
|
|
});
|
|
const subTabAnchorRef = useRef(null);
|
|
const isStereo = channelInfo ? channelInfo.isStereo : buffer && buffer.numberOfChannels >= 2;
|
|
const channelLabel = channelInfo ? channelInfo.label : isStereo ? 'STEREO' : 'MONO';
|
|
// Mono: force volume mode (panning not applicable)
|
|
const effectiveGraphMode = !isStereo && graphMode === 'pan' ? null : graphMode;
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas || !buffer) return;
|
|
const ctx = canvas.getContext('2d');
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null;
|
|
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
|
|
const vWidth = wrapper ? wrapper.clientWidth : 1200;
|
|
const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200));
|
|
const h = canvas.parentElement ? canvas.parentElement.clientHeight : 200;
|
|
canvas.width = Math.round(drawWidth * dpr);
|
|
canvas.height = Math.round(h * dpr);
|
|
ctx.scale(dpr, dpr);
|
|
ctx.imageSmoothingEnabled = false;
|
|
canvas.style.position = 'absolute';
|
|
canvas.style.left = `${scrollLeft}px`;
|
|
canvas.style.width = `${drawWidth}px`;
|
|
canvas.style.height = `${h}px`;
|
|
ctx.fillStyle = '#181818';
|
|
ctx.fillRect(0, 0, drawWidth, 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 = effectiveGraphMode === '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 = effectiveGraphMode === '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';
|
|
|
|
// Channel label (L / R for stereo, M for mono)
|
|
ctx.fillStyle = '#a1a1aa';
|
|
ctx.font = 'bold 8px monospace';
|
|
if (isStereo) {
|
|
ctx.fillText('L', xStart + 2, clipTop + clipHeight * 0.28);
|
|
ctx.fillText('R', xStart + 2, clipTop + clipHeight * 0.72);
|
|
} else {
|
|
ctx.fillText('M', xStart + 2, clipTop + clipHeight / 2);
|
|
}
|
|
|
|
// Draw waveform inside clip (speed-adjusted) with fade envelope applied
|
|
const drawXStart = Math.max(0, Math.floor(xStart));
|
|
const drawXEnd = Math.min(drawWidth, Math.ceil(xEnd));
|
|
const samplesPerPixel = buffer.sampleRate / zoom * speed;
|
|
const bufDur = buffer.duration;
|
|
const mid = h / 2;
|
|
const peakRatio = clipHeight * 0.45;
|
|
const getFadeGainAtTime = t => {
|
|
if (fadeInLen > 0 && t < fadeInLen) {
|
|
return (1 - Math.cos(Math.PI * t / fadeInLen)) / 2;
|
|
} else if (fadeOutLen > 0 && t > bufDur - fadeOutLen) {
|
|
const ratio = (t - (bufDur - fadeOutLen)) / fadeOutLen;
|
|
return (1 + Math.cos(Math.PI * ratio)) / 2;
|
|
}
|
|
return 1;
|
|
};
|
|
ctx.strokeStyle = '#5bc0be';
|
|
ctx.lineWidth = 1.2;
|
|
if (samplesPerPixel < 4) {
|
|
// High-zoom continuous vector line rendering (#5bc0be Cornflower Blue)
|
|
const startSample = Math.max(0, Math.floor((drawXStart - xStart) / zoom * speed * buffer.sampleRate));
|
|
const endSample = Math.min(len, Math.ceil((drawXEnd - xStart) / zoom * speed * buffer.sampleRate));
|
|
ctx.beginPath();
|
|
let first = true;
|
|
const maxSamples = 50000;
|
|
const vectorStep = Math.max(1, Math.floor((endSample - startSample) / maxSamples));
|
|
for (let i = startSample; i < endSample; i += vectorStep) {
|
|
const timeInClip = i / buffer.sampleRate / speed;
|
|
const px = xStart + timeInClip * zoom;
|
|
const fadeGain = getFadeGainAtTime(timeInClip);
|
|
const volGain = getVolumeGainAtTime(px / zoom);
|
|
const totalGain = fadeGain * volGain;
|
|
const y = mid - data[i] * totalGain * peakRatio;
|
|
if (first) {
|
|
ctx.moveTo(px, y);
|
|
first = false;
|
|
} else {
|
|
ctx.lineTo(px, y);
|
|
}
|
|
}
|
|
ctx.stroke();
|
|
|
|
// Draw granular sample nodes only at extreme zoom (samplesPerPixel < 0.3)
|
|
if (samplesPerPixel < 0.3) {
|
|
const maxNodes = 5000;
|
|
const step = Math.max(1, Math.floor((endSample - startSample) / maxNodes));
|
|
ctx.fillStyle = '#6ee7b7';
|
|
let drawn = 0;
|
|
for (let i = startSample; i < endSample && drawn < maxNodes; i += step) {
|
|
const timeInClip = i / buffer.sampleRate / speed;
|
|
const px = xStart + timeInClip * zoom;
|
|
const fadeGain = getFadeGainAtTime(timeInClip);
|
|
const volGain = getVolumeGainAtTime(px / zoom);
|
|
const totalGain = fadeGain * volGain;
|
|
const y = mid - data[i] * totalGain * peakRatio;
|
|
ctx.fillRect(px - 1, y - 1, 2, 2);
|
|
drawn++;
|
|
}
|
|
}
|
|
} else {
|
|
// Coarse view vertical peak min/max bars
|
|
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);
|
|
let fadeGain = getFadeGainAtTime(timeInClip);
|
|
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 * peakRatio;
|
|
const yBottom = mid + maxVal * peakRatio;
|
|
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 < drawWidth) {
|
|
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));
|
|
|
|
// Shift + Click range selection in SubTab Waveform
|
|
if (e.shiftKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const anchor = subTabAnchorRef.current !== null && subTabAnchorRef.current !== undefined ? subTabAnchorRef.current : selectionStart !== null && selectionStart !== undefined ? selectionStart : currentTime;
|
|
const selS = Math.min(anchor, startTime);
|
|
const selE = Math.max(anchor, startTime);
|
|
onSelectRange(selS, selE);
|
|
onPlayheadSet(startTime);
|
|
return;
|
|
}
|
|
|
|
// 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
|
|
subTabAnchorRef.current = startTime;
|
|
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));
|
|
const anchor = subTabAnchorRef.current ?? startTime;
|
|
onSelectRange(Math.min(anchor, ct), Math.max(anchor, 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 /*#__PURE__*/React.createElement("div", {
|
|
style: {
|
|
width: `${timelineWidth}px`,
|
|
height: '100%',
|
|
position: 'relative',
|
|
overflow: 'hidden'
|
|
}
|
|
}, /*#__PURE__*/React.createElement("canvas", {
|
|
ref: canvasRef,
|
|
style: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
imageRendering: 'pixelated'
|
|
},
|
|
className: "cursor-crosshair 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 /*#__PURE__*/React.createElement("div", {
|
|
className: "daw-header flex h-16 items-center px-4 border-b daw-border gap-3 bg-zinc-800"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center space-x-1 border-r border-zinc-700 pr-3"
|
|
}, /*#__PURE__*/React.createElement("button", {
|
|
className: `p-1 rounded ${activeTool === 'select' ? 'bg-cyan-700' : 'bg-zinc-700'}`,
|
|
onClick: () => setActiveTool('select'),
|
|
title: "Select Tool"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "mouse-pointer",
|
|
className: "w-4 h-4"
|
|
}))), /*#__PURE__*/React.createElement("button", {
|
|
className: `p-1 rounded ${activeTool === 'grab' ? 'bg-cyan-700' : 'bg-zinc-700'}`,
|
|
onClick: () => setActiveTool('grab'),
|
|
title: "Grab Tool"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "hand",
|
|
className: "w-4 h-4"
|
|
}))), /*#__PURE__*/React.createElement("button", {
|
|
className: `p-1 rounded ${activeTool === 'razor' ? 'bg-cyan-700' : 'bg-zinc-700'}`,
|
|
onClick: () => setActiveTool('razor'),
|
|
title: "Razor Tool"
|
|
}, /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("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"
|
|
}), /*#__PURE__*/React.createElement("path", {
|
|
d: "M4 9h16l-3 9H7z"
|
|
}), /*#__PURE__*/React.createElement("circle", {
|
|
cx: "12",
|
|
cy: "6",
|
|
r: "1"
|
|
}))), /*#__PURE__*/React.createElement("button", {
|
|
className: `p-1 rounded ${activeTool === 'pen' ? 'bg-cyan-700' : 'bg-zinc-700'}`,
|
|
onClick: () => setActiveTool('pen'),
|
|
title: "Pen Tool"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "pen-tool",
|
|
className: "w-4 h-4"
|
|
}))), /*#__PURE__*/React.createElement("div", {
|
|
className: "w-[1px] h-4 bg-zinc-800 mx-0.5"
|
|
}), /*#__PURE__*/React.createElement("button", {
|
|
onClick: onGlue,
|
|
className: "p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-purple-400 transition",
|
|
title: "Glue Clips"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "link",
|
|
className: "w-3.5 h-3.5"
|
|
}))), /*#__PURE__*/React.createElement("button", {
|
|
onClick: onCut,
|
|
className: "p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-red-400 transition",
|
|
title: "Cut (Ctrl+X)"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "scissors",
|
|
className: "w-3.5 h-3.5"
|
|
}))), /*#__PURE__*/React.createElement("button", {
|
|
onClick: onCopy,
|
|
className: "p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-blue-400 transition",
|
|
title: "Copy (Ctrl+C)"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "copy",
|
|
className: "w-3.5 h-3.5"
|
|
}))), /*#__PURE__*/React.createElement("button", {
|
|
onClick: onPaste,
|
|
className: "p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-emerald-400 transition",
|
|
title: "Paste (Ctrl+V)"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "clipboard",
|
|
className: "w-3.5 h-3.5"
|
|
})))), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center gap-1 border-r border-zinc-700 pr-3"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "text-xs text-zinc-500 font-bold uppercase"
|
|
}, "Snap"), /*#__PURE__*/React.createElement("select", {
|
|
value: snapValue || 'free',
|
|
onChange: e => onSnapChange(e.target.value),
|
|
className: "bg-zinc-850 text-zinc-300 text-xs px-1 py-0.5 rounded border border-zinc-800 focus:outline-none font-mono"
|
|
}, /*#__PURE__*/React.createElement("option", {
|
|
value: "free"
|
|
}, "Free"), /*#__PURE__*/React.createElement("option", {
|
|
value: "1"
|
|
}, "1"), /*#__PURE__*/React.createElement("option", {
|
|
value: "1/2"
|
|
}, "1/2"), /*#__PURE__*/React.createElement("option", {
|
|
value: "1/4"
|
|
}, "1/4"), /*#__PURE__*/React.createElement("option", {
|
|
value: "1/8"
|
|
}, "1/8"), /*#__PURE__*/React.createElement("option", {
|
|
value: "1/16"
|
|
}, "1/16"), /*#__PURE__*/React.createElement("option", {
|
|
value: "1/32"
|
|
}, "1/32"))), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center space-x-2 flex-1 overflow-x-auto no-scrollbar"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "text-xs text-zinc-400"
|
|
}, "Normalize:"), /*#__PURE__*/React.createElement("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"
|
|
}), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-xs text-zinc-500 w-10 text-right"
|
|
}, st.effects?.normalizeDb || 0, " dB")), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "text-xs text-zinc-400"
|
|
}, "Gain:"), /*#__PURE__*/React.createElement("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"
|
|
}), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-xs text-zinc-500 w-10 text-right"
|
|
}, st.effects?.gainDb || 0, " dB")), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "text-xs text-zinc-400"
|
|
}, "Pitch:"), /*#__PURE__*/React.createElement("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"
|
|
}), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-xs text-zinc-500 w-10 text-right"
|
|
}, st.effects?.pitch || 0, " st")), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "text-xs text-zinc-400"
|
|
}, "Stretch:"), /*#__PURE__*/React.createElement("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"
|
|
}), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-xs text-zinc-500 w-10 text-right"
|
|
}, st.effects?.speedStretch || 100, "%"))), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center space-x-1 border-r border-zinc-700 pr-3"
|
|
}, /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => handleSubTabFade(st.id, 'in'),
|
|
className: "px-2 py-1 text-xs rounded hover:bg-zinc-700"
|
|
}, "Fade In"), /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => handleSubTabFade(st.id, 'out'),
|
|
className: "px-2 py-1 text-xs rounded hover:bg-zinc-700"
|
|
}, "Fade Out")), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center space-x-1 border-r border-zinc-700 pr-3"
|
|
}, /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "skip-back",
|
|
className: "w-4 h-4"
|
|
}))), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": isPlaying ? 'pause' : 'play',
|
|
className: "w-4 h-4 fill-current"
|
|
}))), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "square",
|
|
className: "w-4 h-4 fill-current"
|
|
}))), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "skip-forward",
|
|
className: "w-4 h-4"
|
|
}))), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "repeat",
|
|
className: "w-4 h-4"
|
|
}))), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "circle",
|
|
className: "w-4 h-4"
|
|
})))), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center space-x-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "text-xs text-zinc-400"
|
|
}, "Rate:"), /*#__PURE__*/React.createElement("select", {
|
|
value: st.playbackRate || 1,
|
|
onChange: e => onRateChange(parseFloat(e.target.value)),
|
|
className: "bg-zinc-700 text-zinc-100 text-xs px-1 py-0.5 rounded border border-zinc-600"
|
|
}, /*#__PURE__*/React.createElement("option", {
|
|
value: "0.5"
|
|
}, "0.5x"), /*#__PURE__*/React.createElement("option", {
|
|
value: "0.75"
|
|
}, "0.75x"), /*#__PURE__*/React.createElement("option", {
|
|
value: "1"
|
|
}, "1x"), /*#__PURE__*/React.createElement("option", {
|
|
value: "1.25"
|
|
}, "1.25x"), /*#__PURE__*/React.createElement("option", {
|
|
value: "1.5"
|
|
}, "1.5x"), /*#__PURE__*/React.createElement("option", {
|
|
value: "2"
|
|
}, "2x"))));
|
|
};
|
|
|
|
// ── 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 wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null;
|
|
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
|
|
const vWidth = wrapper ? wrapper.clientWidth : 1200;
|
|
const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200));
|
|
const h = canvas.parentElement ? canvas.parentElement.clientHeight : 200;
|
|
canvas.width = Math.round(drawWidth * dpr);
|
|
canvas.height = Math.round(h * dpr);
|
|
ctx.scale(dpr, dpr);
|
|
ctx.imageSmoothingEnabled = false;
|
|
canvas.style.position = 'absolute';
|
|
canvas.style.left = `${scrollLeft}px`;
|
|
canvas.style.width = `${drawWidth}px`;
|
|
canvas.style.height = `${h}px`;
|
|
ctx.fillStyle = '#1a1a2e';
|
|
ctx.fillRect(0, 0, drawWidth, h);
|
|
ctx.strokeStyle = '#2a2a4e';
|
|
ctx.lineWidth = 0.5;
|
|
for (let t = 0; t <= buffer.duration; t += 0.5) {
|
|
const x = t / buffer.duration * drawWidth;
|
|
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(drawWidth, y);
|
|
ctx.stroke();
|
|
}
|
|
if (fadeInLen > 0) {
|
|
const fadeX = fadeInLen / buffer.duration * drawWidth;
|
|
ctx.fillStyle = 'rgba(16, 185, 129, 0.12)';
|
|
ctx.fillRect(0, 0, fadeX, h);
|
|
}
|
|
if (fadeOutLen > 0) {
|
|
const fadeX = (buffer.duration - fadeOutLen) / buffer.duration * drawWidth;
|
|
const fadeW = fadeOutLen / buffer.duration * drawWidth;
|
|
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 * drawWidth;
|
|
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 * drawWidth;
|
|
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', drawWidth / 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(drawWidth, 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 /*#__PURE__*/React.createElement("div", {
|
|
style: {
|
|
width: `${timelineWidth}px`,
|
|
height: '100%',
|
|
position: 'relative',
|
|
overflow: 'hidden'
|
|
}
|
|
}, /*#__PURE__*/React.createElement("canvas", {
|
|
ref: canvasRef,
|
|
style: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
imageRendering: 'pixelated'
|
|
},
|
|
className: "cursor-crosshair rounded border border-zinc-700",
|
|
onMouseDown: handleMouseDown,
|
|
onMouseMove: handleMouseMove,
|
|
onMouseUp: handleMouseUp,
|
|
onMouseLeave: handleMouseUp,
|
|
onContextMenu: handleContextMenu
|
|
}));
|
|
};
|
|
const AuthModal = ({
|
|
isOpen,
|
|
mode,
|
|
forceMandatory,
|
|
onClose,
|
|
onSuccess
|
|
}) => {
|
|
if (!isOpen) return null;
|
|
const [activeTab, setActiveTab] = useState(mode || 'login');
|
|
const [username, setUsername] = useState('admin');
|
|
const [email, setEmail] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [oldPassword, setOldPassword] = useState('');
|
|
const [newPassword, setNewPassword] = useState('');
|
|
const [error, setError] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
useEffect(() => {
|
|
if (mode) setActiveTab(mode);
|
|
if (mode === 'force_change' && !oldPassword) {
|
|
setOldPassword('admin123');
|
|
}
|
|
}, [mode]);
|
|
const handleSubmit = async e => {
|
|
e.preventDefault();
|
|
setError('');
|
|
setLoading(true);
|
|
try {
|
|
if (activeTab === 'login') {
|
|
const targetUsername = username.trim() || 'admin';
|
|
const targetPwd = password.trim() || 'admin123';
|
|
const res = await window.SonicAPI.login(targetUsername, targetPwd);
|
|
localStorage.setItem('sonic_token', res.access_token);
|
|
localStorage.setItem('sonic_user', JSON.stringify(res.user));
|
|
if (res.user && res.user.must_change_password) {
|
|
setOldPassword(targetPwd);
|
|
}
|
|
onSuccess(res.user, res.access_token);
|
|
} else if (activeTab === 'register') {
|
|
const res = await window.SonicAPI.register(username.trim(), email.trim(), password.trim());
|
|
localStorage.setItem('sonic_token', res.access_token);
|
|
localStorage.setItem('sonic_user', JSON.stringify(res.user));
|
|
onSuccess(res.user, res.access_token);
|
|
} else if (activeTab === 'force_change') {
|
|
const res = await window.SonicAPI.changePassword(oldPassword.trim(), newPassword.trim());
|
|
localStorage.setItem('sonic_token', res.access_token);
|
|
const user = JSON.parse(localStorage.getItem('sonic_user') || '{}');
|
|
user.must_change_password = false;
|
|
localStorage.setItem('sonic_user', JSON.stringify(user));
|
|
onSuccess(user, res.access_token);
|
|
}
|
|
} catch (err) {
|
|
setError(err.message || (activeTab === 'login' ? 'Tài khoản hoặc mật khẩu không chính xác. (Nếu bạn đã đổi mật khẩu trước đó, vui lòng nhập mật khẩu mới mà bạn đã tạo)' : 'Thao tác không thành công'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
const isForceMode = activeTab === 'force_change';
|
|
const canClose = !forceMandatory && !isForceMode;
|
|
return /*#__PURE__*/React.createElement("div", {
|
|
className: "fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-md p-6 text-slate-200"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex justify-between items-center pb-4 border-b border-[#383838]"
|
|
}, /*#__PURE__*/React.createElement("h3", {
|
|
className: "text-lg font-bold text-teal-400"
|
|
}, isForceMode ? '⚠️ Bắt Buộc Đổi Mật Khẩu Khởi Tạo' : activeTab === 'login' ? '🔐 Đăng Nhập Hệ Thống' : '📝 Đăng Ký Tài Khoản'), canClose && /*#__PURE__*/React.createElement("button", {
|
|
onClick: onClose,
|
|
className: "text-slate-400 hover:text-slate-200"
|
|
}, "✕")), error && /*#__PURE__*/React.createElement("div", {
|
|
className: "mt-4 p-3 bg-red-900/40 border border-red-700 rounded-lg text-red-200 text-sm"
|
|
}, error), /*#__PURE__*/React.createElement("form", {
|
|
onSubmit: handleSubmit,
|
|
className: "mt-4 space-y-4"
|
|
}, isForceMode ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("p", {
|
|
className: "text-xs text-amber-400 bg-amber-950/60 p-2.5 border border-amber-800/80 rounded leading-relaxed"
|
|
}, "🔒 Tài khoản của bạn đang dùng mật khẩu khởi tạo mặc định. Để bảo mật hệ thống, bạn phải đổi mật khẩu mới trước khi tiếp tục."), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
|
className: "block text-xs font-semibold mb-1 text-slate-400"
|
|
}, "Mật khẩu hiện tại (Mặc định: admin123)"), /*#__PURE__*/React.createElement("input", {
|
|
type: "password",
|
|
autoComplete: "current-password",
|
|
required: true,
|
|
value: oldPassword,
|
|
onChange: e => setOldPassword(e.target.value),
|
|
className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"
|
|
})), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
|
className: "block text-xs font-semibold mb-1 text-slate-400"
|
|
}, "Mật khẩu mới"), /*#__PURE__*/React.createElement("input", {
|
|
type: "password",
|
|
autoComplete: "new-password",
|
|
required: true,
|
|
value: newPassword,
|
|
onChange: e => setNewPassword(e.target.value),
|
|
className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"
|
|
}))) : /*#__PURE__*/React.createElement(React.Fragment, null, activeTab === 'login' ? /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
|
className: "block text-xs font-semibold mb-1 text-slate-400"
|
|
}, "Tên đăng nhập ", /*#__PURE__*/React.createElement("span", {
|
|
className: "text-teal-400 font-normal"
|
|
}, "(Tùy chọn - Admin có thể bỏ trống)")), /*#__PURE__*/React.createElement("input", {
|
|
type: "text",
|
|
placeholder: "Mặc định: admin",
|
|
value: username,
|
|
onChange: e => setUsername(e.target.value),
|
|
className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"
|
|
})) : /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
|
className: "block text-xs font-semibold mb-1 text-slate-400"
|
|
}, "Tên đăng nhập"), /*#__PURE__*/React.createElement("input", {
|
|
type: "text",
|
|
required: true,
|
|
value: username,
|
|
onChange: e => setUsername(e.target.value),
|
|
className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"
|
|
})), activeTab === 'register' && /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
|
className: "block text-xs font-semibold mb-1 text-slate-400"
|
|
}, "Email"), /*#__PURE__*/React.createElement("input", {
|
|
type: "email",
|
|
required: true,
|
|
value: email,
|
|
onChange: e => setEmail(e.target.value),
|
|
className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"
|
|
})), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
|
className: "block text-xs font-semibold mb-1 text-slate-400"
|
|
}, "Mật khẩu ", activeTab === 'login' && /*#__PURE__*/React.createElement("span", {
|
|
className: "text-amber-400 font-normal"
|
|
}, "(Lần đầu: admin123)")), /*#__PURE__*/React.createElement("input", {
|
|
type: "password",
|
|
autoComplete: activeTab === 'login' ? 'current-password' : 'new-password',
|
|
required: true,
|
|
value: password,
|
|
onChange: e => setPassword(e.target.value),
|
|
className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"
|
|
}))), /*#__PURE__*/React.createElement("button", {
|
|
type: "submit",
|
|
disabled: loading,
|
|
className: "w-full py-2 bg-teal-600 hover:bg-teal-500 text-white font-semibold rounded-lg shadow transition duration-150"
|
|
}, loading ? 'Đang xác thực...' : isForceMode ? 'Đổi Mật Khẩu Ngay' : activeTab === 'login' ? 'Đăng Nhập System' : 'Tạo Tài Khoản Mới'), activeTab === 'login' && !isForceMode && /*#__PURE__*/React.createElement("button", {
|
|
type: "button",
|
|
onClick: () => {
|
|
setUsername('admin');
|
|
setPassword('admin123');
|
|
setError('');
|
|
},
|
|
className: "w-full mt-2 py-1.5 bg-amber-950/60 hover:bg-amber-900/80 text-amber-300 border border-amber-800/60 text-xs font-semibold rounded-lg transition flex items-center justify-center gap-1.5"
|
|
}, "🔑 Điền Nhanh Tài Khoản Admin (admin / admin123)")), !isForceMode && /*#__PURE__*/React.createElement("div", {
|
|
className: "mt-4 pt-4 border-t border-[#383838] text-center text-xs text-slate-400"
|
|
}, activeTab === 'login' ? /*#__PURE__*/React.createElement("span", null, "Chưa có tài khoản? ", /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => setActiveTab('register'),
|
|
className: "text-teal-400 hover:underline"
|
|
}, "Đăng ký ngay")) : /*#__PURE__*/React.createElement("span", null, "Đã có tài khoản? ", /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => setActiveTab('login'),
|
|
className: "text-teal-400 hover:underline"
|
|
}, "Đăng nhập")))));
|
|
};
|
|
const AIConfigModal = ({
|
|
isOpen,
|
|
onClose,
|
|
onConfigSaved
|
|
}) => {
|
|
if (!isOpen) return null;
|
|
const defaultProvidersList = [{
|
|
id: 'openai_default',
|
|
name: 'OpenAI Official',
|
|
provider_type: 'openai',
|
|
api_base_url: 'https://api.openai.com/v1',
|
|
api_key: '',
|
|
model_name: 'gpt-4o',
|
|
temperature: 0.7,
|
|
is_active: true
|
|
}, {
|
|
id: 'openai_compat_default',
|
|
name: 'OpenAI Compatible (Ollama/LocalAI/DeepSeek)',
|
|
provider_type: 'openai_compatible',
|
|
api_base_url: 'http://localhost:11434/v1',
|
|
api_key: 'ollama',
|
|
model_name: 'deepseek-r1',
|
|
temperature: 0.7,
|
|
is_active: false
|
|
}, {
|
|
id: 'anthropic_default',
|
|
name: 'Anthropic Claude',
|
|
provider_type: 'anthropic',
|
|
api_base_url: 'https://api.anthropic.com/v1',
|
|
api_key: '',
|
|
model_name: 'claude-3-5-sonnet',
|
|
temperature: 0.7,
|
|
is_active: false
|
|
}, {
|
|
id: 'gemini_default',
|
|
name: 'Google Gemini',
|
|
provider_type: 'gemini',
|
|
api_base_url: 'https://generativelanguage.googleapis.com',
|
|
api_key: '',
|
|
model_name: 'gemini-1.5-pro',
|
|
temperature: 0.7,
|
|
is_active: false
|
|
}];
|
|
const [providers, setProviders] = useState(defaultProvidersList);
|
|
const [selectedId, setSelectedId] = useState('openai_default');
|
|
const [msg, setMsg] = useState('');
|
|
const [error, setError] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
useEffect(() => {
|
|
if (isOpen) loadConfigs();
|
|
}, [isOpen]);
|
|
const loadConfigs = async () => {
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
const data = await window.SonicAPI.getAIConfigs();
|
|
if (data && data.providers) {
|
|
setProviders(data.providers);
|
|
if (data.providers.length > 0) setSelectedId(data.providers[0].id);
|
|
}
|
|
} catch (err) {
|
|
setError(err.message || 'Lỗi nạp cấu hình AI');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
const handleSave = async e => {
|
|
e.preventDefault();
|
|
setMsg('');
|
|
setError('');
|
|
setLoading(true);
|
|
try {
|
|
const res = await window.SonicAPI.saveAIConfigs(providers);
|
|
setMsg(res.message || 'Đã lưu cấu hình AI Providers thành công!');
|
|
if (onConfigSaved) onConfigSaved(providers);
|
|
} catch (err) {
|
|
setError(err.message || 'Lỗi khi lưu cấu hình AI');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
const updateProviderField = (id, field, value) => {
|
|
setProviders(prev => prev.map(p => p.id === id ? {
|
|
...p,
|
|
[field]: value
|
|
} : p));
|
|
};
|
|
const activeProvider = providers.find(p => p.id === selectedId) || providers[0];
|
|
return /*#__PURE__*/React.createElement("div", {
|
|
className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm select-none"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-2xl p-6 text-slate-200"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex justify-between items-center pb-4 border-b border-[#383838]"
|
|
}, /*#__PURE__*/React.createElement("h3", {
|
|
className: "text-lg font-bold text-cyan-400 flex items-center gap-2"
|
|
}, "🤖 Quản Lý & Cấu Hình AI Providers"), /*#__PURE__*/React.createElement("button", {
|
|
onClick: onClose,
|
|
className: "text-slate-400 hover:text-slate-200"
|
|
}, "✕")), msg && /*#__PURE__*/React.createElement("div", {
|
|
className: "mt-3 p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs"
|
|
}, msg), error && /*#__PURE__*/React.createElement("div", {
|
|
className: "mt-3 p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs"
|
|
}, error), /*#__PURE__*/React.createElement("div", {
|
|
className: "mt-4 grid grid-cols-3 gap-4"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "space-y-1.5 border-r border-[#383838] pr-3"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "text-xs uppercase font-bold text-slate-400 block mb-2"
|
|
}, "Providers"), providers.map(p => /*#__PURE__*/React.createElement("button", {
|
|
key: p.id,
|
|
onClick: () => setSelectedId(p.id),
|
|
className: `w-full text-left px-3 py-2 rounded-lg text-xs font-semibold flex items-center justify-between transition ${selectedId === p.id ? 'bg-cyan-600 text-white shadow' : 'bg-[#1e1e1e] text-slate-300 hover:bg-[#2e2e2e]'}`
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "truncate"
|
|
}, p.name), p.is_active && /*#__PURE__*/React.createElement("span", {
|
|
className: "w-2 h-2 rounded-full bg-emerald-400"
|
|
}))), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex gap-1 mt-2"
|
|
}, /*#__PURE__*/React.createElement("button", {
|
|
onClick: e => {
|
|
e.stopPropagation();
|
|
const newId = 'provider_' + Date.now();
|
|
setProviders(prev => [...prev, {
|
|
id: newId,
|
|
name: 'New Provider',
|
|
provider_type: 'openai_compatible',
|
|
api_base_url: 'https://api.openai.com/v1',
|
|
api_key: '',
|
|
model_name: 'gpt-4o-mini',
|
|
temperature: 0.7,
|
|
is_active: false
|
|
}]);
|
|
setSelectedId(newId);
|
|
},
|
|
className: "flex-1 px-2 py-1 bg-emerald-700 hover:bg-emerald-600 text-white text-xs font-bold rounded"
|
|
}, "+ Thêm"), providers.length > 1 && /*#__PURE__*/React.createElement("button", {
|
|
onClick: e => {
|
|
e.stopPropagation();
|
|
if (confirm(`Xóa provider "${providers.find(p => p.id === selectedId)?.name}"?`)) {
|
|
setProviders(prev => {
|
|
const filtered = prev.filter(p => p.id !== selectedId);
|
|
if (filtered.length > 0) setSelectedId(filtered[0].id);
|
|
return filtered;
|
|
});
|
|
}
|
|
},
|
|
className: "px-2 py-1 bg-red-800 hover:bg-red-700 text-white text-xs font-bold rounded"
|
|
}, "Xóa"))), activeProvider && /*#__PURE__*/React.createElement("form", {
|
|
onSubmit: handleSave,
|
|
className: "col-span-2 space-y-3.5"
|
|
}, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
|
className: "block text-xs font-semibold text-slate-400 mb-1"
|
|
}, "Tên Provider"), /*#__PURE__*/React.createElement("input", {
|
|
type: "text",
|
|
value: activeProvider.name,
|
|
onChange: e => updateProviderField(activeProvider.id, 'name', e.target.value),
|
|
className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-cyan-500"
|
|
})), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
|
className: "block text-xs font-semibold text-slate-400 mb-1"
|
|
}, "API Base URL (Endpoint)"), /*#__PURE__*/React.createElement("input", {
|
|
type: "text",
|
|
value: activeProvider.api_base_url || '',
|
|
onChange: e => updateProviderField(activeProvider.id, 'api_base_url', e.target.value),
|
|
className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-cyan-300 focus:outline-none focus:border-cyan-500",
|
|
placeholder: "https://api.openai.com/v1"
|
|
})), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
|
className: "block text-xs font-semibold text-slate-400 mb-1"
|
|
}, "API Key Cá Nhân"), /*#__PURE__*/React.createElement("input", {
|
|
type: "password",
|
|
value: activeProvider.api_key || '',
|
|
onChange: e => updateProviderField(activeProvider.id, 'api_key', e.target.value),
|
|
className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-slate-200 focus:outline-none focus:border-cyan-500",
|
|
placeholder: "sk-..."
|
|
})), /*#__PURE__*/React.createElement("div", {
|
|
className: "grid grid-cols-2 gap-3"
|
|
}, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
|
className: "block text-xs font-semibold text-slate-400 mb-1"
|
|
}, "Model Name"), /*#__PURE__*/React.createElement("input", {
|
|
type: "text",
|
|
value: activeProvider.model_name || '',
|
|
onChange: e => updateProviderField(activeProvider.id, 'model_name', e.target.value),
|
|
className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-amber-300 focus:outline-none focus:border-cyan-500"
|
|
})), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
|
className: "block text-xs font-semibold text-slate-400 mb-1"
|
|
}, "Temperature"), /*#__PURE__*/React.createElement("input", {
|
|
type: "number",
|
|
step: "0.1",
|
|
min: "0",
|
|
max: "2",
|
|
value: activeProvider.temperature ?? 0.7,
|
|
onChange: e => updateProviderField(activeProvider.id, 'temperature', parseFloat(e.target.value)),
|
|
className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-cyan-500"
|
|
}))), /*#__PURE__*/React.createElement("div", {
|
|
className: "pt-2 flex items-center justify-between"
|
|
}, /*#__PURE__*/React.createElement("label", {
|
|
className: "flex items-center gap-2 cursor-pointer text-xs text-slate-300"
|
|
}, /*#__PURE__*/React.createElement("input", {
|
|
type: "checkbox",
|
|
checked: activeProvider.is_active,
|
|
onChange: e => updateProviderField(activeProvider.id, 'is_active', e.target.checked),
|
|
className: "rounded accent-cyan-500"
|
|
}), "Kích hoạt Provider này"), /*#__PURE__*/React.createElement("button", {
|
|
type: "submit",
|
|
disabled: loading,
|
|
className: "px-4 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-white font-semibold text-xs rounded-lg shadow transition"
|
|
}, loading ? 'Đang lưu...' : 'Lưu Cấu Hình AI'))))));
|
|
};
|
|
const ProfileModal = ({
|
|
isOpen,
|
|
onClose
|
|
}) => {
|
|
if (!isOpen) return null;
|
|
const [profile, setProfile] = useState(null);
|
|
const [oldPassword, setOldPassword] = useState('');
|
|
const [newPassword, setNewPassword] = useState('');
|
|
const [msg, setMsg] = useState('');
|
|
const [error, setError] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
useEffect(() => {
|
|
if (isOpen) fetchProfile();
|
|
}, [isOpen]);
|
|
const fetchProfile = async () => {
|
|
try {
|
|
const data = await window.SonicAPI.getProfile();
|
|
setProfile(data);
|
|
} catch (e) {
|
|
setError(e.message || 'Không thể tải thông tin profile');
|
|
}
|
|
};
|
|
const handleChangePassword = async e => {
|
|
e.preventDefault();
|
|
setMsg('');
|
|
setError('');
|
|
setLoading(true);
|
|
try {
|
|
const res = await window.SonicAPI.changePassword(oldPassword, newPassword);
|
|
setMsg(res.message || 'Đổi mật khẩu thành công!');
|
|
setOldPassword('');
|
|
setNewPassword('');
|
|
} catch (err) {
|
|
setError(err.message || 'Lỗi khi đổi mật khẩu');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
return /*#__PURE__*/React.createElement("div", {
|
|
className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-lg p-6 text-slate-200"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex justify-between items-center pb-4 border-b border-[#383838]"
|
|
}, /*#__PURE__*/React.createElement("h3", {
|
|
className: "text-lg font-bold text-teal-400"
|
|
}, "👤 Hồ Sơ Cá Nhân & Hạn Mức Quota"), /*#__PURE__*/React.createElement("button", {
|
|
onClick: onClose,
|
|
className: "text-slate-400 hover:text-slate-200"
|
|
}, "✕")), profile && /*#__PURE__*/React.createElement("div", {
|
|
className: "mt-4 space-y-4"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "bg-[#1e1e1e] p-4 rounded-lg border border-[#333] grid grid-cols-2 gap-4 text-xs"
|
|
}, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", {
|
|
className: "text-slate-500 block"
|
|
}, "Tên người dùng"), /*#__PURE__*/React.createElement("span", {
|
|
className: "font-bold text-teal-300 text-sm"
|
|
}, profile.username)), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", {
|
|
className: "text-slate-500 block"
|
|
}, "Vai trò"), /*#__PURE__*/React.createElement("span", {
|
|
className: "uppercase font-semibold text-amber-400"
|
|
}, profile.role)), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", {
|
|
className: "text-slate-500 block"
|
|
}, "Email"), /*#__PURE__*/React.createElement("span", null, profile.email)), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", {
|
|
className: "text-slate-500 block"
|
|
}, "Dung lượng Quota"), /*#__PURE__*/React.createElement("span", {
|
|
className: "font-semibold text-slate-200"
|
|
}, profile.quota.used_mb, " MB / ", profile.quota.storage_limit_mb, " MB"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex justify-between text-xs mb-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "text-slate-400"
|
|
}, "Tiến trình sử dụng bộ nhớ Server"), /*#__PURE__*/React.createElement("span", {
|
|
className: "font-bold text-teal-400"
|
|
}, (profile.quota.used_mb / profile.quota.storage_limit_mb * 100).toFixed(1), "%")), /*#__PURE__*/React.createElement("div", {
|
|
className: "w-full h-2 bg-slate-800 rounded-full overflow-hidden"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "h-full bg-teal-500 rounded-full transition-all duration-300",
|
|
style: {
|
|
width: `${Math.min(100, profile.quota.used_mb / profile.quota.storage_limit_mb * 100)}%`
|
|
}
|
|
}))), /*#__PURE__*/React.createElement("form", {
|
|
onSubmit: handleChangePassword,
|
|
className: "pt-4 border-t border-[#383838] space-y-3"
|
|
}, /*#__PURE__*/React.createElement("h4", {
|
|
className: "text-xs font-bold text-slate-300 uppercase"
|
|
}, "Thay Đổi Mật Khẩu"), msg && /*#__PURE__*/React.createElement("div", {
|
|
className: "p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs"
|
|
}, msg), error && /*#__PURE__*/React.createElement("div", {
|
|
className: "p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs"
|
|
}, error), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
|
className: "block text-xs text-slate-400 mb-1"
|
|
}, "Mật khẩu cũ"), /*#__PURE__*/React.createElement("input", {
|
|
type: "password",
|
|
autoComplete: "current-password",
|
|
required: true,
|
|
value: oldPassword,
|
|
onChange: e => setOldPassword(e.target.value),
|
|
className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs focus:outline-none focus:border-teal-500"
|
|
})), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
|
className: "block text-xs text-slate-400 mb-1"
|
|
}, "Mật khẩu mới"), /*#__PURE__*/React.createElement("input", {
|
|
type: "password",
|
|
autoComplete: "new-password",
|
|
required: true,
|
|
value: newPassword,
|
|
onChange: e => setNewPassword(e.target.value),
|
|
className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs focus:outline-none focus:border-teal-500"
|
|
})), /*#__PURE__*/React.createElement("button", {
|
|
type: "submit",
|
|
disabled: loading,
|
|
className: "w-full py-1.5 bg-teal-600 hover:bg-teal-500 text-white font-semibold text-xs rounded transition"
|
|
}, loading ? 'Đang cập nhật...' : 'Cập Nhật Mật Khẩu')))));
|
|
};
|
|
const SystemManagerModal = ({
|
|
isOpen,
|
|
onClose
|
|
}) => {
|
|
if (!isOpen) return null;
|
|
const [users, setUsers] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [msg, setMsg] = useState('');
|
|
const [error, setError] = useState('');
|
|
const [editingQuotaUser, setEditingQuotaUser] = useState(null);
|
|
const [newQuotaMb, setNewQuotaMb] = useState(500);
|
|
useEffect(() => {
|
|
if (isOpen) loadUsers();
|
|
}, [isOpen]);
|
|
const loadUsers = async () => {
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
const data = await window.SonicAPI.listUsers();
|
|
setUsers(data);
|
|
} catch (err) {
|
|
setError(err.message || 'Không thể tải danh sách người dùng hệ thống');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
const handleSaveQuota = async userId => {
|
|
try {
|
|
await window.SonicAPI.updateUserQuota(userId, parseInt(newQuotaMb));
|
|
setMsg('Đã cập nhật hạn mức Quota thành công!');
|
|
setEditingQuotaUser(null);
|
|
loadUsers();
|
|
} catch (err) {
|
|
setError(err.message || 'Lỗi cập nhật Quota');
|
|
}
|
|
};
|
|
const handleToggleRole = async user => {
|
|
const nextRole = user.role === 'admin' ? 'standard' : 'admin';
|
|
try {
|
|
await window.SonicAPI.updateUserRole(user.id, nextRole, user.is_active);
|
|
setMsg(`Đã đổi vai trò người dùng ${user.username} thành ${nextRole}`);
|
|
loadUsers();
|
|
} catch (err) {
|
|
setError(err.message || 'Lỗi cập nhật vai trò');
|
|
}
|
|
};
|
|
const handleDeleteUser = async userId => {
|
|
if (!confirm('Bạn có chắc chắn muốn xóa người dùng này khỏi hệ thống?')) return;
|
|
try {
|
|
await window.SonicAPI.deleteUser(userId);
|
|
setMsg('Đã xóa người dùng thành công');
|
|
loadUsers();
|
|
} catch (err) {
|
|
setError(err.message || 'Lỗi khi xóa người dùng');
|
|
}
|
|
};
|
|
return /*#__PURE__*/React.createElement("div", {
|
|
className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-4xl p-6 text-slate-200"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex justify-between items-center pb-4 border-b border-[#383838]"
|
|
}, /*#__PURE__*/React.createElement("h3", {
|
|
className: "text-lg font-bold text-amber-400"
|
|
}, "⚙️ Quản Lý Hệ Thống & Phân Quyền Admin"), /*#__PURE__*/React.createElement("button", {
|
|
onClick: onClose,
|
|
className: "text-slate-400 hover:text-slate-200"
|
|
}, "✕")), msg && /*#__PURE__*/React.createElement("div", {
|
|
className: "mt-3 p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs"
|
|
}, msg), error && /*#__PURE__*/React.createElement("div", {
|
|
className: "mt-3 p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs"
|
|
}, error), /*#__PURE__*/React.createElement("div", {
|
|
className: "mt-4 overflow-x-auto max-h-96 no-scrollbar"
|
|
}, loading ? /*#__PURE__*/React.createElement("div", {
|
|
className: "py-8 text-center text-slate-400 text-xs"
|
|
}, "Đang tải thông tin hệ thống...") : /*#__PURE__*/React.createElement("table", {
|
|
className: "w-full text-left text-xs border-collapse"
|
|
}, /*#__PURE__*/React.createElement("thead", null, /*#__PURE__*/React.createElement("tr", {
|
|
className: "border-b border-[#383838] text-slate-400 bg-[#1e1e1e]"
|
|
}, /*#__PURE__*/React.createElement("th", {
|
|
className: "p-3"
|
|
}, "Tên Người Dùng"), /*#__PURE__*/React.createElement("th", {
|
|
className: "p-3"
|
|
}, "Email"), /*#__PURE__*/React.createElement("th", {
|
|
className: "p-3"
|
|
}, "Vai Trò"), /*#__PURE__*/React.createElement("th", {
|
|
className: "p-3"
|
|
}, "Dung Lượng Sử Dụng"), /*#__PURE__*/React.createElement("th", {
|
|
className: "p-3"
|
|
}, "Hạn Mức Quota"), /*#__PURE__*/React.createElement("th", {
|
|
className: "p-3 text-right"
|
|
}, "Thao Tác"))), /*#__PURE__*/React.createElement("tbody", {
|
|
className: "divide-y divide-[#333]"
|
|
}, users.map(u => /*#__PURE__*/React.createElement("tr", {
|
|
key: u.id,
|
|
className: "hover:bg-[#2e2e2e]"
|
|
}, /*#__PURE__*/React.createElement("td", {
|
|
className: "p-3 font-semibold text-teal-300"
|
|
}, u.username, u.must_change_password && /*#__PURE__*/React.createElement("span", {
|
|
className: "ml-2 text-xs bg-amber-900/60 text-amber-300 px-1.5 py-0.5 rounded"
|
|
}, "Mật khẩu gốc")), /*#__PURE__*/React.createElement("td", {
|
|
className: "p-3 text-slate-300"
|
|
}, u.email), /*#__PURE__*/React.createElement("td", {
|
|
className: "p-3 uppercase font-bold text-amber-400"
|
|
}, u.role), /*#__PURE__*/React.createElement("td", {
|
|
className: "p-3"
|
|
}, u.used_mb, " MB"), /*#__PURE__*/React.createElement("td", {
|
|
className: "p-3"
|
|
}, editingQuotaUser === u.id ? /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center space-x-1"
|
|
}, /*#__PURE__*/React.createElement("input", {
|
|
type: "number",
|
|
value: newQuotaMb,
|
|
onChange: e => setNewQuotaMb(e.target.value),
|
|
className: "w-16 bg-[#1e1e1e] border border-[#444] rounded px-1 py-0.5 text-xs text-slate-200"
|
|
}), /*#__PURE__*/React.createElement("span", null, "MB"), /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => handleSaveQuota(u.id),
|
|
className: "px-2 py-0.5 bg-teal-600 rounded text-xs"
|
|
}, "Lưu")) : /*#__PURE__*/React.createElement("span", {
|
|
className: "font-semibold"
|
|
}, u.quota_mb, " MB")), /*#__PURE__*/React.createElement("td", {
|
|
className: "p-3 text-right space-x-2"
|
|
}, /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => {
|
|
setEditingQuotaUser(u.id);
|
|
setNewQuotaMb(u.quota_mb);
|
|
},
|
|
className: "px-2 py-1 bg-slate-700 hover:bg-slate-600 rounded text-xs"
|
|
}, "Sửa Quota"), /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => handleToggleRole(u),
|
|
className: "px-2 py-1 bg-amber-700/60 hover:bg-amber-600 rounded text-xs"
|
|
}, "Đổi Role"), u.role !== 'admin' && /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => handleDeleteUser(u.id),
|
|
className: "px-2 py-1 bg-red-700/60 hover:bg-red-600 rounded text-xs"
|
|
}, "Xóa")))))))));
|
|
};
|
|
const App = () => {
|
|
// ── State Definitions ──
|
|
const [tracks, setTracks] = useState([{
|
|
id: '1',
|
|
name: 'Track 01',
|
|
buffer: null,
|
|
startTime: 0,
|
|
height: 128,
|
|
volumeDb: 0,
|
|
pan: 0,
|
|
muted: false,
|
|
solo: false,
|
|
color: '#0f766e',
|
|
markers: [],
|
|
serverFileId: null,
|
|
clips: []
|
|
}, {
|
|
id: '2',
|
|
name: 'Track 02',
|
|
buffer: null,
|
|
startTime: 0,
|
|
height: 128,
|
|
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 openPanel = id => {
|
|
if (id === 'export') setShowExportPanel(true);else if (id === 'ai') setShowAIPanel(true);else if (id === 'python_tools') setShowPythonToolsPanel(true);else if (id === 'selection') setShowSelectionPanel(true);
|
|
};
|
|
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 [showPythonToolsPanel, setShowPythonToolsPanel] = useState(true);
|
|
const [showMediaExplorer, setShowMediaExplorer] = useState(true);
|
|
const [showFxRack, setShowFxRack] = useState(false);
|
|
const [showMidiEvents, setShowMidiEvents] = useState(false);
|
|
const [rightSidebarWidth, setRightSidebarWidth] = useState(320);
|
|
const [mediaExplorerHeight, setMediaExplorerHeight] = useState(50);
|
|
const [panelPositions, setPanelPositions] = useState({
|
|
export: 'bottom',
|
|
ai: 'right',
|
|
python_tools: 'bottom',
|
|
selection: 'bottom',
|
|
media_explorer: 'right',
|
|
fx_rack: 'bottom',
|
|
midi_events: '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 colResizerRef = useRef(null);
|
|
const rowResizerRef = 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 [aiProviders, setAiProviders] = useState([]);
|
|
useEffect(() => {
|
|
(async () => {
|
|
try {
|
|
const data = await window.SonicAPI.getAIConfigs();
|
|
if (data && data.providers) {
|
|
setAiProviders(data.providers);
|
|
const active = data.providers.find(p => p.is_active) || data.providers[0];
|
|
if (active) setSelectedProviderId(active.id);
|
|
}
|
|
} catch (e) { /* server may not have config endpoint */ }
|
|
})();
|
|
}, []);
|
|
const [analysisState, setAnalysisState] = useState({
|
|
status: 'Sẵn sàng. Chạy AI để phân tích nhịp.',
|
|
data: null,
|
|
isRunning: false,
|
|
});
|
|
const [aiPrompt, setAiPrompt] = useState('');
|
|
const [promptHistory, setPromptHistory] = useState([]);
|
|
const [promptHistIdx, setPromptHistIdx] = useState(-1);
|
|
const promptHistRef = useRef([]);
|
|
const [aiProvider, setAiProvider] = useState('OpenAI');
|
|
const [aiModel, setAiModel] = useState('GPT-4o');
|
|
const [aiActionLog, setAiActionLog] = useState([]);
|
|
const [aiProcessing, setAiProcessing] = useState(false);
|
|
const [selectedProviderId, setSelectedProviderId] = useState('');
|
|
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('main');
|
|
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, 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
|
|
});
|
|
|
|
// ── Auth / User State ──
|
|
const [currentUser, setCurrentUser] = useState(null);
|
|
const [authModalOpen, setAuthModalOpen] = useState(false);
|
|
const [authMode, setAuthMode] = useState('login'); // 'login' | 'register' | 'force_change'
|
|
const [isMandatoryLogin, setIsMandatoryLogin] = useState(false);
|
|
const [profileModalOpen, setProfileModalOpen] = useState(false);
|
|
const [systemManagerModalOpen, setSystemManagerModalOpen] = useState(false);
|
|
const [aiConfigModalOpen, setAiConfigModalOpen] = useState(false);
|
|
useEffect(() => {
|
|
const checkAuthStatus = async () => {
|
|
const savedToken = localStorage.getItem('sonic_token');
|
|
if (!savedToken) {
|
|
setIsMandatoryLogin(true);
|
|
setAuthMode('login');
|
|
setAuthModalOpen(true);
|
|
return;
|
|
}
|
|
try {
|
|
const profile = await window.SonicAPI.getProfile();
|
|
setCurrentUser(profile);
|
|
if (profile.must_change_password) {
|
|
setIsMandatoryLogin(true);
|
|
setAuthMode('force_change');
|
|
setAuthModalOpen(true);
|
|
} else {
|
|
setIsMandatoryLogin(false);
|
|
setAuthModalOpen(false);
|
|
}
|
|
} catch (err) {
|
|
localStorage.removeItem('sonic_token');
|
|
localStorage.removeItem('sonic_user');
|
|
setCurrentUser(null);
|
|
setIsMandatoryLogin(true);
|
|
setAuthMode('login');
|
|
setAuthModalOpen(true);
|
|
}
|
|
};
|
|
checkAuthStatus();
|
|
}, []);
|
|
const loadPendingSfsProject = () => {
|
|
const proj = window.__pendingSfsProject;
|
|
if (!proj) return;
|
|
try {
|
|
const restored = (proj.tracks || []).map(t => ({
|
|
...t,
|
|
buffer: null,
|
|
channelInfo: t.channelInfo || null,
|
|
clips: t.clips || [],
|
|
serverFileId: t.serverFileId || null
|
|
}));
|
|
if (restored.length > 0) {
|
|
setTracks(restored);
|
|
showToast(`Đã tải dự án "${proj.name}" từ liên kết .sfs thành công!`, "success");
|
|
}
|
|
} catch (e) {
|
|
showToast("Lỗi tải dự án từ .sfs", "error");
|
|
} finally {
|
|
window.__pendingSfsProject = null;
|
|
}
|
|
};
|
|
const handleAuthSuccess = user => {
|
|
setCurrentUser(user);
|
|
if (user.must_change_password) {
|
|
setIsMandatoryLogin(true);
|
|
setAuthMode('force_change');
|
|
setAuthModalOpen(true);
|
|
} else {
|
|
setIsMandatoryLogin(false);
|
|
setAuthModalOpen(false);
|
|
loadPendingSfsProject();
|
|
}
|
|
};
|
|
const handleLogout = () => {
|
|
localStorage.removeItem('sonic_token');
|
|
localStorage.removeItem('sonic_user');
|
|
setCurrentUser(null);
|
|
setIsMandatoryLogin(true);
|
|
setAuthMode('login');
|
|
setAuthModalOpen(true);
|
|
};
|
|
|
|
// ── Temp project auto-save (local + server) ──
|
|
useEffect(() => {
|
|
const serializeSafe = arr => (arr || []).map(t => ({
|
|
id: t.id,
|
|
name: t.name,
|
|
startTime: t.startTime,
|
|
height: t.height,
|
|
volumeDb: t.volumeDb,
|
|
pan: t.pan,
|
|
muted: t.muted,
|
|
solo: t.solo,
|
|
color: t.color,
|
|
markers: t.markers || [],
|
|
serverFileId: t.serverFileId || null,
|
|
channelInfo: t.channelInfo ? {
|
|
channels: t.channelInfo.channels,
|
|
isStereo: t.channelInfo.isStereo,
|
|
label: t.channelInfo.label
|
|
} : null
|
|
}));
|
|
window.SonicStorage.scheduleTempAutoSave(() => ({
|
|
id: 'temp_project',
|
|
name: 'Dự án tạm chưa lưu',
|
|
tracks: serializeSafe(tracks),
|
|
subTabs: (subTabs || []).map(s => ({
|
|
id: s.id,
|
|
label: s.label,
|
|
trackId: s.trackId,
|
|
clipId: s.clipId,
|
|
startTime: s.startTime,
|
|
endTime: s.endTime,
|
|
speed: s.speed,
|
|
fadeInLen: s.fadeInLen || 0,
|
|
fadeOutLen: s.fadeOutLen || 0,
|
|
graphMode: s.graphMode,
|
|
volumeNodes: s.volumeNodes || [],
|
|
panningNodes: s.panningNodes || [],
|
|
channelInfo: s.channelInfo ? {
|
|
channels: s.channelInfo.channels,
|
|
isStereo: s.channelInfo.isStereo,
|
|
label: s.channelInfo.label
|
|
} : null
|
|
}))
|
|
}));
|
|
}, [tracks, subTabs]);
|
|
|
|
// Lucide icons initialization
|
|
useEffect(() => {
|
|
setTimeout(() => {
|
|
if (window.lucide) {
|
|
window.lucide.createIcons();
|
|
}
|
|
}, 50);
|
|
}, [activeTool, activeTab]);
|
|
const timelineWrapperRef = useRef(null);
|
|
const tcpContainerRef = useRef(null);
|
|
const [scrollLeft, setScrollLeft] = useState(0);
|
|
const handleTimelineScroll = e => {
|
|
if (tcpContainerRef.current) {
|
|
tcpContainerRef.current.scrollTop = e.currentTarget.scrollTop;
|
|
}
|
|
setScrollLeft(e.currentTarget.scrollLeft);
|
|
};
|
|
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);
|
|
};
|
|
|
|
// ── Right Sidebar Column Resizer ──
|
|
const startColResize = e => {
|
|
e.preventDefault();
|
|
const startX = e.clientX;
|
|
const startWidth = rightSidebarWidth;
|
|
const onMove = ev => {
|
|
const deltaX = startX - ev.clientX;
|
|
const newWidth = Math.max(200, Math.min(600, startWidth + deltaX));
|
|
setRightSidebarWidth(newWidth);
|
|
};
|
|
const onUp = () => {
|
|
document.removeEventListener('mousemove', onMove);
|
|
document.removeEventListener('mouseup', onUp);
|
|
};
|
|
document.addEventListener('mousemove', onMove);
|
|
document.addEventListener('mouseup', onUp);
|
|
};
|
|
|
|
// ── Right Sidebar Row Resizer (Media Explorer / AI Panel) ──
|
|
const startRowResize = e => {
|
|
e.preventDefault();
|
|
const startY = e.clientY;
|
|
const startHeight = mediaExplorerHeight;
|
|
const sidebarEl = document.getElementById('right-sidebar');
|
|
const sidebarHeight = sidebarEl ? sidebarEl.getBoundingClientRect().height : 400;
|
|
const onMove = ev => {
|
|
const deltaY = startY - ev.clientY;
|
|
const pct = ((startHeight / 100 * sidebarHeight + deltaY) / sidebarHeight) * 100;
|
|
const newPct = Math.max(20, Math.min(80, pct));
|
|
setMediaExplorerHeight(newPct);
|
|
};
|
|
const onUp = () => {
|
|
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 rulerAnchorRef = useRef(null);
|
|
const isDraggingRulerRef = useRef(false);
|
|
const handlePlayPauseRef = useRef(null);
|
|
const currentTimeRef = useRef(currentTime);
|
|
currentTimeRef.current = currentTime;
|
|
|
|
// ── 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 => {
|
|
// Bypass global hotkeys when typing inside input/textarea/contentEditable elements
|
|
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable)) {
|
|
return;
|
|
}
|
|
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();
|
|
handleImportSFS();
|
|
return;
|
|
}
|
|
if (ctrl && !alt && e.key === 'n') {
|
|
e.preventDefault();
|
|
setTracks([{
|
|
id: '1',
|
|
name: 'Track 01',
|
|
buffer: null,
|
|
startTime: 0,
|
|
height: 128,
|
|
volumeDb: 0,
|
|
pan: 0,
|
|
muted: false,
|
|
solo: false,
|
|
color: '#0f766e',
|
|
markers: [],
|
|
serverFileId: null
|
|
}, {
|
|
id: '2',
|
|
name: 'Track 02',
|
|
buffer: null,
|
|
startTime: 0,
|
|
height: 128,
|
|
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();
|
|
handleExportSFS();
|
|
return;
|
|
}
|
|
if (ctrl && alt && e.key === 's' || ctrl && e.shiftKey && e.key === 's') {
|
|
e.preventDefault();
|
|
handleExportSFS();
|
|
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 numChannels = t.buffer.numberOfChannels || 1;
|
|
const subBuffer = ctx.createBuffer(numChannels, len, sr);
|
|
for (let c = 0; c < numChannels; c++) {
|
|
subBuffer.copyToChannel(t.buffer.getChannelData(c).subarray(startSample, endSample), c);
|
|
}
|
|
const subChannelInfo = window.SonicAudio.analyzeAudioBufferChannels(subBuffer);
|
|
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,
|
|
channelInfo: subChannelInfo,
|
|
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 numChannels = clip.buffer.numberOfChannels || 1;
|
|
const ctx = getAudioContext();
|
|
const subBuffer = ctx.createBuffer(numChannels, len, sr);
|
|
for (let c = 0; c < numChannels; c++) {
|
|
subBuffer.copyToChannel(clip.buffer.getChannelData(c), c);
|
|
}
|
|
const subChannelInfo = window.SonicAudio.analyzeAudioBufferChannels(subBuffer);
|
|
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,
|
|
channelInfo: subChannelInfo,
|
|
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: 128,
|
|
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 zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
|
|
setZoom(prevZoom => {
|
|
let newZoom = prevZoom * zoomFactor;
|
|
if (newZoom < minZoom) newZoom = minZoom;
|
|
if (newZoom > 50000) newZoom = 50000;
|
|
requestAnimationFrame(() => {
|
|
const centerTime = currentTimeRef.current;
|
|
const newCenterX = centerTime * newZoom;
|
|
const viewportWidth = timeline.clientWidth;
|
|
timeline.scrollLeft = newCenterX - viewportWidth / 2;
|
|
});
|
|
return newZoom;
|
|
});
|
|
} else if (e.shiftKey) {
|
|
e.preventDefault();
|
|
timeline.scrollLeft += e.deltaY;
|
|
}
|
|
};
|
|
timeline.addEventListener('wheel', handleWheel, {
|
|
passive: false
|
|
});
|
|
return () => timeline.removeEventListener('wheel', handleWheel);
|
|
}, [minZoom]);
|
|
|
|
// ── 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);
|
|
};
|
|
|
|
// ── Playhead set with seek+play ──
|
|
const handlePlayheadSet = (time, shiftKey) => {
|
|
localSelectionAnchorRef.current = time;
|
|
if (isPlaying) {
|
|
// Click during playback: seek to position and continue playing
|
|
setCurrentTime(time);
|
|
stopAllPlayback();
|
|
setTimeout(() => {
|
|
startOffsetTimeRef.current = time;
|
|
startAudioTimeRef.current = getAudioContext().currentTime;
|
|
startTrackPlayback(time);
|
|
setIsPlaying(true);
|
|
}, 50);
|
|
} else {
|
|
// Normal click: just set playhead
|
|
setCurrentTime(time);
|
|
}
|
|
};
|
|
const clearLocalSelection = () => {
|
|
setSelectionMode(null);
|
|
setLocalSelectionTrackId(null);
|
|
setLocalSelectionStart(null);
|
|
setLocalSelectionEnd(null);
|
|
};
|
|
const handleRulerMouseDown = e => {
|
|
if (e.ctrlKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
clearLocalSelection();
|
|
setSelectionMode(null);
|
|
setSelectionStart(null);
|
|
setSelectionEnd(null);
|
|
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 = mouseX / zoom;
|
|
const time = snapValue !== 'free' ? snapTime(rawTime, snapValue, bpm) : rawTime;
|
|
clearLocalSelection();
|
|
setSelectionMode('global');
|
|
rulerDragStartRef.current = time;
|
|
isDraggingRulerRef.current = true;
|
|
if (e.shiftKey) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
// Shift+click on ruler: lock existing anchor (or currentTime fallback) and extend global selection
|
|
const anchor = rulerAnchorRef.current !== null && rulerAnchorRef.current !== undefined ? rulerAnchorRef.current : selectionStart !== null && selectionStart !== undefined ? selectionStart : currentTime;
|
|
const selS = Math.min(anchor, time);
|
|
const selE = Math.max(anchor, time);
|
|
setSelectionStart(selS);
|
|
setSelectionEnd(selE);
|
|
} else {
|
|
rulerAnchorRef.current = time;
|
|
setSelectionStart(time);
|
|
setSelectionEnd(time);
|
|
}
|
|
handlePlayheadSet(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;
|
|
const anchor = rulerAnchorRef.current ?? rulerDragStartRef.current ?? time;
|
|
setSelectionStart(Math.min(anchor, time));
|
|
setSelectionEnd(Math.max(anchor, 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 localSelectionAnchorRef = useRef(null);
|
|
const handleTrackLaneMouseDown = (trackId, time) => {
|
|
setSelectedTrackId(trackId);
|
|
clearLocalSelection();
|
|
localSelectionAnchorRef.current = time;
|
|
setSelectionMode('local');
|
|
setLocalSelectionTrackId(trackId);
|
|
setLocalSelectionStart(time);
|
|
setLocalSelectionEnd(time);
|
|
setSelectionStart(time);
|
|
setSelectionEnd(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));
|
|
const anchor = localSelectionAnchorRef.current ?? localDragStartTimeRef.current;
|
|
const selS = Math.min(anchor, time);
|
|
const selE = Math.max(anchor, time);
|
|
setLocalSelectionStart(selS);
|
|
setLocalSelectionEnd(selE);
|
|
setSelectionStart(selS);
|
|
setSelectionEnd(selE);
|
|
};
|
|
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(110, Math.min(300, 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;
|
|
showToast(`Đang nạp file ${file.name}...`, 'info');
|
|
try {
|
|
// Upload to server
|
|
uploadToServer(file, trackId);
|
|
|
|
// Decode locally for playback + analyze channels (stereo/mono)
|
|
const {
|
|
audioBuffer: decodedBuffer,
|
|
channelInfo
|
|
} = await window.SonicAudio.decodeAudioFile(file);
|
|
setTracks(prev => prev.map(t => t.id === trackId ? {
|
|
...t,
|
|
name: file.name,
|
|
buffer: decodedBuffer,
|
|
channelInfo: channelInfo
|
|
} : t));
|
|
showToast(`Nạp file thành công: ${file.name} (${channelInfo.label})`, 'success');
|
|
} catch (err) {
|
|
showToast("Lỗi giải mã âm thanh. Định dạng file không tương thích.", '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: 128,
|
|
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 handleSaveCloud = async () => {
|
|
if (!currentUser) {
|
|
setIsMandatoryLogin(false);
|
|
setAuthMode('login');
|
|
setAuthModalOpen(true);
|
|
return;
|
|
}
|
|
const name = prompt("Nhập tên dự án để lưu lên Cloud:", "Dự án SonicForge");
|
|
if (!name) return;
|
|
const serializeSafe = arr => (arr || []).map(t => ({
|
|
id: t.id,
|
|
name: t.name,
|
|
startTime: t.startTime,
|
|
height: t.height,
|
|
volumeDb: t.volumeDb,
|
|
pan: t.pan,
|
|
muted: t.muted,
|
|
solo: t.solo,
|
|
color: t.color,
|
|
markers: t.markers || [],
|
|
serverFileId: t.serverFileId || null,
|
|
channelInfo: t.channelInfo ? {
|
|
channels: t.channelInfo.channels,
|
|
isStereo: t.channelInfo.isStereo,
|
|
label: t.channelInfo.label
|
|
} : null
|
|
}));
|
|
try {
|
|
const dataJson = JSON.stringify({
|
|
id: 'cloud_project',
|
|
name,
|
|
tracks: serializeSafe(tracks)
|
|
});
|
|
await window.SonicAPI.saveCloudProject(name, dataJson);
|
|
showToast("Đã lưu dự án lên Cloud thành công!", "success");
|
|
} catch (err) {
|
|
showToast(err.message || "Lỗi lưu Cloud", "error");
|
|
}
|
|
};
|
|
const handleExportSFS = () => {
|
|
const serializeSafe = arr => (arr || []).map(t => ({
|
|
id: t.id,
|
|
name: t.name,
|
|
startTime: t.startTime,
|
|
height: t.height,
|
|
volumeDb: t.volumeDb,
|
|
pan: t.pan,
|
|
muted: t.muted,
|
|
solo: t.solo,
|
|
color: t.color,
|
|
markers: t.markers || [],
|
|
serverFileId: t.serverFileId || null,
|
|
channelInfo: t.channelInfo ? {
|
|
channels: t.channelInfo.channels,
|
|
isStereo: t.channelInfo.isStereo,
|
|
label: t.channelInfo.label
|
|
} : null
|
|
}));
|
|
window.SonicStorage.exportProjectToSFS({
|
|
id: 'proj_' + Date.now(),
|
|
name: 'Dự án SonicForge',
|
|
tracks: serializeSafe(tracks)
|
|
});
|
|
showToast("Đã xuất dự án (.sfs) thành công!", "success");
|
|
};
|
|
const handleImportSFS = () => {
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = '.sfs,application/json';
|
|
input.onchange = async e => {
|
|
if (!e.target.files[0]) return;
|
|
try {
|
|
const proj = await window.SonicStorage.importProjectFromSFSFile(e.target.files[0]);
|
|
const restored = (proj.tracks || []).map(t => ({
|
|
...t,
|
|
buffer: null,
|
|
channelInfo: t.channelInfo || null,
|
|
clips: t.clips || [],
|
|
serverFileId: t.serverFileId || null
|
|
}));
|
|
if (restored.length > 0) {
|
|
setTracks(restored);
|
|
showToast(`Đã nạp dự án "${proj.name}" từ tệp .sfs thành công!`, "success");
|
|
}
|
|
} catch (err) {
|
|
showToast(err.message || "Lỗi mở tệp .sfs", "error");
|
|
}
|
|
};
|
|
input.click();
|
|
};
|
|
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);
|
|
};
|
|
|
|
// ── AI Analysic Loop: scan track, detect beats, place markers for loop selection ──
|
|
const handleAIAnalysicLoop = async () => {
|
|
const forcedTrackId = subTabAiTrackIdRef.current;
|
|
subTabAiTrackIdRef.current = null;
|
|
const activeTrackId = forcedTrackId || selectedTrackId;
|
|
const activeTrack = tracks.find(t => t.id === activeTrackId);
|
|
if (!activeTrack || !activeTrack.buffer) {
|
|
showToast("Vui lòng chọn một Track có âm thanh để AI phân tích.", "warning");
|
|
return;
|
|
}
|
|
setAnalysisState({
|
|
status: 'AI Analysic Loop: đang phát hiện nhịp...',
|
|
data: null,
|
|
isRunning: true
|
|
});
|
|
showToast("AI Analysic Loop: đang quét cấu trúc nhịp điệu...", "info");
|
|
try {
|
|
const buffer = activeTrack.buffer;
|
|
const data = buffer.getChannelData(0);
|
|
const sr = buffer.sampleRate;
|
|
const windowSize = Math.min(sr * 3, data.length);
|
|
|
|
// Client-side BPM detection via autocorrelation
|
|
let detectedBPM = 120;
|
|
if (windowSize > sr) {
|
|
let maxCorr = 0;
|
|
for (let lag = Math.floor(sr * 0.3); lag <= Math.floor(sr * 2.0); lag++) {
|
|
let corr = 0;
|
|
const step = 4;
|
|
for (let i = 0; i < windowSize && i + lag < data.length; i += step) {
|
|
corr += data[i] * data[i + lag];
|
|
}
|
|
corr /= windowSize / step;
|
|
if (corr > maxCorr) {
|
|
maxCorr = corr;
|
|
detectedBPM = 60 / (lag / sr);
|
|
}
|
|
}
|
|
}
|
|
detectedBPM = Math.round(Math.min(240, Math.max(60, detectedBPM)));
|
|
const beatDuration = 60 / detectedBPM;
|
|
const barDuration = beatDuration * 4;
|
|
const totalDuration = buffer.duration;
|
|
|
|
// Place markers at each bar (strong beat) position
|
|
const barMarkers = [];
|
|
for (let t = 0; t < totalDuration; t += barDuration) {
|
|
const zcTime = findZeroCrossing(buffer, t);
|
|
barMarkers.push({
|
|
id: 'ai_bar_' + barMarkers.length + '_' + Date.now(),
|
|
time: zcTime,
|
|
label: `Bar ${barMarkers.length + 1}`,
|
|
color: '#06b6d4'
|
|
});
|
|
// Add beat markers within each bar
|
|
for (let b = 1; b < 4; b++) {
|
|
const bt = t + b * beatDuration;
|
|
if (bt < totalDuration) {
|
|
const zcBt = findZeroCrossing(buffer, bt);
|
|
barMarkers.push({
|
|
id: 'ai_beat_' + barMarkers.length + '_' + Date.now(),
|
|
time: zcBt,
|
|
label: `Beat ${b + 1}`,
|
|
color: '#a855f7'
|
|
});
|
|
}
|
|
}
|
|
}
|
|
setTracks(prev => prev.map(t => {
|
|
if (t.id !== activeTrack.id) return t;
|
|
const existingMarkers = t.markers || [];
|
|
return {
|
|
...t,
|
|
markers: [...existingMarkers, ...barMarkers]
|
|
};
|
|
}));
|
|
|
|
// Find the first strong beat to set as selection start
|
|
const firstBeat = barMarkers.length > 0 ? barMarkers[0].time : 0;
|
|
const secondBar = barMarkers.length > 4 ? barMarkers[Math.min(4, barMarkers.length - 1)].time : Math.min(totalDuration, firstBeat + barDuration);
|
|
setSelectionStart(firstBeat);
|
|
setSelectionEnd(secondBar);
|
|
setAnalysisState({
|
|
status: `AI Analysic Loop: ${detectedBPM} BPM, ${barMarkers.length} markers (Bar/Beat)`,
|
|
data: {
|
|
bpm: detectedBPM,
|
|
bars: Math.floor(totalDuration / barDuration)
|
|
},
|
|
isRunning: false
|
|
});
|
|
showToast(`AI Analysic Loop: ${detectedBPM} BPM - ${Math.floor(totalDuration / barDuration)} bars detected`, "success");
|
|
} catch (err) {
|
|
setAnalysisState({
|
|
status: 'Lỗi AI Analysic Loop',
|
|
data: null,
|
|
isRunning: false
|
|
});
|
|
showToast(err.message || 'Lỗi khi phân tích nhịp', 'error');
|
|
}
|
|
};
|
|
// ── Mark Selection ──
|
|
// ── Helper: set selection range from buffer (used by sub-tab AI) ──
|
|
const setSelectionRangeOnBuffer = (buffer, startTime, endTime) => {
|
|
setSelectionStart(startTime);
|
|
setSelectionEnd(endTime);
|
|
};
|
|
|
|
// Ref for forced trackId (used by sub-tab AI buttons, overrides selectedTrackId)
|
|
const subTabAiTrackIdRef = useRef(null);
|
|
const handleAIScan = async () => {
|
|
// Resolve track: prefer sub-tab override, then selectedTrackId
|
|
const forcedTrackId = subTabAiTrackIdRef.current;
|
|
subTabAiTrackIdRef.current = null;
|
|
const activeTrackId = forcedTrackId || selectedTrackId;
|
|
const activeTrack = tracks.find(t => t.id === activeTrackId);
|
|
if (!activeTrack || !activeTrack.buffer) {
|
|
showToast("Vui lòng chọn một Track có âm thanh để AI quét Loop.", "warning");
|
|
return;
|
|
}
|
|
setAnalysisState({
|
|
status: 'AI Loop Scan đang quét nhịp điệu và phách mạnh...',
|
|
data: null,
|
|
isRunning: true
|
|
});
|
|
showToast("AI Scan đang phân tích tempo và phách mạnh...", "info");
|
|
try {
|
|
const buffer = activeTrack.buffer;
|
|
const data = buffer.getChannelData(0);
|
|
const sr = buffer.sampleRate;
|
|
const windowSize = Math.min(sr * 3, data.length);
|
|
|
|
// Detect BPM via autocorrelation
|
|
let detectedBPM = 120;
|
|
if (windowSize > sr) {
|
|
let maxCorr = 0;
|
|
for (let lag = Math.floor(sr * 0.3); lag <= Math.floor(sr * 2.0); lag++) {
|
|
let corr = 0;
|
|
const step = 4;
|
|
for (let i = 0; i < windowSize && i + lag < data.length; i += step) {
|
|
corr += data[i] * data[i + lag];
|
|
}
|
|
corr /= windowSize / step;
|
|
if (corr > maxCorr) {
|
|
maxCorr = corr;
|
|
detectedBPM = 60 / (lag / sr);
|
|
}
|
|
}
|
|
}
|
|
detectedBPM = Math.round(Math.min(240, Math.max(60, detectedBPM)));
|
|
|
|
// Calculate bar grid
|
|
const beatDuration = 60 / detectedBPM;
|
|
const barDuration = beatDuration * 4;
|
|
const totalDuration = buffer.duration;
|
|
|
|
// Place markers at bar starts (strong beats / downbeats)
|
|
const barMarkers = [];
|
|
for (let t = 0; t < totalDuration; t += barDuration) {
|
|
const zcTime = findZeroCrossing(buffer, t);
|
|
barMarkers.push({
|
|
id: 'ai_bar_' + barMarkers.length + '_' + Date.now(),
|
|
time: zcTime,
|
|
label: `Downbeat ${barMarkers.length + 1}`,
|
|
color: '#06b6d4'
|
|
});
|
|
}
|
|
setTracks(prev => prev.map(t => {
|
|
if (t.id !== activeTrack.id) return t;
|
|
const existingMarkers = t.markers || [];
|
|
return {
|
|
...t,
|
|
markers: [...existingMarkers, ...barMarkers]
|
|
};
|
|
}));
|
|
|
|
// Set selection to the first downbeat
|
|
const firstBarStart = barMarkers.length > 0 ? barMarkers[0].time : 0;
|
|
const secondBarStart = barMarkers.length > 1 ? barMarkers[1].time : Math.min(totalDuration, firstBarStart + barDuration);
|
|
setSelectionStart(firstBarStart);
|
|
setSelectionEnd(secondBarStart);
|
|
setAnalysisState({
|
|
status: `AI Scan: ${detectedBPM} BPM, ${barMarkers.length} downbeats (đã snap zero-crossing)`,
|
|
data: {
|
|
bpm: detectedBPM
|
|
},
|
|
isRunning: false
|
|
});
|
|
showToast(`AI Scan: ${detectedBPM} BPM - ${barMarkers.length} downbeats detected`, "success");
|
|
} catch (err) {
|
|
setAnalysisState({
|
|
status: 'Lỗi khi AI Scan',
|
|
data: null,
|
|
isRunning: false
|
|
});
|
|
showToast(err.message || 'Lỗi khi quét AI Loop', 'error');
|
|
}
|
|
};
|
|
const runPythonTool = async toolType => {
|
|
const activeTrack = tracks.find(t => t.id === selectedTrackId);
|
|
if (!activeTrack || !activeTrack.buffer) {
|
|
showToast("Vui lòng chọn một Track để xử lý công cụ Python.", "warning");
|
|
return;
|
|
}
|
|
try {
|
|
if (toolType === 'normalize') {
|
|
const channelData = activeTrack.buffer.getChannelData(0);
|
|
let maxVal = 0;
|
|
for (let i = 0; i < channelData.length; i++) {
|
|
maxVal = Math.max(maxVal, Math.abs(channelData[i]));
|
|
}
|
|
if (maxVal > 0) {
|
|
const gain = 1.0 / maxVal;
|
|
for (let i = 0; i < channelData.length; i++) {
|
|
channelData[i] *= gain;
|
|
}
|
|
}
|
|
showToast("Đã Chuẩn Hóa Peak âm thanh về 0 dB!", "success");
|
|
} else if (toolType === 'invert_phase') {
|
|
const channelData = activeTrack.buffer.getChannelData(0);
|
|
for (let i = 0; i < channelData.length; i++) {
|
|
channelData[i] *= -1;
|
|
}
|
|
showToast("Đã Đảo Pha (180°) âm thanh thành công!", "success");
|
|
} else if (toolType === 'swap_channels') {
|
|
if (activeTrack.buffer.numberOfChannels >= 2) {
|
|
const left = activeTrack.buffer.getChannelData(0);
|
|
const right = activeTrack.buffer.getChannelData(1);
|
|
for (let i = 0; i < left.length; i++) {
|
|
const temp = left[i];
|
|
left[i] = right[i];
|
|
right[i] = temp;
|
|
}
|
|
showToast("Đã Đổi Kênh Left / Right thành công!", "success");
|
|
} else {
|
|
showToast("Track hiện tại là Mono. Chỉ áp dụng Đổi Kênh cho Stereo.", "info");
|
|
}
|
|
} else if (toolType === 'synth_wave') {
|
|
generateSynthToTrack(activeTrack.id, 'synth');
|
|
showToast("Đã tạo Tín Hiệu Sóng Tổng Hợp bằng công cụ Python!", "success");
|
|
}
|
|
} catch (err) {
|
|
showToast(err.message || "Lỗi khi chạy công cụ Python", "error");
|
|
}
|
|
};
|
|
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 (Music Theory Loop Detection) ──
|
|
const handleAICutToNewTrack = () => {
|
|
const forcedTrackId = subTabAiTrackIdRef.current;
|
|
subTabAiTrackIdRef.current = null;
|
|
const activeTrackId = forcedTrackId || selectedTrackId;
|
|
const activeTrack = tracks.find(t => t.id === activeTrackId);
|
|
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 Cut: đang phân tích nhịp và tìm loop point...',
|
|
data: null,
|
|
isRunning: true
|
|
});
|
|
showToast("AI Cut: đang phân tích nhịp điệu và tìm điểm loop chính xác...", "info");
|
|
setTimeout(() => {
|
|
try {
|
|
const buffer = activeTrack.buffer;
|
|
const sampleRate = buffer.sampleRate;
|
|
const channelData = buffer.getChannelData(0);
|
|
const dataLen = channelData.length;
|
|
const windowSize = Math.min(sampleRate * 3, dataLen);
|
|
|
|
// Detect BPM via autocorrelation
|
|
let detectedBPM = 120;
|
|
if (windowSize > sampleRate) {
|
|
let maxCorr = 0;
|
|
for (let lag = Math.floor(sampleRate * 0.3); lag <= Math.floor(sampleRate * 2.0); lag++) {
|
|
let corr = 0;
|
|
const step = 4;
|
|
for (let i = 0; i < windowSize && i + lag < dataLen; i += step) {
|
|
corr += channelData[i] * channelData[i + lag];
|
|
}
|
|
corr /= windowSize / step;
|
|
if (corr > maxCorr) {
|
|
maxCorr = corr;
|
|
detectedBPM = 60 / (lag / sampleRate);
|
|
}
|
|
}
|
|
}
|
|
detectedBPM = Math.round(Math.min(240, Math.max(60, detectedBPM)));
|
|
const beatDuration = 60 / detectedBPM;
|
|
const barDuration = beatDuration * 4;
|
|
|
|
// Use the selection range
|
|
const rawStart = selectionStats.start;
|
|
const rawEnd = selectionStats.end;
|
|
const selDuration = rawEnd - rawStart;
|
|
|
|
// Find the nearest bar start (downbeat) for loop start
|
|
const barsFromZero = rawStart / barDuration;
|
|
const nearestBarStart = Math.round(barsFromZero) * barDuration;
|
|
const loopStart = Math.max(0, Math.min(rawStart + barDuration, nearestBarStart));
|
|
|
|
// Find the nearest beat 4 (bar end) for loop end
|
|
// In 4/4 time: beat 4 = barStart + 3*beatDuration = barEnd
|
|
const barsFromStart = rawEnd / barDuration;
|
|
const nearestBarEnd = Math.round(barsFromStart) * barDuration;
|
|
// Ensure minimum 1 bar loop
|
|
let loopEnd = Math.max(loopStart + barDuration, nearestBarEnd);
|
|
if (loopEnd > rawEnd + beatDuration) loopEnd = loopStart + Math.ceil(selDuration / barDuration) * barDuration;
|
|
|
|
// Snap to zero-crossing for click-free loop
|
|
const snapLoopStart = findZeroCrossing(buffer, loopStart);
|
|
const snapLoopEnd = findZeroCrossing(buffer, loopEnd);
|
|
|
|
// Place markers for the loop points
|
|
setTracks(prev => prev.map(t => {
|
|
if (t.id !== activeTrack.id) return t;
|
|
const existingMarkers = t.markers || [];
|
|
const filtered = existingMarkers.filter(m => !m.id.startsWith('ai_loop_'));
|
|
return {
|
|
...t,
|
|
markers: [...filtered, {
|
|
id: 'ai_loop_start_' + Date.now(),
|
|
time: snapLoopStart,
|
|
label: 'Loop Start (Bar ' + (Math.floor(snapLoopStart / barDuration) + 1) + ')',
|
|
color: '#06b6d4'
|
|
}, {
|
|
id: 'ai_loop_end_' + Date.now(),
|
|
time: snapLoopEnd,
|
|
label: 'Loop End (Beat 4)',
|
|
color: '#a855f7'
|
|
}]
|
|
};
|
|
}));
|
|
setSelectionStart(snapLoopStart);
|
|
setSelectionEnd(snapLoopEnd);
|
|
const startSample = Math.max(0, Math.min(dataLen - 1, Math.floor(snapLoopStart * sampleRate)));
|
|
const endSample = Math.max(0, Math.min(dataLen, Math.floor(snapLoopEnd * 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;
|
|
}
|
|
const context = getAudioContext();
|
|
const numChannels = buffer.numberOfChannels || 1;
|
|
const slicedBuffer = context.createBuffer(numChannels, sliceLength, sampleRate);
|
|
for (let c = 0; c < numChannels; c++) {
|
|
const srcData = buffer.getChannelData(c);
|
|
const dstData = slicedBuffer.getChannelData(c);
|
|
dstData.set(srcData.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 barNum = Math.floor(snapLoopStart / barDuration) + 1;
|
|
const barsCount = Math.max(1, Math.round((snapLoopEnd - snapLoopStart) / barDuration));
|
|
const newTrack = {
|
|
id: newId,
|
|
name: `Loop_${barNum}bar_${activeTrack.name.replace('.wav', '').slice(0, 10)}_${snapLoopStart.toFixed(1)}s.wav`,
|
|
buffer: slicedBuffer,
|
|
channelInfo: activeTrack.channelInfo ? {
|
|
...activeTrack.channelInfo
|
|
} : null,
|
|
startTime: snapLoopStart,
|
|
clips: [{
|
|
id: 'clip_' + newId,
|
|
buffer: slicedBuffer,
|
|
startTime: snapLoopStart,
|
|
name: `Loop_${barNum}bar_${snapLoopStart.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: snapLoopEnd - snapLoopStart
|
|
}],
|
|
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: `AI Cut: ${detectedBPM} BPM, ${barsCount} bars loop (Zero-Crossing aligned)`,
|
|
data: {
|
|
bpm: detectedBPM,
|
|
bars: barsCount,
|
|
timeSig: '4/4'
|
|
},
|
|
isRunning: false
|
|
});
|
|
showToast(`AI Cut: ${barsCount} bars loop at ${snapLoopStart.toFixed(3)}s - ${snapLoopEnd.toFixed(3)}s [${detectedBPM} BPM]`, "success");
|
|
setTimeout(() => lucide.createIcons(), 200);
|
|
} catch (err) {
|
|
showToast("Lỗi khi AI Cut: " + err.message, "error");
|
|
setAnalysisState({
|
|
status: 'Lỗi AI Cut',
|
|
data: null,
|
|
isRunning: false
|
|
});
|
|
}
|
|
}, 800);
|
|
};
|
|
|
|
// ── AI Prompt Send to Active Provider ──
|
|
const handleAISend = async () => {
|
|
const prompt = aiPrompt.trim();
|
|
if (!prompt) { showToast('Vui lòng nhập nội dung prompt.', 'warning'); return; }
|
|
setAiProcessing(true);
|
|
setAiActionLog(prev => [...prev, { type: 'status', text: ` ⏳ Đang gửi prompt đến AI...`, time: Date.now() }]);
|
|
try {
|
|
const selectedProvider = aiProviders.find(p => p.id === selectedProviderId) || (aiProviders.length > 0 ? aiProviders[0] : null);
|
|
const provider = selectedProvider || aiConfig;
|
|
const baseUrl = provider.api_base_url || provider.baseUrl || `${API_BASE_URL}`;
|
|
const apiKey = provider.api_key || provider.apiKey || '';
|
|
const model = provider.model_name || provider.model || 'deepseek-chat';
|
|
const dawContext = window.AIGateway.buildAIPromptContext({
|
|
tracks, bpm, selectedTrackId, currentTime, selLeft, selRight
|
|
});
|
|
const result = await window.AIGateway.executeAIPrompt({
|
|
prompt,
|
|
provider: provider.name || 'default',
|
|
model,
|
|
apiKey,
|
|
baseUrl: baseUrl.replace(/\/chat\/completions$/, '').replace(/\/$/, ''),
|
|
dawContext,
|
|
tools: window.AIGateway.DEFAULT_TOOLS
|
|
});
|
|
const hasText = !!result.textResponse;
|
|
const hasCalls = result.functionCalls && result.functionCalls.length > 0;
|
|
if (hasText) {
|
|
setAiActionLog(prev => [...prev, { type: 'status', text: ` AI: ${result.textResponse.slice(0, 500)}`, time: Date.now() }]);
|
|
}
|
|
if (hasCalls) {
|
|
setAiActionLog(prev => [...prev, { type: 'info', text: ` Gọi ${result.functionCalls.length} lệnh...`, time: Date.now() }]);
|
|
for (const fc of result.functionCalls) {
|
|
setAiActionLog(prev => [...prev, { type: 'info', text: ` Gọi lệnh: ${fc.name}(${JSON.stringify(fc.arguments)})`, time: Date.now() }]);
|
|
const cmdName = fc.name.toUpperCase();
|
|
if (window.DAWCommandDispatcher) {
|
|
try {
|
|
const cmdResult = window.DAWCommandDispatcher.execute(cmdName, fc.arguments);
|
|
setAiActionLog(prev => [...prev, { type: 'status', text: ` ✅ ${fc.name}: ${cmdResult.success ? 'thành công' : 'thất bại'}`, time: Date.now() }]);
|
|
} catch (cmdErr) {
|
|
setAiActionLog(prev => [...prev, { type: 'error', text: ` ❌ ${fc.name}: ${cmdErr.message}`, time: Date.now() }]);
|
|
}
|
|
} else {
|
|
setAiActionLog(prev => [...prev, { type: 'error', text: ` ❌ ${fc.name}: DAWCommandDispatcher not available`, time: Date.now() }]);
|
|
}
|
|
}
|
|
}
|
|
if (!hasText && !hasCalls) {
|
|
setAiActionLog(prev => [...prev, { type: 'error', text: ` AI không trả về lệnh hoặc text. Kiểm tra provider/model có hỗ trợ function calling.`, time: Date.now() }]);
|
|
}
|
|
setAiActionLog(prev => [...prev, { type: 'status', text: ` Hoàn tất.`, time: Date.now() }]);
|
|
setAiPrompt('');
|
|
setTimeout(() => lucide.createIcons(), 200);
|
|
} catch (err) {
|
|
setAiActionLog(prev => [...prev, { type: 'error', text: ` Lỗi: ${err.message}`, time: Date.now() }]);
|
|
showToast(`AI Error: ${err.message}`, 'error');
|
|
} finally {
|
|
setAiProcessing(false);
|
|
}
|
|
};
|
|
|
|
// ── 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');
|
|
};
|
|
|
|
// ── DAW Command Registration for AI (28_AI_PANEL.md §1 & §2) ──
|
|
useEffect(() => {
|
|
if (typeof window.DAWCommandDispatcher === 'undefined') return;
|
|
const api = {
|
|
createTrack: (args) => {
|
|
const name = args.name || `AI_Track_${Date.now()}`;
|
|
const type = args.type || 'audio';
|
|
const newId = addNewTrack();
|
|
if (name && name !== `AI_Track_${Date.now()}`) {
|
|
updateTrackName(newId, name);
|
|
}
|
|
return { success: true, trackId: newId, name };
|
|
},
|
|
deleteTrack: (args) => {
|
|
const tid = args.track_id || selectedTrackId;
|
|
if (!tid) return { success: false, error: 'No track_id provided' };
|
|
deleteTrack(tid);
|
|
return { success: true, trackId: tid };
|
|
},
|
|
addClip: (args) => {
|
|
const trackId = args.track_id || selectedTrackId;
|
|
const barDur = 60 / parseInt(bpm || 120) * 4;
|
|
let startTime;
|
|
if (args.start_time !== undefined && args.start_time !== null) startTime = args.start_time;
|
|
else if (args.start_bar !== undefined && args.start_bar !== null) startTime = args.start_bar * barDur;
|
|
else startTime = currentTime;
|
|
const track = tracks.find(t => t.id === trackId);
|
|
if (!track) return { success: false, error: 'Track not found' };
|
|
const ctx = getAudioContext();
|
|
const sr = 44100;
|
|
let duration;
|
|
if (args.duration_seconds !== undefined && args.duration_seconds !== null) duration = args.duration_seconds;
|
|
else if (args.length_bars !== undefined && args.length_bars !== null) duration = args.length_bars * barDur;
|
|
else duration = 2;
|
|
const buffer = ctx.createBuffer(1, Math.floor(sr * duration), sr);
|
|
const data = buffer.getChannelData(0);
|
|
for (let i = 0; i < data.length; i++) data[i] = 0;
|
|
const clipId = 'clip_' + Date.now();
|
|
setTracks(prev => prev.map(t => {
|
|
if (t.id !== trackId) return t;
|
|
const clips = t.clips && t.clips.length > 0 ? t.clips : (t.buffer ? [{ id: 'default_' + t.id, buffer: t.buffer, startTime: t.startTime || 0, name: t.name }] : []);
|
|
return {
|
|
...t,
|
|
clips: [...clips, { id: clipId, buffer, startTime, name: args.name || 'AI Clip' }],
|
|
buffer: clips.length > 0 ? clips[0].buffer : buffer,
|
|
startTime: clips.length > 0 ? clips[0].startTime : startTime,
|
|
name: clips.length > 0 ? clips[0].name : (args.name || t.name)
|
|
};
|
|
}));
|
|
return { success: true, clipId, trackId };
|
|
},
|
|
removeClip: (args) => {
|
|
const trackId = args.track_id || selectedTrackId;
|
|
const clipId = args.clip_id;
|
|
setTracks(prev => prev.map(t => {
|
|
if (t.id !== trackId) return t;
|
|
const updatedClips = (t.clips || []).filter(c => c.id !== clipId);
|
|
return { ...t, clips: updatedClips, buffer: updatedClips[0]?.buffer || null, startTime: updatedClips[0]?.startTime || 0, name: updatedClips[0]?.name || t.name };
|
|
}));
|
|
return { success: true };
|
|
},
|
|
setTrackVolume: (args) => {
|
|
const trackId = args.track_id || selectedTrackId;
|
|
const vol = args.volume_db ?? args.volume ?? 0;
|
|
updateTrackVolumeDb(trackId, parseFloat(vol));
|
|
return { success: true, trackId, volumeDb: vol };
|
|
},
|
|
setTrackPan: (args) => {
|
|
const trackId = args.track_id || selectedTrackId;
|
|
const pan = args.pan ?? 0;
|
|
updateTrackPan(trackId, parseInt(pan));
|
|
return { success: true, trackId, pan };
|
|
},
|
|
toggleMute: (args) => {
|
|
const trackId = args.track_id || selectedTrackId;
|
|
toggleTrackMute(trackId);
|
|
const track = tracks.find(t => t.id === trackId);
|
|
return { success: true, trackId, muted: track ? track.muted : null };
|
|
},
|
|
toggleSolo: (args) => {
|
|
const trackId = args.track_id || selectedTrackId;
|
|
toggleTrackSoloEvaluate(trackId);
|
|
const track = tracks.find(t => t.id === trackId);
|
|
return { success: true, trackId, solo: track ? track.solo : null };
|
|
},
|
|
processAudioDsp: (args) => {
|
|
const trackId = args.track_id || selectedTrackId;
|
|
const action = args.action;
|
|
const params = args.params || {};
|
|
const track = tracks.find(t => t.id === trackId);
|
|
if (!track || !track.buffer) return { success: false, error: 'Track has no audio buffer' };
|
|
if (action === 'normalize') {
|
|
const channelData = track.buffer.getChannelData(0);
|
|
let maxVal = 0;
|
|
for (let i = 0; i < channelData.length; i++) maxVal = Math.max(maxVal, Math.abs(channelData[i]));
|
|
if (maxVal > 0) {
|
|
const gain = 1.0 / maxVal;
|
|
for (let i = 0; i < channelData.length; i++) channelData[i] *= gain;
|
|
}
|
|
return { success: true, action: 'normalize' };
|
|
} else if (action === 'invert_phase') {
|
|
const channelData = track.buffer.getChannelData(0);
|
|
for (let i = 0; i < channelData.length; i++) channelData[i] *= -1;
|
|
return { success: true, action: 'invert_phase' };
|
|
} else if (action === 'gain') {
|
|
const gainDb = params.gain_db ?? 0;
|
|
const scale = Math.pow(10, gainDb / 20);
|
|
const channelData = track.buffer.getChannelData(0);
|
|
for (let i = 0; i < channelData.length; i++) channelData[i] = Math.max(-1, Math.min(1, channelData[i] * scale));
|
|
return { success: true, action: 'gain', gainDb };
|
|
} else if (action === 'pitch_shift') {
|
|
const semitones = params.semitones ?? 0;
|
|
const ratio = Math.pow(2, semitones / 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 channelData = track.buffer.getChannelData(0);
|
|
const newData = applyResample(channelData, 1 / ratio);
|
|
const ctx = getAudioContext();
|
|
const newBuffer = ctx.createBuffer(1, newData.length, track.buffer.sampleRate);
|
|
newBuffer.copyToChannel(newData, 0);
|
|
setTracks(prev => prev.map(t => t.id === trackId ? { ...t, buffer: newBuffer } : t));
|
|
return { success: true, action: 'pitch_shift', semitones };
|
|
}
|
|
return { success: false, error: `Unknown action: ${action}` };
|
|
},
|
|
renameTrack: (args) => {
|
|
const tid = args.track_id || selectedTrackId;
|
|
const name = args.name;
|
|
if (!tid) return { success: false, error: 'No track_id provided' };
|
|
if (!name) return { success: false, error: 'No name provided' };
|
|
updateTrackName(tid, name);
|
|
return { success: true, trackId: tid, name };
|
|
},
|
|
setSelection: (args) => {
|
|
const barDur = 60 / parseInt(bpm || 120) * 4;
|
|
let start, end;
|
|
if (args.start_time !== undefined && args.start_time !== null) start = args.start_time;
|
|
else if (args.start_bar !== undefined && args.start_bar !== null) start = args.start_bar * barDur;
|
|
else start = currentTime;
|
|
if (args.end_time !== undefined && args.end_time !== null) end = args.end_time;
|
|
else if (args.length_bars !== undefined && args.length_bars !== null) end = start + args.length_bars * barDur;
|
|
else if (args.end_bar !== undefined && args.end_bar !== null) end = args.end_bar * barDur;
|
|
else end = start + barDur;
|
|
clearLocalSelection();
|
|
setSelectionMode('global');
|
|
setSelectionStart(start);
|
|
setSelectionEnd(end);
|
|
return { success: true, start: parseFloat(start.toFixed(3)), end: parseFloat(end.toFixed(3)), length: parseFloat((end - start).toFixed(3)) };
|
|
},
|
|
scanTrack: (args) => {
|
|
const tid = args.track_id || selectedTrackId;
|
|
const track = tracks.find(t => t.id === tid);
|
|
if (!track) return { success: false, error: 'Track not found' };
|
|
if (!track.buffer) return { success: false, error: 'Track has no audio buffer. Load audio first.' };
|
|
const buffer = track.buffer;
|
|
const data = buffer.getChannelData(0);
|
|
const sr = buffer.sampleRate;
|
|
const channels = buffer.numberOfChannels;
|
|
const duration = buffer.duration;
|
|
const totalSamples = buffer.length;
|
|
const windowSize = Math.min(sr * 3, data.length);
|
|
let detectedBPM = 0;
|
|
if (windowSize > sr) {
|
|
let maxCorr = 0;
|
|
for (let lag = Math.floor(sr * 0.3); lag <= Math.floor(sr * 2.0); lag++) {
|
|
let corr = 0;
|
|
const step = 4;
|
|
for (let i = 0; i < windowSize && i + lag < data.length; i += step) corr += data[i] * data[i + lag];
|
|
corr /= windowSize / step;
|
|
if (corr > maxCorr) { maxCorr = corr; detectedBPM = 60 / (lag / sr); }
|
|
}
|
|
}
|
|
detectedBPM = Math.round(Math.min(300, Math.max(30, detectedBPM)));
|
|
const bitDepth = 16;
|
|
const bitrate = Math.round(sr * channels * bitDepth / 1000);
|
|
return {
|
|
success: true,
|
|
trackId: tid,
|
|
trackName: track.name,
|
|
bpm: detectedBPM,
|
|
sampleRate: sr,
|
|
channels,
|
|
duration: parseFloat(duration.toFixed(3)),
|
|
totalSamples,
|
|
bitDepth,
|
|
bitrateKbps: bitrate,
|
|
hasAudio: true
|
|
};
|
|
},
|
|
setBpm: (args) => {
|
|
const bpmVal = args.bpm || args.tempo || 120;
|
|
setBpm(String(bpmVal));
|
|
return { success: true, bpm: bpmVal };
|
|
},
|
|
setPlayhead: (args) => {
|
|
const barDur = 60 / parseInt(bpm || 120) * 4;
|
|
let time;
|
|
if (args.time !== undefined && args.time !== null) time = args.time;
|
|
else if (args.bar !== undefined && args.bar !== null) time = args.bar * barDur;
|
|
else time = 0;
|
|
handlePlayheadSet(time);
|
|
return { success: true, time: parseFloat(time.toFixed(3)) };
|
|
},
|
|
addMarker: (args) => {
|
|
const trackId = args.track_id || selectedTrackId;
|
|
const time = args.time ?? currentTime;
|
|
const track = tracks.find(t => t.id === trackId);
|
|
if (!track) return { success: false, error: 'Track not found' };
|
|
setTracks(prev => prev.map(t => {
|
|
if (t.id !== trackId) return t;
|
|
return { ...t, markers: [...(t.markers || []), { id: 'ai_marker_' + Date.now(), time, label: args.label || 'AI Marker' }] };
|
|
}));
|
|
return { success: true, trackId, time };
|
|
}
|
|
};
|
|
window.DAWCommandDispatcher.registerDAWCommands(api);
|
|
}, [tracks, selectedTrackId, currentTime, bpm]);
|
|
|
|
// ── 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 /*#__PURE__*/React.createElement("div", {
|
|
className: "h-full w-full flex flex-col bg-[#1e1e1e]"
|
|
}, /*#__PURE__*/React.createElement("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: 128,
|
|
volumeDb: 0,
|
|
pan: 0,
|
|
muted: false,
|
|
solo: false,
|
|
color: '#0f766e',
|
|
markers: [],
|
|
serverFileId: null
|
|
}, {
|
|
id: '2',
|
|
name: 'Track 02',
|
|
buffer: null,
|
|
startTime: 0,
|
|
height: 128,
|
|
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: () => handleImportSFS()
|
|
}, {
|
|
label: 'Save Project',
|
|
icon: 'save',
|
|
shortcut: 'Ctrl+S',
|
|
action: () => handleExportSFS()
|
|
}, {
|
|
label: 'Save As...',
|
|
icon: 'save',
|
|
shortcut: 'Ctrl+Alt+S',
|
|
action: () => handleExportSFS()
|
|
}, {
|
|
label: 'Save to Cloud',
|
|
icon: 'upload-cloud',
|
|
action: () => handleSaveCloud()
|
|
}, {
|
|
sep: true
|
|
}, {
|
|
label: 'Config AI Providers...',
|
|
icon: 'settings',
|
|
action: () => setAiConfigModalOpen(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
|
|
}, ...(currentUser ? [{
|
|
label: 'Profile',
|
|
icon: 'user',
|
|
action: () => setProfileModalOpen(true)
|
|
}] : []), ...(currentUser && currentUser.role === 'admin' ? [{
|
|
label: 'System Manager',
|
|
icon: 'settings',
|
|
action: () => setSystemManagerModalOpen(true)
|
|
}] : []), {
|
|
label: 'Logout',
|
|
icon: 'log-out',
|
|
action: () => handleLogout()
|
|
}]
|
|
}, {
|
|
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 AI Providers...',
|
|
icon: 'settings',
|
|
action: () => setAiConfigModalOpen(true)
|
|
}, {
|
|
label: 'Python DSP Tools Panel',
|
|
icon: 'wrench',
|
|
action: () => openPanel('python_tools')
|
|
}]
|
|
}, {
|
|
label: 'Help',
|
|
items: [{
|
|
label: 'About SonicForge',
|
|
icon: 'info',
|
|
action: () => showToast('SonicForge Studio v1.0 - Professional DAW', 'info')
|
|
}]
|
|
}].map(menu => /*#__PURE__*/React.createElement("div", {
|
|
key: menu.label,
|
|
className: "relative"
|
|
}, /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => setMenuOpen(menuOpen === menu.label ? null : menu.label),
|
|
className: `px-3 py-1 text-xs 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), menuOpen === menu.label && /*#__PURE__*/React.createElement("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 ? /*#__PURE__*/React.createElement("div", {
|
|
key: i,
|
|
className: "h-px bg-zinc-700 my-1"
|
|
}) : /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": item.icon,
|
|
className: "w-3.5 h-3.5 text-zinc-500 shrink-0"
|
|
})), /*#__PURE__*/React.createElement("span", {
|
|
className: "flex-1"
|
|
}, item.label), item.shortcut && /*#__PURE__*/React.createElement("span", {
|
|
className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"
|
|
}, item.shortcut)))))), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex-1"
|
|
}), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center gap-2 px-2"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: `text-xs 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), /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => setShowAIConfig(!showAIConfig),
|
|
className: `px-1.5 py-0.5 rounded text-xs 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'}`
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "cpu",
|
|
className: "w-3 h-3"
|
|
}))))), menuOpen && /*#__PURE__*/React.createElement("div", {
|
|
className: "fixed inset-0 z-40",
|
|
onClick: () => setMenuOpen(null)
|
|
}), /*#__PURE__*/React.createElement("div", {
|
|
className: "h-7 bg-[#222] border-b border-zinc-800 flex items-stretch px-2 gap-0.5 shrink-0 overflow-x-auto"
|
|
}, /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => setActiveTab('main'),
|
|
className: `px-3 text-xs 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'}`
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "layout-dashboard",
|
|
className: "w-3 h-3"
|
|
})), " Main Session"), subTabs.map(st => /*#__PURE__*/React.createElement("div", {
|
|
key: st.id,
|
|
className: "flex items-stretch"
|
|
}, /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => setActiveTab(st.id),
|
|
className: `px-2 text-xs 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'}`
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "file-edit",
|
|
className: "w-3 h-3"
|
|
})), /*#__PURE__*/React.createElement("span", {
|
|
className: "max-w-[100px] truncate"
|
|
}, st.label)), /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => closeSubTab(st.id),
|
|
className: "px-1 text-zinc-600 hover:text-red-400 transition text-xs",
|
|
title: "Close tab"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "x",
|
|
className: "w-3 h-3"
|
|
})))))), showAIConfig && /*#__PURE__*/React.createElement("div", {
|
|
className: "bg-zinc-900 border-b border-purple-900 p-3 flex flex-col gap-2 transition-all"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "text-xs font-bold text-purple-400 uppercase tracking-wider flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "cpu",
|
|
className: "w-4 h-4"
|
|
})), " Cấu hình cổng kết nối API"), /*#__PURE__*/React.createElement("div", {
|
|
className: "grid grid-cols-1 md:grid-cols-3 gap-2 text-xs"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex flex-col gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "text-xs text-zinc-500 font-bold uppercase"
|
|
}, "Endpoint Base URL"), /*#__PURE__*/React.createElement("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-xs",
|
|
placeholder: "https://api.openai.com/v1"
|
|
})), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex flex-col gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "text-xs text-zinc-500 font-bold uppercase"
|
|
}, "API Token Key"), /*#__PURE__*/React.createElement("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-xs",
|
|
placeholder: "sk-..."
|
|
})), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex flex-col gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "text-xs text-zinc-500 font-bold uppercase"
|
|
}, "Model Name"), /*#__PURE__*/React.createElement("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-xs",
|
|
placeholder: "gpt-4o-mini"
|
|
})))), /*#__PURE__*/React.createElement("div", {
|
|
className: "h-9 bg-[#222] border-b border-zinc-800 flex items-center px-3 gap-2 shrink-0 select-none"
|
|
}, /*#__PURE__*/React.createElement("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'
|
|
}
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center gap-0.5 mr-1 text-zinc-600"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "grip-vertical",
|
|
className: "w-3 h-3"
|
|
}))), /*#__PURE__*/React.createElement("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)"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "mouse-pointer",
|
|
className: "w-3.5 h-3.5"
|
|
}))), /*#__PURE__*/React.createElement("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)"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "hand",
|
|
className: "w-3.5 h-3.5"
|
|
}))), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center gap-0.5 border border-zinc-700 rounded bg-zinc-850 px-0.5"
|
|
}, /*#__PURE__*/React.createElement("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)"
|
|
}, /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("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"
|
|
}), /*#__PURE__*/React.createElement("path", {
|
|
d: "M4 9h16l-3 9H7z"
|
|
}), /*#__PURE__*/React.createElement("circle", {
|
|
cx: "12",
|
|
cy: "6",
|
|
r: "1"
|
|
}))), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "link",
|
|
className: "w-3.5 h-3.5"
|
|
})))), /*#__PURE__*/React.createElement("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)"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "pen-tool",
|
|
className: "w-3.5 h-3.5"
|
|
}))), /*#__PURE__*/React.createElement("div", {
|
|
className: "w-[1px] h-5 bg-zinc-700 mx-0.5"
|
|
}), /*#__PURE__*/React.createElement("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)"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "scissors",
|
|
className: "w-3.5 h-3.5"
|
|
}))), /*#__PURE__*/React.createElement("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)"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "copy",
|
|
className: "w-3.5 h-3.5"
|
|
}))), /*#__PURE__*/React.createElement("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)"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "clipboard",
|
|
className: "w-3.5 h-3.5"
|
|
}))), /*#__PURE__*/React.createElement("div", {
|
|
className: "w-[1px] h-5 bg-zinc-700 mx-0.5"
|
|
}), /*#__PURE__*/React.createElement("button", {
|
|
onClick: addNewTrack,
|
|
className: "px-2 py-1 bg-cyan-600 hover:bg-cyan-500 text-white font-semibold rounded text-xs flex items-center gap-1 shadow transition",
|
|
title: "Thêm Track Mới (Ctrl+I)"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "plus",
|
|
className: "w-3.5 h-3.5"
|
|
})), /*#__PURE__*/React.createElement("span", null, "Track")), /*#__PURE__*/React.createElement("div", {
|
|
className: "w-[1px] h-5 bg-zinc-700 mx-0.5"
|
|
}), /*#__PURE__*/React.createElement("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)"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "undo",
|
|
className: "w-3.5 h-3.5"
|
|
}))), /*#__PURE__*/React.createElement("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)"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "redo",
|
|
className: "w-3.5 h-3.5"
|
|
})))), /*#__PURE__*/React.createElement("div", {
|
|
className: "w-[1px] h-5 bg-zinc-800 mx-0.5"
|
|
}), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "skip-back",
|
|
className: "w-3.5 h-3.5"
|
|
}))), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "step-back",
|
|
className: "w-3.5 h-3.5"
|
|
}))), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": isPlaying ? "pause" : "play",
|
|
className: "w-3.5 h-3.5 fill-current"
|
|
}))), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "square",
|
|
className: "w-3.5 h-3.5 fill-current"
|
|
}))), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "step-forward",
|
|
className: "w-3.5 h-3.5"
|
|
}))), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "skip-forward",
|
|
className: "w-3.5 h-3.5"
|
|
}))), /*#__PURE__*/React.createElement("div", {
|
|
className: "w-[1px] h-5 bg-zinc-800 mx-0.5"
|
|
}), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "repeat",
|
|
className: "w-3.5 h-3.5"
|
|
}))), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-[14px] text-zinc-500 font-bold uppercase ml-2"
|
|
}, "Snap"), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("option", {
|
|
value: "free"
|
|
}, "Free"), /*#__PURE__*/React.createElement("option", {
|
|
value: "1"
|
|
}, "1"), /*#__PURE__*/React.createElement("option", {
|
|
value: "1/2"
|
|
}, "1/2"), /*#__PURE__*/React.createElement("option", {
|
|
value: "1/4"
|
|
}, "1/4"), /*#__PURE__*/React.createElement("option", {
|
|
value: "1/8"
|
|
}, "1/8"), /*#__PURE__*/React.createElement("option", {
|
|
value: "1/16"
|
|
}, "1/16"), /*#__PURE__*/React.createElement("option", {
|
|
value: "1/32"
|
|
}, "1/32")), /*#__PURE__*/React.createElement("div", {
|
|
className: "w-[1px] h-6 bg-zinc-800 mx-1.5"
|
|
}), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-[14px] text-zinc-500 font-bold"
|
|
}, "Bars:"), /*#__PURE__*/React.createElement("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"
|
|
}), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-[14px] text-zinc-500"
|
|
}, "-"), /*#__PURE__*/React.createElement("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"
|
|
}), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-[14px] text-zinc-500"
|
|
}, "#"), /*#__PURE__*/React.createElement("input", {
|
|
type: "number",
|
|
min: "1",
|
|
value: numberBar,
|
|
readOnly: true,
|
|
className: "w-14 bg-black text-zinc-400 text-[14px] px-1 py-0.5 rounded border border-zinc-800 text-center font-mono"
|
|
}), /*#__PURE__*/React.createElement("div", {
|
|
className: "w-[1px] h-6 bg-zinc-800 mx-1.5"
|
|
}), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-[14px] text-zinc-500"
|
|
}, "Start:"), /*#__PURE__*/React.createElement("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"
|
|
}), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-[14px] text-zinc-500"
|
|
}, "End:"), /*#__PURE__*/React.createElement("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"
|
|
}), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-[14px] text-zinc-500"
|
|
}, "Len:"), /*#__PURE__*/React.createElement("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"
|
|
}), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex-1"
|
|
}), /*#__PURE__*/React.createElement("div", {
|
|
className: "text-xs font-bold font-mono text-zinc-100"
|
|
}, formatTime(currentTime))), (() => {
|
|
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('python_tools', panelPositions.python_tools || 'bottom', showPythonToolsPanel);
|
|
addPanel('selection', panelPositions.selection, showSelectionPanel);
|
|
addPanel('media_explorer', panelPositions.media_explorer || 'right', showMediaExplorer);
|
|
addPanel('fx_rack', panelPositions.fx_rack || 'bottom', showFxRack);
|
|
addPanel('midi_events', panelPositions.midi_events || 'bottom', showMidiEvents);
|
|
const closePanel = id => {
|
|
if (id === 'export') setShowExportPanel(false);
|
|
else if (id === 'ai') setShowAIPanel(false);
|
|
else if (id === 'python_tools') setShowPythonToolsPanel(false);
|
|
else if (id === 'selection') setShowSelectionPanel(false);
|
|
else if (id === 'media_explorer') setShowMediaExplorer(false);
|
|
else if (id === 'fx_rack') setShowFxRack(false);
|
|
else if (id === 'midi_events') setShowMidiEvents(false);
|
|
};
|
|
const renderPanelContent = panelId => {
|
|
const h = id => e => {
|
|
startPanelDrag(id, e);
|
|
};
|
|
if (panelId === 'export') return /*#__PURE__*/React.createElement("div", {
|
|
className: "flex flex-col h-full gap-1.5"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none",
|
|
onMouseDown: e => startPanelDrag('export', e)
|
|
}, /*#__PURE__*/React.createElement("h3", {
|
|
className: "font-bold text-xs text-zinc-200 flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "grip-vertical",
|
|
className: "w-3 h-3 text-zinc-500"
|
|
})), /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "save",
|
|
className: "w-3.5 h-3.5 text-cyan-400"
|
|
})), " Export"), /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => closePanel('export'),
|
|
className: "text-zinc-600 hover:text-zinc-300"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "x",
|
|
className: "w-3 h-3"
|
|
})))), /*#__PURE__*/React.createElement("div", {
|
|
className: "grid grid-cols-3 gap-1"
|
|
}, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
|
className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"
|
|
}, "SR"), /*#__PURE__*/React.createElement("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-xs text-zinc-300 focus:outline-none"
|
|
}, /*#__PURE__*/React.createElement("option", {
|
|
value: "44100"
|
|
}, "44.1k"), /*#__PURE__*/React.createElement("option", {
|
|
value: "48000"
|
|
}, "48k"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
|
className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"
|
|
}, "Bit"), /*#__PURE__*/React.createElement("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-xs text-zinc-300 focus:outline-none"
|
|
}, /*#__PURE__*/React.createElement("option", {
|
|
value: "16"
|
|
}, "16"), /*#__PURE__*/React.createElement("option", {
|
|
value: "24"
|
|
}, "24"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", {
|
|
className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"
|
|
}, "Fmt"), /*#__PURE__*/React.createElement("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-xs text-zinc-300 focus:outline-none"
|
|
}, /*#__PURE__*/React.createElement("option", {
|
|
value: "wav"
|
|
}, "WAV")))), /*#__PURE__*/React.createElement("button", {
|
|
onClick: triggerWavExport,
|
|
disabled: isExporting,
|
|
className: "w-full py-1 bg-cyan-700 hover:bg-cyan-600 text-zinc-100 font-bold rounded text-xs flex items-center justify-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "download-cloud",
|
|
className: "w-3 h-3"
|
|
})), isExporting ? '...' : 'Export'));
|
|
if (panelId === 'ai') return /*#__PURE__*/React.createElement("div", {
|
|
className: "flex flex-col h-full gap-1.5"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none",
|
|
onMouseDown: e => startPanelDrag('ai', e)
|
|
}, /*#__PURE__*/React.createElement("h3", {
|
|
className: "font-bold text-xs text-zinc-200 flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "grip-vertical",
|
|
className: "w-3 h-3 text-zinc-500"
|
|
})), /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "cpu",
|
|
className: "w-3.5 h-3.5 text-purple-400"
|
|
})), " AI Copilot"), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => {
|
|
setAiActionLog([]);
|
|
showToast('Đã xoá nhật ký AI.', 'info');
|
|
},
|
|
className: "text-zinc-600 hover:text-zinc-300",
|
|
title: "Clear log"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "trash-2",
|
|
className: "w-3 h-3"
|
|
}))), /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => closePanel('ai'),
|
|
className: "text-zinc-600 hover:text-zinc-300"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "x",
|
|
className: "w-3 h-3"
|
|
}))))), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center gap-1.5 shrink-0"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "cpu",
|
|
className: "w-3 h-3 text-purple-400"
|
|
})), /*#__PURE__*/React.createElement("select", {
|
|
value: selectedProviderId,
|
|
onChange: e => setSelectedProviderId(e.target.value),
|
|
className: "flex-1 bg-[#141414] text-zinc-200 border border-zinc-700 rounded px-1 py-0.5 text-xs font-mono focus:outline-none focus:border-purple-600"
|
|
}, aiProviders.length === 0 ? /*#__PURE__*/React.createElement("option", {
|
|
value: ""
|
|
}, "Chưa có provider") : aiProviders.map(p => /*#__PURE__*/React.createElement("option", {
|
|
key: p.id,
|
|
value: p.id
|
|
}, p.name, p.is_active ? '' : ' (inactive)')))), /*#__PURE__*/React.createElement("div", {
|
|
className: "border-t border-zinc-800 pt-1.5 mt-1"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "text-xs font-bold text-zinc-400 uppercase mb-1 flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "message-square",
|
|
className: "w-3 h-3"
|
|
})), " Copilot Prompt"), /*#__PURE__*/React.createElement("textarea", {
|
|
value: aiPrompt,
|
|
onChange: e => setAiPrompt(e.target.value),
|
|
placeholder: "Nhập lệnh điều khiển DAW... (VD: Tạo track mới tên Beat, đặt BPM 128)",
|
|
className: "w-full bg-[#1e1e1e] text-zinc-200 p-1.5 rounded border border-zinc-700 focus:outline-none focus:border-purple-600 font-mono text-xs resize-none",
|
|
rows: 2,
|
|
onKeyDown: e => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleAISend();
|
|
}
|
|
}
|
|
}), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center gap-1 mt-1"
|
|
}, /*#__PURE__*/React.createElement("button", {
|
|
onClick: handleAISend,
|
|
disabled: aiProcessing,
|
|
className: "flex-1 py-1 bg-purple-700 hover:bg-purple-600 text-white font-bold rounded text-xs border border-purple-500 flex items-center justify-center gap-1"
|
|
}, aiProcessing ? 'Đang suy luận...' : /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "send",
|
|
className: "w-3 h-3"
|
|
})), " Gửi")), /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => {
|
|
setAiPrompt('');
|
|
setAiActionLog([]);
|
|
},
|
|
className: "px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded text-xs border border-zinc-700"
|
|
}, "Clear")), /*#__PURE__*/React.createElement("div", {
|
|
className: "text-xs text-zinc-600 mt-0.5"
|
|
}, "Enter để gửi nhanh")), /*#__PURE__*/React.createElement("div", {
|
|
className: "border-t border-zinc-800 pt-1.5 mt-1 flex-1 min-h-0 flex flex-col"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "text-xs font-bold text-zinc-400 uppercase mb-1 flex items-center justify-between shrink-0"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "list",
|
|
className: "w-3 h-3"
|
|
}), " Action Log"), aiActionLog.length > 0 && /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => {
|
|
if (window.DAWCommandDispatcher && window.DAWCommandDispatcher.undo) {
|
|
const entry = window.DAWCommandDispatcher.undo();
|
|
if (entry) {
|
|
setAiActionLog(prev => [...prev, { type: 'undo', text: `Undo: ${entry.name}`, time: Date.now() }]);
|
|
showToast(`Undo AI: ${entry.name}`, 'info');
|
|
}
|
|
} else {
|
|
handleUndo();
|
|
setAiActionLog(prev => [...prev, { type: 'undo', text: 'Undo (Ctrl+Z)', time: Date.now() }]);
|
|
}
|
|
},
|
|
className: "text-xs text-amber-400 hover:text-amber-300 border border-amber-800 rounded px-1 py-0.5"
|
|
}, "Undo"))), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex-1 overflow-y-auto no-scrollbar bg-[#0f0f0f] rounded border border-zinc-800 p-1 select-text"
|
|
}, aiActionLog.length === 0 ? /*#__PURE__*/React.createElement("div", {
|
|
className: "text-xs text-zinc-600 italic select-text"
|
|
}, "Chưa có hành động nào.") : aiActionLog.map((entry, i) => /*#__PURE__*/React.createElement("div", {
|
|
key: i,
|
|
className: `text-xs font-mono py-0.5 border-b border-zinc-900 last:border-0 ${entry.type === 'error' ? 'text-red-400' : entry.type === 'status' ? 'text-zinc-400 italic' : entry.type === 'undo' ? 'text-amber-400' : 'text-zinc-300'}`
|
|
}, new Date(entry.time).toLocaleTimeString(), entry.text))));
|
|
if (panelId === 'python_tools') return /*#__PURE__*/React.createElement("div", {
|
|
className: "flex flex-col h-full gap-1.5"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none",
|
|
onMouseDown: e => startPanelDrag('python_tools', e)
|
|
}, /*#__PURE__*/React.createElement("h3", {
|
|
className: "font-bold text-xs text-amber-300 flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "grip-vertical",
|
|
className: "w-3 h-3 text-zinc-500"
|
|
})), /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "wrench",
|
|
className: "w-3.5 h-3.5 text-amber-400"
|
|
})), " Python DSP Tools"), /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => closePanel('python_tools'),
|
|
className: "text-zinc-600 hover:text-zinc-300"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "x",
|
|
className: "w-3 h-3"
|
|
})))), /*#__PURE__*/React.createElement("div", {
|
|
className: "p-1 bg-[#141414] rounded border border-zinc-800 text-xs font-mono text-zinc-400"
|
|
}, "// Non-AI Audio Processing Tools"), /*#__PURE__*/React.createElement("div", {
|
|
className: "grid grid-cols-2 gap-1 text-xs"
|
|
}, /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => runPythonTool('normalize'),
|
|
className: "py-1 bg-amber-950/80 hover:bg-amber-900 text-amber-300 border border-amber-800/80 rounded font-bold flex items-center justify-center gap-1"
|
|
}, "⚡ Peak Norm (0dB)"), /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => runPythonTool('invert_phase'),
|
|
className: "py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 border border-zinc-700 rounded font-bold flex items-center justify-center gap-1"
|
|
}, "🔄 Phase Invert"), /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => runPythonTool('swap_channels'),
|
|
className: "py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 border border-zinc-700 rounded font-bold flex items-center justify-center gap-1"
|
|
}, "🔀 Swap L/R"), /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => runPythonTool('synth_wave'),
|
|
className: "py-1 bg-teal-950/80 hover:bg-teal-900 text-teal-300 border border-teal-800/80 rounded font-bold flex items-center justify-center gap-1"
|
|
}, "🎹 Gen Synth Tone")));
|
|
if (panelId === 'selection') return /*#__PURE__*/React.createElement("div", {
|
|
className: "flex flex-col h-full gap-1.5"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none",
|
|
onMouseDown: e => startPanelDrag('selection', e)
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "text-xs text-zinc-500 font-bold uppercase flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "grip-vertical",
|
|
className: "w-3 h-3 text-zinc-500"
|
|
})), " Selection"), /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => closePanel('selection'),
|
|
className: "text-zinc-600 hover:text-zinc-300"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "x",
|
|
className: "w-3 h-3"
|
|
})))), /*#__PURE__*/React.createElement("div", {
|
|
className: "grid grid-cols-3 gap-1 bg-[#141414] p-1.5 rounded border border-zinc-800"
|
|
}, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", {
|
|
className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"
|
|
}, "Start"), /*#__PURE__*/React.createElement("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-xs rounded py-0.5 border border-zinc-700 focus:outline-none"
|
|
})), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", {
|
|
className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"
|
|
}, "End"), /*#__PURE__*/React.createElement("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-xs rounded py-0.5 border border-zinc-700 focus:outline-none"
|
|
})), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex flex-col justify-center"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "text-[7px] text-zinc-500 font-bold uppercase"
|
|
}, "Len"), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-zinc-200 font-mono text-xs font-semibold mt-0.5"
|
|
}, selectionStats.length, "s"))), /*#__PURE__*/React.createElement("div", {
|
|
className: "grid grid-cols-3 gap-1 bg-[#141414] p-1.5 rounded border border-zinc-800 mt-1"
|
|
}, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", {
|
|
className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"
|
|
}, "Begin Bar"), /*#__PURE__*/React.createElement("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-xs rounded py-0.5 border border-zinc-700 focus:outline-none"
|
|
})), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", {
|
|
className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5"
|
|
}, "End Bar"), /*#__PURE__*/React.createElement("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-xs rounded py-0.5 border border-zinc-700 focus:outline-none"
|
|
})), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex flex-col justify-center"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "text-[7px] text-zinc-500 font-bold uppercase"
|
|
}, "# Bars"), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-zinc-200 font-mono text-xs font-semibold mt-0.5"
|
|
}, numberBar))));
|
|
if (panelId === 'media_explorer') return /*#__PURE__*/React.createElement("div", {
|
|
className: "flex flex-col h-full gap-1.5"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none",
|
|
onMouseDown: e => startPanelDrag('media_explorer', e)
|
|
}, /*#__PURE__*/React.createElement("h3", {
|
|
className: "font-bold text-xs text-emerald-300 flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "grip-vertical",
|
|
className: "w-3 h-3 text-zinc-500"
|
|
})), /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "folder-open",
|
|
className: "w-3.5 h-3.5 text-emerald-400"
|
|
})), " Media Explorer"), /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => closePanel('media_explorer'),
|
|
className: "text-zinc-600 hover:text-zinc-300"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "x",
|
|
className: "w-3 h-3"
|
|
})))), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex-1 overflow-y-auto text-xs text-zinc-400 font-mono p-2"
|
|
}, "// Placeholder: Media files browser"));
|
|
if (panelId === 'fx_rack') return /*#__PURE__*/React.createElement("div", {
|
|
className: "flex flex-col h-full gap-1.5"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none",
|
|
onMouseDown: e => startPanelDrag('fx_rack', e)
|
|
}, /*#__PURE__*/React.createElement("h3", {
|
|
className: "font-bold text-xs text-rose-300 flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "grip-vertical",
|
|
className: "w-3 h-3 text-zinc-500"
|
|
})), /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "sliders",
|
|
className: "w-3.5 h-3.5 text-rose-400"
|
|
})), " Plugin FX Rack"), /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => closePanel('fx_rack'),
|
|
className: "text-zinc-600 hover:text-zinc-300"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "x",
|
|
className: "w-3 h-3"
|
|
})))), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex-1 text-xs text-zinc-500 italic flex items-center justify-center"
|
|
}, "No FX plugins loaded"));
|
|
if (panelId === 'midi_events') return /*#__PURE__*/React.createElement("div", {
|
|
className: "flex flex-col h-full gap-1.5"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none",
|
|
onMouseDown: e => startPanelDrag('midi_events', e)
|
|
}, /*#__PURE__*/React.createElement("h3", {
|
|
className: "font-bold text-xs text-sky-300 flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "grip-vertical",
|
|
className: "w-3 h-3 text-zinc-500"
|
|
})), /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "music",
|
|
className: "w-3.5 h-3.5 text-sky-400"
|
|
})), " MIDI Event List"), /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => closePanel('midi_events'),
|
|
className: "text-zinc-600 hover:text-zinc-300"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "x",
|
|
className: "w-3 h-3"
|
|
})))), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex-1 text-xs text-zinc-500 italic flex items-center justify-center"
|
|
}, "No MIDI events selected"));
|
|
return null;
|
|
};
|
|
const renderDock = (pos, title) => {
|
|
const panels = dockPanels[pos];
|
|
if (panels.length === 0) return null;
|
|
const isSide = pos === 'left' || pos === 'right';
|
|
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;
|
|
if (pos === 'right') return /*#__PURE__*/React.createElement("div", {
|
|
id: "right-sidebar",
|
|
className: `${borderClass} ${bgClass} flex flex-col overflow-hidden select-none`,
|
|
style: {
|
|
width: `${rightSidebarWidth}px`,
|
|
minWidth: '200px',
|
|
maxWidth: '600px',
|
|
flexShrink: 0
|
|
}
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex-1 flex flex-col gap-2 p-2 overflow-hidden"
|
|
}, panels.map((p, idx) => /*#__PURE__*/React.createElement(React.Fragment, {
|
|
key: p
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: `${p === 'media_explorer' ? 'flex-shrink-0' : 'flex-1 min-h-0'} border border-zinc-700 rounded-lg bg-[#262626] shadow-sm`,
|
|
style: p === 'media_explorer' ? {
|
|
height: `${mediaExplorerHeight}%`
|
|
} : {}
|
|
}, renderPanelContent(p)), idx < panels.length - 1 && /*#__PURE__*/React.createElement("div", {
|
|
className: "h-1.5 cursor-ns-resize hover:bg-cyan-500/50 transition-colors rounded shrink-0",
|
|
onMouseDown: startRowResize
|
|
})))));
|
|
const sideClass = isSide ? 'shrink-0 overflow-y-auto' : 'shrink-0';
|
|
return /*#__PURE__*/React.createElement("div", {
|
|
id: "daw-bottom",
|
|
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 => /*#__PURE__*/React.createElement("div", {
|
|
key: p,
|
|
className: `${isSide ? 'w-full' : 'flex-none w-[320px]'} border border-zinc-700 rounded-lg bg-[#262626] shadow-sm p-2`
|
|
}, renderPanelContent(p))));
|
|
};
|
|
return /*#__PURE__*/React.createElement("div", {
|
|
ref: workspaceRef,
|
|
className: "flex-1 flex flex-col overflow-hidden select-none daw-bg relative"
|
|
}, panelDragRef.current && panelDropZone && /*#__PURE__*/React.createElement("div", {
|
|
className: "absolute inset-0 z-50 pointer-events-none"
|
|
}, panelDropZone === 'top' && /*#__PURE__*/React.createElement("div", {
|
|
className: "absolute top-0 left-0 right-0 h-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]"
|
|
}), panelDropZone === 'bottom' && /*#__PURE__*/React.createElement("div", {
|
|
className: "absolute bottom-0 left-0 right-0 h-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]"
|
|
}), panelDropZone === 'left' && /*#__PURE__*/React.createElement("div", {
|
|
className: "absolute top-0 bottom-0 left-0 w-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]"
|
|
}), panelDropZone === 'right' && /*#__PURE__*/React.createElement("div", {
|
|
className: "absolute top-0 bottom-0 right-0 w-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]"
|
|
})), dragGhostPanel && dragGhostPos && /*#__PURE__*/React.createElement("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
|
|
}
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center gap-2 text-xs text-zinc-200 font-bold"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "move",
|
|
className: "w-3.5 h-3.5 text-cyan-400"
|
|
})), dragGhostPanel === 'export' ? 'Export Panel' : dragGhostPanel === 'ai' ? 'AI Panel' : dragGhostPanel === 'python_tools' ? 'Audio Processing Panel' : 'Selection Panel'), /*#__PURE__*/React.createElement("div", {
|
|
className: "text-xs text-zinc-500 mt-1"
|
|
}, "Drop at edge to dock")), renderDock('top', 'Top'), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex-1 flex overflow-hidden"
|
|
}, renderDock('left', 'Left'), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex-1 flex flex-col overflow-hidden min-w-0"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex-1 flex overflow-hidden"
|
|
}, activeTab === 'main' ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
|
|
ref: tcpContainerRef,
|
|
onScroll: handleTCPScroll,
|
|
className: "w-[300px] shrink-0 z-20 bg-[#262626] overflow-hidden flex flex-col border-r border-zinc-900",
|
|
style: {
|
|
scrollbarWidth: 'none',
|
|
msOverflowStyle: 'none'
|
|
}
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "sticky top-0 z-50 flex items-center justify-between h-10 border-b border-zinc-900 bg-[#242424] px-2 shrink-0"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "text-xs font-bold text-zinc-300 flex items-center gap-1.5"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "sliders",
|
|
className: "w-3.5 h-3.5 text-cyan-400"
|
|
})), "TRACKS (", tracks.length, ")"), /*#__PURE__*/React.createElement("button", {
|
|
onClick: addNewTrack,
|
|
className: "px-2.5 py-1 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold flex items-center gap-1 shadow transition"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "plus",
|
|
className: "w-3 h-3"
|
|
})), " Add Track")), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center justify-between w-full"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center gap-2"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "text-xs font-bold text-purple-400 font-mono"
|
|
}, "TM"), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-xs font-semibold text-zinc-300"
|
|
}, "Tempo")), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("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-xs text-zinc-300 text-center font-mono focus:outline-none focus:border-purple-500",
|
|
min: "40",
|
|
max: "300"
|
|
}), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-xs text-zinc-500"
|
|
}, "BPM")))), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex-1 flex flex-col divide-y divide-[#141414] bg-[#262626]"
|
|
}, tracks.length === 0 ? /*#__PURE__*/React.createElement("div", {
|
|
className: "p-6 text-center text-zinc-400 flex flex-col items-center justify-center space-y-3"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "plus-circle",
|
|
className: "w-8 h-8 text-cyan-400 opacity-80"
|
|
})), /*#__PURE__*/React.createElement("p", {
|
|
className: "text-xs font-medium"
|
|
}, "Chưa có Track nào trong dự án."), /*#__PURE__*/React.createElement("button", {
|
|
onClick: addNewTrack,
|
|
className: "px-3 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold flex items-center gap-1.5 shadow"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "plus",
|
|
className: "w-3.5 h-3.5"
|
|
})), " Thêm Track Mới")) : tracks.map((track, idx) => {
|
|
const isSelected = selectedTrackId === track.id;
|
|
return /*#__PURE__*/React.createElement("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)
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-start justify-between"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center gap-2"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "text-xs font-bold text-zinc-500 font-mono"
|
|
}, (idx + 1).toString().padStart(2, '0')), /*#__PURE__*/React.createElement("label", {
|
|
onClick: e => {
|
|
e.stopPropagation();
|
|
const el = e.currentTarget.querySelector('input');
|
|
if (el) el.click();
|
|
},
|
|
className: "cursor-pointer"
|
|
}, /*#__PURE__*/React.createElement("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"
|
|
}), /*#__PURE__*/React.createElement("div", {
|
|
className: "w-3 h-3 rounded-full border border-zinc-600 hover:scale-110 transition-transform",
|
|
style: {
|
|
backgroundColor: track.color
|
|
}
|
|
})), editingTrackName === track.id ? /*#__PURE__*/React.createElement("input", {
|
|
type: "text",
|
|
value: editNameInput,
|
|
autoFocus: true,
|
|
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"
|
|
}) : /*#__PURE__*/React.createElement("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)), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("button", {
|
|
onClick: e => {
|
|
e.stopPropagation();
|
|
toggleTrackMute(track.id);
|
|
},
|
|
className: `px-1.5 py-0.5 text-xs 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"), /*#__PURE__*/React.createElement("button", {
|
|
onClick: e => {
|
|
e.stopPropagation();
|
|
toggleTrackSoloEvaluate(track.id);
|
|
},
|
|
className: `px-1.5 py-0.5 text-xs 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"), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "trash-2",
|
|
className: "w-3.5 h-3.5"
|
|
}))))), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex flex-col gap-0.5 text-xs",
|
|
onClick: e => e.stopPropagation()
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "w-8 text-right text-zinc-500 text-xs"
|
|
}, "Vol:"), /*#__PURE__*/React.createElement("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'
|
|
}
|
|
}), /*#__PURE__*/React.createElement("span", {
|
|
className: "w-12 text-right font-mono text-zinc-300 text-xs"
|
|
}, track.volumeDb ?? 0, "dB")), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "w-8 text-right text-zinc-500 text-xs"
|
|
}, "Pan:"), /*#__PURE__*/React.createElement("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'
|
|
}
|
|
}), /*#__PURE__*/React.createElement("span", {
|
|
className: "w-12 text-right font-mono text-zinc-300 text-xs"
|
|
}, track.pan > 0 ? 'R' + track.pan : track.pan < 0 ? 'L' + Math.abs(track.pan) : 'C'))), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center gap-1.5 mt-1",
|
|
onClick: e => e.stopPropagation()
|
|
}, /*#__PURE__*/React.createElement("input", {
|
|
type: "file",
|
|
id: `upload-${track.id}`,
|
|
accept: "audio/*",
|
|
className: "hidden",
|
|
onChange: e => loadFileOnTrack(track.id, e.target.files[0])
|
|
}), /*#__PURE__*/React.createElement("label", {
|
|
htmlFor: `upload-${track.id}`,
|
|
className: "px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded text-xs flex items-center gap-1 cursor-pointer border border-zinc-700 font-medium"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "upload",
|
|
className: "w-3 h-3"
|
|
})), " File"), /*#__PURE__*/React.createElement("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-xs font-bold flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "wand-2",
|
|
className: "w-3 h-3"
|
|
})), " FX: ", /*#__PURE__*/React.createElement("span", {
|
|
className: "text-zinc-500 font-normal"
|
|
}, "None")), /*#__PURE__*/React.createElement("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-xs font-bold flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "music",
|
|
className: "w-3 h-3"
|
|
})), " Synth: ", /*#__PURE__*/React.createElement("span", {
|
|
className: "text-zinc-500 font-normal"
|
|
}, "None"))), /*#__PURE__*/React.createElement("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()
|
|
}));
|
|
})), /*#__PURE__*/React.createElement("div", {
|
|
className: "h-[48px] border-r border-zinc-900 bg-[#1e1e1e]/50 shrink-0"
|
|
})), /*#__PURE__*/React.createElement("div", {
|
|
ref: timelineWrapperRef,
|
|
onScroll: handleTimelineScroll,
|
|
className: "flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
style: {
|
|
width: `${timelineWidth}px`
|
|
},
|
|
className: "relative flex flex-col min-h-full"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "sticky top-0 z-45 flex h-10 border-b border-zinc-900 bg-[#242424] shrink-0"
|
|
}, /*#__PURE__*/React.createElement("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 /*#__PURE__*/React.createElement("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));
|
|
}))), selectionMode === 'global' && selLeft !== null && selRight !== null && selRight > selLeft && /*#__PURE__*/React.createElement("div", {
|
|
className: "absolute inset-0 pointer-events-none z-20",
|
|
style: {
|
|
left: `${selLeft * zoom}px`,
|
|
width: `${(selRight - selLeft) * zoom}px`,
|
|
top: '40px'
|
|
}
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "w-full h-full bg-amber-500/10",
|
|
style: {
|
|
borderLeft: '1px solid #f59e0b',
|
|
borderRight: '1px solid #f59e0b'
|
|
}
|
|
})), /*#__PURE__*/React.createElement("div", {
|
|
className: "sticky top-10 z-35 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] shrink-0"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex-1 relative h-full overflow-hidden bg-[#1a1a2e]"
|
|
}, /*#__PURE__*/React.createElement(TempoTrackLane, {
|
|
bpm: parseInt(bpm) || 120,
|
|
zoom: zoom,
|
|
timelineWidth: timelineWidth,
|
|
viewportWidth: viewportWidth,
|
|
onPlayheadSet: handlePlayheadSet,
|
|
snapValue: snapValue,
|
|
onRulerMouseDown: handleRulerMouseDown,
|
|
scrollLeft: scrollLeft
|
|
}))), /*#__PURE__*/React.createElement("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 /*#__PURE__*/React.createElement("div", {
|
|
key: track.id,
|
|
style: {
|
|
height: `${track.height || 96}px`
|
|
},
|
|
className: `shrink-0 relative 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)
|
|
}, /*#__PURE__*/React.createElement(WaveformLane, {
|
|
track: track,
|
|
zoom: zoom,
|
|
timelineWidth: timelineWidth,
|
|
viewportWidth: viewportWidth,
|
|
onSelectRange: handleSelectRange,
|
|
onPlayheadSet: handlePlayheadSet,
|
|
isSelected: isSelected,
|
|
onSelectTrack: setSelectedTrackId,
|
|
markers: track.markers,
|
|
onTrackLaneMouseDown: handleTrackLaneMouseDown,
|
|
onContextMenu: handleContextMenu,
|
|
onClipDragStart: handleClipDragStart,
|
|
onClipStretchStart: handleClipStretchStart,
|
|
onSelectionEdgeDragStart: handleSelectionEdgeDragStart,
|
|
setSelectedClipId: setSelectedClipId,
|
|
selectedClipId: selectedClipId,
|
|
activeTool: activeTool,
|
|
onSplitTrackAtTime: handleSplitTrackAtTime,
|
|
onEditClipInSubTab: handleEditClipInSubTab,
|
|
snapValue: snapValue,
|
|
bpm: bpm,
|
|
selectionMode: selectionMode,
|
|
localSelectionTrackId: localSelectionTrackId,
|
|
localSelectionStart: localSelectionStart,
|
|
currentTime: currentTime,
|
|
getLocalAnchor: () => localSelectionAnchorRef.current,
|
|
onClearLocalSelection: clearLocalSelection,
|
|
onSetSelectionMode: setSelectionMode,
|
|
onSetSelectionStart: setSelectionStart,
|
|
onSetSelectionEnd: setSelectionEnd,
|
|
onSetCurrentTime: setCurrentTime,
|
|
onSetLocalSelectionTrackId: setLocalSelectionTrackId,
|
|
onSetLocalSelectionStart: setLocalSelectionStart,
|
|
onSetLocalSelectionEnd: setLocalSelectionEnd,
|
|
localSelLeft: localSelectionStart !== null && localSelectionEnd !== null ? Math.min(localSelectionStart, localSelectionEnd) : null,
|
|
localSelRight: localSelectionStart !== null && localSelectionEnd !== null ? Math.max(localSelectionStart, localSelectionEnd) : null,
|
|
scrollLeft: scrollLeft
|
|
}), selectionMode === 'local' && localSelectionTrackId === track.id && localSelectionStart !== null && localSelectionEnd !== null && Math.abs(localSelectionEnd - localSelectionStart) > 0 && /*#__PURE__*/React.createElement("div", {
|
|
className: "absolute top-0 bottom-0 border-l border-r border-amber-400 bg-amber-500/20 z-20 pointer-events-none",
|
|
style: {
|
|
left: `${Math.min(localSelectionStart, localSelectionEnd) * zoom}px`,
|
|
width: `${Math.abs(localSelectionEnd - localSelectionStart) * zoom}px`
|
|
}
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "absolute -left-[1px] top-0 bottom-0 w-[2px] bg-amber-400 z-30 pointer-events-auto cursor-ew-resize",
|
|
onMouseDown: e => handleHandleDragStart(e, 'left')
|
|
}), /*#__PURE__*/React.createElement("div", {
|
|
className: "absolute -right-[1px] top-0 bottom-0 w-[2px] bg-amber-400 z-30 pointer-events-auto cursor-ew-resize",
|
|
onMouseDown: e => handleHandleDragStart(e, 'right')
|
|
})), track.buffer && /*#__PURE__*/React.createElement("div", {
|
|
className: "absolute right-2 top-2 flex items-center gap-1 z-10 opacity-70 hover:opacity-100"
|
|
}, /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => handleSplitTrack(track.id),
|
|
className: "px-1.5 py-0.5 bg-zinc-900/95 text-zinc-300 rounded text-xs flex items-center gap-1 border border-zinc-700/50"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "scissors",
|
|
className: "w-2.5 h-2.5 text-cyan-400"
|
|
})), " Cắt")), /*#__PURE__*/React.createElement("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()
|
|
}));
|
|
}), /*#__PURE__*/React.createElement("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
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "flex items-center gap-1 text-zinc-400"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "plus",
|
|
className: "w-3.5 h-3.5"
|
|
})), " Kéo clip xuống hoặc Click tạo Track")), selectionMode === 'global' && selLeft !== null && selRight !== null && selRight > selLeft && /*#__PURE__*/React.createElement("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
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "absolute -left-[1px] top-0 bottom-0 w-[1px] bg-amber-500 hover:bg-amber-400 cursor-ew-resize z-30",
|
|
onMouseDown: e => handleHandleDragStart(e, 'left')
|
|
}), /*#__PURE__*/React.createElement("div", {
|
|
className: "absolute -right-[1px] top-0 bottom-0 w-[1px] bg-amber-500 hover:bg-amber-400 cursor-ew-resize z-30",
|
|
onMouseDown: e => handleHandleDragStart(e, 'right')
|
|
})), /*#__PURE__*/React.createElement("div", {
|
|
className: "absolute top-0 bottom-0 w-[2px] bg-[#ef4444] z-20 pointer-events-none",
|
|
style: {
|
|
left: `${playheadLeftPos}px`
|
|
}
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "w-3 h-3 bg-[#ef4444] rotate-45 transform -translate-x-1/2 -translate-y-1/2 absolute top-0"
|
|
})))))) : (() => {
|
|
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 /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
|
|
className: "w-[320px] shrink-0 z-30 bg-[#262626] overflow-y-scroll no-scrollbar flex flex-col border-r border-zinc-900",
|
|
style: {
|
|
scrollbarWidth: 'none',
|
|
msOverflowStyle: 'none'
|
|
}
|
|
}, /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "text-xs font-bold text-zinc-500 uppercase"
|
|
}, "Sub-Tab"), /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => closeSubTab(st.id),
|
|
className: "px-1.5 py-0.5 text-xs bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded border border-zinc-700 flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "x",
|
|
className: "w-3 h-3"
|
|
})), " Close")), vTrack ? /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center justify-between mb-2"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center gap-2"
|
|
}, /*#__PURE__*/React.createElement("label", {
|
|
onClick: e => {
|
|
e.stopPropagation();
|
|
const el = e.currentTarget.querySelector('input');
|
|
if (el) el.click();
|
|
},
|
|
className: "cursor-pointer"
|
|
}, /*#__PURE__*/React.createElement("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"
|
|
}), /*#__PURE__*/React.createElement("div", {
|
|
className: "w-3 h-3 rounded-full border border-zinc-600 hover:scale-110 transition-transform",
|
|
style: {
|
|
backgroundColor: vTrack.color
|
|
}
|
|
})), editingTrackName === vTrack.id ? /*#__PURE__*/React.createElement("input", {
|
|
type: "text",
|
|
value: editNameInput,
|
|
autoFocus: true,
|
|
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"
|
|
}) : /*#__PURE__*/React.createElement("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)), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("button", {
|
|
onClick: e => {
|
|
e.stopPropagation();
|
|
toggleTrackMute(vTrack.id);
|
|
},
|
|
className: `px-1.5 py-0.5 text-xs 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"), /*#__PURE__*/React.createElement("button", {
|
|
onClick: e => {
|
|
e.stopPropagation();
|
|
toggleTrackSoloEvaluate(vTrack.id);
|
|
},
|
|
className: `px-1.5 py-0.5 text-xs 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"))), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex flex-col gap-2.5 text-[14px] mb-3"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "w-10 text-right text-zinc-500 text-[14px]"
|
|
}, "Vol:"), /*#__PURE__*/React.createElement("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'
|
|
}
|
|
}), /*#__PURE__*/React.createElement("span", {
|
|
className: "w-16 text-right font-mono text-zinc-300 text-[14px]",
|
|
id: `tcp-vol-label-${st.id}`
|
|
}, vTrack.volumeDb ?? 0, "dB")), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "w-10 text-right text-zinc-500 text-[14px]"
|
|
}, "Pan:"), /*#__PURE__*/React.createElement("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'
|
|
}
|
|
}), /*#__PURE__*/React.createElement("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')), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center gap-1.5 text-[14px]"
|
|
}, /*#__PURE__*/React.createElement("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'}`
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "repeat",
|
|
className: "w-3.5 h-3.5"
|
|
}))), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "arrow-left-right",
|
|
className: "w-3.5 h-3.5"
|
|
}))), /*#__PURE__*/React.createElement("span", {
|
|
className: "w-10 text-right text-zinc-500 text-[14px]"
|
|
}, "Loop:"), /*#__PURE__*/React.createElement("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"
|
|
}))), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex gap-1 justify-between my-2.5"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "text-[14px] font-bold text-zinc-400 uppercase tracking-tighter",
|
|
title: "Normalize"
|
|
}, "Norm"), /*#__PURE__*/React.createElement("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"
|
|
}), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-[14px] font-mono text-zinc-300 font-bold"
|
|
}, subTabNormVal, "dB"), /*#__PURE__*/React.createElement("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")), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "text-[14px] font-bold text-zinc-400 uppercase tracking-tighter",
|
|
title: "Pitch Shift"
|
|
}, "Pitch"), /*#__PURE__*/React.createElement("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"
|
|
}), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-[14px] font-mono text-zinc-300 font-bold"
|
|
}, subTabPitchVal > 0 ? '+' : '', subTabPitchVal, "st"), /*#__PURE__*/React.createElement("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")), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "text-[14px] font-bold text-zinc-400 uppercase tracking-tighter",
|
|
title: "Gain Multiplier"
|
|
}, "Gain"), /*#__PURE__*/React.createElement("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"
|
|
}), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-[14px] font-mono text-zinc-300 font-bold"
|
|
}, subTabGainVal, "%"), /*#__PURE__*/React.createElement("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"))), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex-1"
|
|
}), /*#__PURE__*/React.createElement("div", {
|
|
className: "mt-auto pt-2.5 border-t border-zinc-800"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center justify-between text-[14px] text-zinc-500 mb-1 font-semibold uppercase"
|
|
}, /*#__PURE__*/React.createElement("span", null, "Duration:"), /*#__PURE__*/React.createElement("span", {
|
|
className: "font-mono text-zinc-300"
|
|
}, st.buffer ? formatTime(st.buffer.duration) : '0s')), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center justify-between text-[14px] text-zinc-500 mb-2.5 font-semibold uppercase"
|
|
}, /*#__PURE__*/React.createElement("span", null, "SR:"), /*#__PURE__*/React.createElement("span", {
|
|
className: "font-mono text-zinc-300"
|
|
}, st.buffer ? st.buffer.sampleRate : 0, " Hz")), /*#__PURE__*/React.createElement("div", {
|
|
className: "grid grid-cols-2 gap-1 mb-2"
|
|
}, /*#__PURE__*/React.createElement("button", {
|
|
onClick: async e => {
|
|
subTabAiTrackIdRef.current = st.trackId;
|
|
handleAIScan();
|
|
},
|
|
disabled: analysisState.isRunning,
|
|
className: "py-1 bg-purple-900 hover:bg-purple-800 text-purple-100 font-bold rounded text-xs border border-purple-700 flex items-center justify-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "map-pin",
|
|
className: "w-3 h-3"
|
|
})), " Scan"), /*#__PURE__*/React.createElement("button", {
|
|
onClick: async e => {
|
|
subTabAiTrackIdRef.current = st.trackId;
|
|
handleAICutToNewTrack();
|
|
},
|
|
disabled: analysisState.isRunning,
|
|
className: "py-1 bg-fuchsia-800 hover:bg-fuchsia-700 text-white font-bold rounded text-xs flex items-center justify-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "scissors",
|
|
className: "w-3 h-3"
|
|
})), " Cut"), /*#__PURE__*/React.createElement("button", {
|
|
onClick: async e => {
|
|
subTabAiTrackIdRef.current = st.trackId;
|
|
if (st.buffer) {
|
|
setSelectionRangeOnBuffer(st.buffer, st.selectionStart || 0, st.selectionEnd || st.buffer.duration);
|
|
}
|
|
handleAIAnalysicLoop();
|
|
},
|
|
disabled: analysisState.isRunning,
|
|
className: "py-1 bg-violet-800 hover:bg-violet-700 text-violet-100 font-bold rounded text-xs border border-violet-600 flex items-center justify-center gap-1 col-span-2"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "sparkles",
|
|
className: "w-3 h-3"
|
|
})), " AI Analysic Loop")), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "download",
|
|
className: "w-4 h-4"
|
|
})), " Export"), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "save",
|
|
className: "w-4 h-4"
|
|
})), " Save"))) : /*#__PURE__*/React.createElement("div", {
|
|
className: "flex-1 flex items-center justify-center text-xs text-zinc-500"
|
|
}, "Track not found")), /*#__PURE__*/React.createElement("div", {
|
|
ref: timelineWrapperRef,
|
|
className: "flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
style: {
|
|
width: `${subTabTimelineWidth}px`
|
|
},
|
|
className: "relative flex flex-col min-h-full"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "sticky top-0 z-45 flex h-10 border-b border-zinc-900 bg-[#242424] shrink-0"
|
|
}, /*#__PURE__*/React.createElement("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 /*#__PURE__*/React.createElement("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));
|
|
}))), /*#__PURE__*/React.createElement("div", {
|
|
className: "sticky top-10 z-35 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] shrink-0"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex-1 relative h-full overflow-hidden bg-[#1a1a2e]"
|
|
}, /*#__PURE__*/React.createElement(TempoTrackLane, {
|
|
bpm: parseInt(bpm) || 120,
|
|
zoom: zoom,
|
|
timelineWidth: subTabTimelineWidth,
|
|
onPlayheadSet: setCurrentTime,
|
|
snapValue: snapValue
|
|
}))), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex-1 flex flex-col relative bg-[#111111] min-h-full"
|
|
}, vTrack && /*#__PURE__*/React.createElement("div", {
|
|
style: {
|
|
height: `${subTabHeight}px`
|
|
},
|
|
className: "relative overflow-hidden border-2 border-purple-500/40 rounded bg-[#151515] mx-1 mt-1 shrink-0"
|
|
}, /*#__PURE__*/React.createElement(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,
|
|
channelInfo: st.channelInfo,
|
|
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;
|
|
}
|
|
}), /*#__PURE__*/React.createElement("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"
|
|
})), st.selectionStart !== null && st.selectionEnd !== null && st.selectionEnd > st.selectionStart && /*#__PURE__*/React.createElement("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`
|
|
}
|
|
})))));
|
|
})())), /*#__PURE__*/React.createElement("div", {
|
|
className: "w-1 cursor-ew-resize hover:bg-cyan-500/50 active:bg-cyan-400 transition-colors shrink-0 z-10",
|
|
onMouseDown: startColResize
|
|
}), renderDock('right', 'Right')), renderDock('bottom', 'Bottom'));
|
|
})(), /*#__PURE__*/React.createElement("div", {
|
|
className: "h-6 bg-[#141414] border-t border-zinc-900 px-4 flex items-center justify-between text-xs text-zinc-500 select-none shrink-0"
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center gap-4"
|
|
}, /*#__PURE__*/React.createElement("span", null, "Status: ", isPlaying ? 'Playing' : 'Stopped'), activeTab !== 'main' && /*#__PURE__*/React.createElement("span", {
|
|
className: "text-amber-400 font-semibold uppercase"
|
|
}, "Sub-Tab"), activeTab === 'main' && /*#__PURE__*/React.createElement("span", {
|
|
className: "text-cyan-400 font-semibold uppercase"
|
|
}, "Track: ID ", selectedTrackId), selectionMode === 'local' && /*#__PURE__*/React.createElement("span", {
|
|
className: "text-amber-400 font-semibold uppercase text-xs"
|
|
}, "Local Sel"), selectionMode === 'global' && /*#__PURE__*/React.createElement("span", {
|
|
className: "text-purple-400 font-semibold uppercase text-xs"
|
|
}, "Global Sel"), soloedTrackId && /*#__PURE__*/React.createElement("span", {
|
|
className: "text-amber-500 font-semibold"
|
|
}, "Solo: ID ", soloedTrackId), isLoopingSelection && selectionMode === 'local' && /*#__PURE__*/React.createElement("span", {
|
|
className: "text-emerald-400 font-semibold uppercase text-xs"
|
|
}, "Solo Loop"), isLoopingSelection && selectionMode !== 'local' && /*#__PURE__*/React.createElement("span", {
|
|
className: "text-cyan-400 font-semibold uppercase text-xs"
|
|
}, "Master Loop")), /*#__PURE__*/React.createElement("div", {
|
|
className: "flex items-center gap-2"
|
|
}, /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => setShowExportPanel(p => !p),
|
|
className: `px-1.5 py-0.5 rounded text-xs 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})`
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "save",
|
|
className: "w-3 h-3"
|
|
})), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-[7px] opacity-60"
|
|
}, showExportPanel ? panelPositions.export[0].toUpperCase() : '')), /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => setShowSelectionPanel(p => !p),
|
|
className: `px-1.5 py-0.5 rounded text-xs 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})`
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "sliders",
|
|
className: "w-3 h-3"
|
|
})), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-[7px] opacity-60"
|
|
}, showSelectionPanel ? panelPositions.selection[0].toUpperCase() : '')), /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => setShowAIPanel(p => !p),
|
|
className: `px-1.5 py-0.5 rounded text-xs 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})`
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "cpu",
|
|
className: "w-3 h-3"
|
|
})), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-[7px] opacity-60"
|
|
}, showAIPanel ? panelPositions.ai[0].toUpperCase() : '')), /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => setShowFxRack(p => !p),
|
|
className: `px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showFxRack ? 'bg-rose-900 text-rose-300' : 'text-zinc-500 hover:text-zinc-300'}`,
|
|
title: `FX Rack Panel (${panelPositions.fx_rack || 'bottom'})`
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "sliders",
|
|
className: "w-3 h-3"
|
|
})), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-[7px] opacity-60"
|
|
}, showFxRack ? (panelPositions.fx_rack || 'bottom')[0].toUpperCase() : '')), /*#__PURE__*/React.createElement("button", {
|
|
onClick: () => setShowMidiEvents(p => !p),
|
|
className: `px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showMidiEvents ? 'bg-sky-900 text-sky-300' : 'text-zinc-500 hover:text-zinc-300'}`,
|
|
title: `MIDI Events Panel (${panelPositions.midi_events || 'bottom'})`
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "music",
|
|
className: "w-3 h-3"
|
|
})), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-[7px] opacity-60"
|
|
}, showMidiEvents ? (panelPositions.midi_events || 'bottom')[0].toUpperCase() : '')), /*#__PURE__*/React.createElement("span", {
|
|
className: "w-[1px] h-3 bg-zinc-800 mx-1"
|
|
}), /*#__PURE__*/React.createElement("span", {
|
|
className: "flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "info",
|
|
className: "w-3 h-3 text-zinc-600"
|
|
})), " Scroll: Zoom"), /*#__PURE__*/React.createElement("span", null, "|"), /*#__PURE__*/React.createElement("span", {
|
|
className: "flex items-center gap-1"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "keyboard",
|
|
className: "w-3 h-3 text-zinc-600"
|
|
})), " Ctrl+Scroll: Playhead"))), contextMenu && (contextMenu.isSubTab ? /*#__PURE__*/React.createElement("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()
|
|
}, /*#__PURE__*/React.createElement("div", {
|
|
className: "px-3 py-1 text-xs 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)), /*#__PURE__*/React.createElement("div", {
|
|
className: "h-px bg-zinc-700 my-1"
|
|
}), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "scissors",
|
|
className: "w-3.5 h-3.5 text-rose-400 shrink-0"
|
|
})), /*#__PURE__*/React.createElement("span", {
|
|
className: "flex-1"
|
|
}, "Cut"), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-purple-400 text-xs font-semibold font-mono ml-auto"
|
|
}, "Ctrl+X")), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "copy",
|
|
className: "w-3.5 h-3.5 text-zinc-400 shrink-0"
|
|
})), /*#__PURE__*/React.createElement("span", {
|
|
className: "flex-1"
|
|
}, "Copy"), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-purple-400 text-xs font-semibold font-mono ml-auto"
|
|
}, "Ctrl+C")), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "clipboard",
|
|
className: "w-3.5 h-3.5 text-emerald-400 shrink-0"
|
|
})), /*#__PURE__*/React.createElement("span", {
|
|
className: "flex-1"
|
|
}, "Paste"), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-purple-400 text-xs font-semibold font-mono ml-auto"
|
|
}, "Ctrl+V")), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "trash-2",
|
|
className: "w-3.5 h-3.5 text-red-400 shrink-0"
|
|
})), /*#__PURE__*/React.createElement("span", {
|
|
className: "flex-1"
|
|
}, "Delete Selected Segment"), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-amber-400 text-xs font-semibold font-mono ml-auto"
|
|
}, "Del")), /*#__PURE__*/React.createElement("div", {
|
|
className: "h-px bg-zinc-700 my-1"
|
|
}), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "repeat",
|
|
className: "w-3.5 h-3.5 text-cyan-400 shrink-0"
|
|
})), /*#__PURE__*/React.createElement("span", {
|
|
className: "flex-1"
|
|
}, "Loop Selection 4 times"), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-purple-400 text-xs font-semibold font-mono ml-auto"
|
|
}, "Ctrl+L"))) : /*#__PURE__*/React.createElement("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()
|
|
}, /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "file-edit",
|
|
className: "w-3.5 h-3.5 text-amber-400 shrink-0"
|
|
})), /*#__PURE__*/React.createElement("span", {
|
|
className: "flex-1"
|
|
}, "Edit"), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"
|
|
}, "Ctrl+E")), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "scissors",
|
|
className: "w-3.5 h-3.5 text-cyan-400 shrink-0"
|
|
})), /*#__PURE__*/React.createElement("span", {
|
|
className: "flex-1"
|
|
}, "Split"), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"
|
|
}, "S")), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "combine",
|
|
className: "w-3.5 h-3.5 text-purple-400 shrink-0"
|
|
})), /*#__PURE__*/React.createElement("span", {
|
|
className: "flex-1"
|
|
}, "Merge"), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"
|
|
}, "Ctrl+M")), /*#__PURE__*/React.createElement("div", {
|
|
className: "h-px bg-zinc-700 my-1"
|
|
}), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "copy",
|
|
className: "w-3.5 h-3.5 text-zinc-400 shrink-0"
|
|
})), /*#__PURE__*/React.createElement("span", {
|
|
className: "flex-1"
|
|
}, "Copy"), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"
|
|
}, "Ctrl+C")), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "scissors",
|
|
className: "w-3.5 h-3.5 text-rose-400 shrink-0"
|
|
})), /*#__PURE__*/React.createElement("span", {
|
|
className: "flex-1"
|
|
}, "Cut"), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"
|
|
}, "Ctrl+X")), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "clipboard",
|
|
className: "w-3.5 h-3.5 text-emerald-400 shrink-0"
|
|
})), /*#__PURE__*/React.createElement("span", {
|
|
className: "flex-1"
|
|
}, "Paste"), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8"
|
|
}, "Ctrl+V")), /*#__PURE__*/React.createElement("div", {
|
|
className: "h-px bg-zinc-700 my-1"
|
|
}), /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("i", {
|
|
"data-lucide": "trash-2",
|
|
className: "w-3.5 h-3.5 text-red-400 shrink-0"
|
|
})), /*#__PURE__*/React.createElement("span", {
|
|
className: "flex-1"
|
|
}, "Delete"), /*#__PURE__*/React.createElement("span", {
|
|
className: "text-amber-400 text-xs font-semibold font-mono ml-auto pl-8"
|
|
}, "Del")))), toastMessage && /*#__PURE__*/React.createElement("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"
|
|
}, /*#__PURE__*/React.createElement("span", {
|
|
className: "inline-flex items-center shrink-0"
|
|
}, /*#__PURE__*/React.createElement("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'}`
|
|
})), toastMessage.text), /*#__PURE__*/React.createElement(AuthModal, {
|
|
isOpen: authModalOpen,
|
|
mode: authMode,
|
|
forceMandatory: isMandatoryLogin,
|
|
onClose: () => setAuthModalOpen(false),
|
|
onSuccess: handleAuthSuccess
|
|
}), /*#__PURE__*/React.createElement(ProfileModal, {
|
|
isOpen: profileModalOpen,
|
|
onClose: () => setProfileModalOpen(false)
|
|
}), /*#__PURE__*/React.createElement(AIConfigModal, {
|
|
isOpen: aiConfigModalOpen,
|
|
onClose: () => setAiConfigModalOpen(false),
|
|
onConfigSaved: (providers) => {
|
|
setAiProviders(providers);
|
|
const active = providers.find(p => p.is_active) || providers[0];
|
|
if (active) setSelectedProviderId(active.id);
|
|
}
|
|
}), /*#__PURE__*/React.createElement(SystemManagerModal, {
|
|
isOpen: systemManagerModalOpen,
|
|
onClose: () => setSystemManagerModalOpen(false)
|
|
}));
|
|
};
|
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
root.render(/*#__PURE__*/React.createElement(App, null));
|
|
setTimeout(() => lucide.createIcons(), 300);
|