fix: chỉnh sửa giao diện của main session
This commit is contained in:
+386
-217
@@ -668,7 +668,7 @@
|
||||
};
|
||||
|
||||
// ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2 & SUB_EDITOR.md) ──
|
||||
const SubTabWaveform = ({ buffer, subTabId, activeTab, currentTime, selectionStart, selectionEnd, onSelectRange, onPlayheadSet, onContextMenu, activeTool }) => {
|
||||
const SubTabWaveform = ({ buffer, subTabId, activeTab, currentTime, selectionStart, selectionEnd, onSelectRange, onPlayheadSet, onContextMenu, activeTool, zoom, timelineWidth }) => {
|
||||
const canvasRef = useRef(null);
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
@@ -676,12 +676,12 @@
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
const w = timelineWidth;
|
||||
const h = rect.height;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
ctx.fillStyle = '#181818';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
@@ -689,30 +689,46 @@
|
||||
const len = data.length;
|
||||
if (len === 0) return;
|
||||
|
||||
// Draw waveform
|
||||
ctx.fillStyle = '#6ee7b7';
|
||||
// Draw waveform only in viewport range for performance & zero-line accuracy
|
||||
const xStart = 0;
|
||||
const wClip = buffer.duration * zoom;
|
||||
const xEnd = xStart + wClip;
|
||||
const drawXStart = Math.max(0, Math.floor(xStart));
|
||||
const drawXEnd = Math.min(w, Math.ceil(xEnd));
|
||||
const samplesPerPixel = buffer.sampleRate / zoom;
|
||||
|
||||
ctx.strokeStyle = '#6ee7b7';
|
||||
ctx.lineWidth = 1;
|
||||
const mid = h / 2;
|
||||
for (let px = 0; px < w; px++) {
|
||||
const start = Math.floor((px / w) * len);
|
||||
const end = Math.floor(((px + 1) / w) * len);
|
||||
|
||||
for (let px = drawXStart; px < drawXEnd; px++) {
|
||||
const time = px / zoom;
|
||||
const sampleIdx = Math.floor(time * 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 maxVal = 0;
|
||||
let minVal = 0;
|
||||
for (let i = start; i < end && i < len; i++) {
|
||||
for (let i = chunkStart; i < chunkEnd; i++) {
|
||||
const val = data[i];
|
||||
if (val > maxVal) maxVal = val;
|
||||
if (val < minVal) minVal = val;
|
||||
}
|
||||
const yTop = mid + (minVal * (h * 0.45));
|
||||
const yBottom = mid + (maxVal * (h * 0.45));
|
||||
ctx.fillRect(px, yTop, 1, Math.max(1, yBottom - yTop));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px, yTop);
|
||||
ctx.lineTo(px, yBottom);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 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 / buffer.duration) * w;
|
||||
const rightPx = (right / buffer.duration) * w;
|
||||
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';
|
||||
@@ -721,20 +737,20 @@
|
||||
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) {
|
||||
const playheadPx = (currentTime / buffer.duration) * w;
|
||||
ctx.strokeStyle = '#ef4444';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(playheadPx, 0);
|
||||
ctx.lineTo(playheadPx, h);
|
||||
ctx.stroke();
|
||||
}
|
||||
// Draw playhead
|
||||
if (currentTime !== null && currentTime >= 0 && currentTime <= buffer.duration) {
|
||||
const playheadPx = currentTime * zoom;
|
||||
ctx.strokeStyle = '#ef4444';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(playheadPx, 0);
|
||||
ctx.lineTo(playheadPx, h);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
}, [buffer, currentTime, selectionStart, selectionEnd]);
|
||||
}, [buffer, currentTime, selectionStart, selectionEnd, zoom, timelineWidth]);
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
if (e.button === 2) return; // ignore right click
|
||||
@@ -972,8 +988,8 @@
|
||||
const App = () => {
|
||||
// ── State Definitions ──
|
||||
const [tracks, setTracks] = useState([
|
||||
{ id: '1', name: 'Track 01', buffer: null, startTime: 0, height: 96, volume: 0.8, muted: false, solo: false, color: '#0f766e', markers: [], serverFileId: null },
|
||||
{ id: '2', name: 'Track 02', buffer: null, startTime: 0, height: 96, volume: 0.8, muted: false, solo: false, color: '#1d4ed8', markers: [], serverFileId: null },
|
||||
{ id: '1', name: 'Track 01', buffer: null, startTime: 0, height: 96, volumeDb: 0, pan: 0, muted: false, solo: false, color: '#0f766e', markers: [], serverFileId: null },
|
||||
{ id: '2', name: 'Track 02', buffer: null, startTime: 0, height: 96, volumeDb: 0, pan: 0, muted: false, solo: false, color: '#1d4ed8', markers: [], serverFileId: null },
|
||||
]);
|
||||
const [bpm, setBpm] = useState(localStorage.getItem('studio_bpm') || '120');
|
||||
const [draggedClip, setDraggedClip] = useState(null); // { trackId, clickOffset, buffer, name, volume, color }
|
||||
@@ -1012,6 +1028,9 @@
|
||||
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 [isExporting, setIsExporting] = useState(false);
|
||||
const [soloedTrackId, setSoloedTrackId] = useState(null);
|
||||
const [toastMessage, setToastMessage] = useState(null);
|
||||
@@ -1019,7 +1038,7 @@
|
||||
const [showExportPanel, setShowExportPanel] = useState(true);
|
||||
const [showAIPanel, setShowAIPanel] = useState(true);
|
||||
const [showSelectionPanel, setShowSelectionPanel] = useState(true);
|
||||
const [panelPositions, setPanelPositions] = useState({ export: 'right', ai: 'right', selection: 'right' });
|
||||
const [panelPositions, setPanelPositions] = useState({ export: 'bottom', ai: 'bottom', selection: 'right' });
|
||||
const [panelDropZone, setPanelDropZone] = useState(null);
|
||||
const [dragGhostPos, setDragGhostPos] = useState(null);
|
||||
const [dragGhostPanel, setDragGhostPanel] = useState(null);
|
||||
@@ -1044,6 +1063,8 @@
|
||||
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 }
|
||||
@@ -1099,7 +1120,8 @@
|
||||
const track = tracks.find(t => t.id === trackId);
|
||||
if (!track) return null;
|
||||
return {
|
||||
volume: track.volume,
|
||||
volumeDb: track.volumeDb,
|
||||
pan: track.pan,
|
||||
muted: track.muted,
|
||||
name: track.name,
|
||||
markers: JSON.parse(JSON.stringify(track.markers || [])),
|
||||
@@ -1202,6 +1224,7 @@
|
||||
};
|
||||
const rulerRef = useRef(null);
|
||||
const activeSourcesRef = useRef([]);
|
||||
const activeTrackNodesRef = useRef({}); // { [trackId]: { gainNode, pannerNode } }
|
||||
const startOffsetTimeRef = useRef(0);
|
||||
const startAudioTimeRef = useRef(0);
|
||||
const animationFrameIdRef = useRef(null);
|
||||
@@ -1468,15 +1491,21 @@
|
||||
showToast(`Đã lặp vùng chọn ${loopCount} lần.`, 'success');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
const alt = e.altKey;
|
||||
|
||||
// Global space play/pause shortcut for transport
|
||||
if (e.key === ' ' || e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
if (handlePlayPauseRef.current) handlePlayPauseRef.current();
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTabRef.current !== 'main') {
|
||||
// Sub-Tab keyboard shortcuts mapping
|
||||
const curTabId = activeTabRef.current;
|
||||
if (e.key === ' ' || e.code === 'Space') { e.preventDefault(); if (handlePlayPauseRef.current) handlePlayPauseRef.current(); return; }
|
||||
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; }
|
||||
@@ -1491,7 +1520,7 @@
|
||||
if (ctrl && e.key === 'z' && !e.shiftKey) { e.preventDefault(); handleUndoRef.current(); return; }
|
||||
if (ctrl && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); handleRedoRef.current(); return; }
|
||||
if (ctrl && !alt && e.key === 'o') { e.preventDefault(); showToast('Open Project dialog','info'); return; }
|
||||
if (ctrl && !alt && e.key === 'n') { e.preventDefault(); setTracks([{ id:'1', name:'Track 01', buffer:null, startTime:0, height:96, volume:0.8, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, startTime:0, height:96, volume:0.8, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','info'); return; }
|
||||
if (ctrl && !alt && e.key === 'n') { e.preventDefault(); setTracks([{ id:'1', name:'Track 01', buffer:null, startTime:0, height:96, volumeDb:0, pan:0, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, startTime:0, height:96, volumeDb:0, pan:0, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','info'); return; }
|
||||
if (ctrl && !alt && e.key === 's') { e.preventDefault(); showToast('Project saved','success'); return; }
|
||||
if ((ctrl && alt && e.key === 's') || (ctrl && e.shiftKey && e.key === 's')) { e.preventDefault(); showToast('Save As dialog','info'); return; }
|
||||
if (ctrl && !alt && e.key === 'i') { e.preventDefault(); addNewTrack(); return; }
|
||||
@@ -1943,7 +1972,7 @@
|
||||
const ctx = getAudioContext();
|
||||
const clipBuffer = ctx.createBuffer(1, len, sr);
|
||||
clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0);
|
||||
clipboardRef.current = { buffer: clipBuffer, name: track.name, volume: track.volume, color: track.color };
|
||||
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;
|
||||
@@ -1954,7 +1983,8 @@
|
||||
clipboardRef.current = {
|
||||
buffer: track.buffer,
|
||||
name: track.name,
|
||||
volume: track.volume,
|
||||
volumeDb: track.volumeDb,
|
||||
pan: track.pan,
|
||||
color: track.color,
|
||||
};
|
||||
closeContextMenu();
|
||||
@@ -1978,7 +2008,7 @@
|
||||
const ctx = getAudioContext();
|
||||
const clipBuffer = ctx.createBuffer(1, len, sr);
|
||||
clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0);
|
||||
clipboardRef.current = { buffer: clipBuffer, name: t.name, volume: t.volume, color: t.color };
|
||||
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);
|
||||
@@ -2002,7 +2032,7 @@
|
||||
showToast('Clipboard trống.', 'warning');
|
||||
return null;
|
||||
}
|
||||
const { buffer: clipBuffer, name, volume, color } = clipboardRef.current;
|
||||
const { buffer: clipBuffer, name, volumeDb, pan, color } = clipboardRef.current;
|
||||
const ctx = getAudioContext();
|
||||
|
||||
const targetTrack = tracks.find(t => t.id === targetTrackId);
|
||||
@@ -2031,7 +2061,8 @@
|
||||
buffer: updatedClips[0].buffer,
|
||||
startTime: updatedClips[0].startTime,
|
||||
name: name || t.name,
|
||||
volume: volume || t.volume,
|
||||
volumeDb: volumeDb ?? t.volumeDb,
|
||||
pan: pan ?? t.pan,
|
||||
color: color || t.color
|
||||
};
|
||||
}
|
||||
@@ -2051,7 +2082,7 @@
|
||||
buffer: clipBuffer,
|
||||
startTime: pasteTime,
|
||||
clips: [newClip],
|
||||
volume: volume || 0.8, muted: false, solo: false,
|
||||
volumeDb: volumeDb ?? 0, pan: pan ?? 0, muted: false, solo: false,
|
||||
color: color || colors[prev.length % colors.length],
|
||||
markers: [], serverFileId: null,
|
||||
}]);
|
||||
@@ -2083,9 +2114,10 @@
|
||||
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] * t.volume;
|
||||
mergedData[startSample + i] += data[i] * volLinear;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -2102,7 +2134,7 @@
|
||||
const names = activeTracks.map(t => t.name).join('+').slice(0, 30);
|
||||
setTracks(prev => [...prev, {
|
||||
id: newId, name: `Merged_${names}.wav`, buffer: merged, startTime: 0,
|
||||
volume: 0.8, muted: false, solo: false,
|
||||
volumeDb: 0, pan: 0, muted: false, solo: false,
|
||||
color: colors[prev.length % colors.length], markers: [], serverFileId: null,
|
||||
}]);
|
||||
setSelectedTrackId(newId);
|
||||
@@ -2122,15 +2154,16 @@
|
||||
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] * t.volume;
|
||||
mdata[startSample + i] += d[i] * volLinear;
|
||||
}
|
||||
}
|
||||
});
|
||||
let mp = 0; for (let i=0;i<mdata.length;i++) { const a=Math.abs(mdata[i]); if (a>mp) mp=a; }
|
||||
if (mp > 1.0) for (let i=0;i<mdata.length;i++) mdata[i] /= mp;
|
||||
setTracks(p => [...p, { id:'merged_'+Date.now(), name:'Merged_mix.wav', buffer:mb, startTime: 0, height: 96, volume:0.8, muted:false, solo:false, color:['#0f766e','#1d4ed8'][p.length%2], markers:[], serverFileId:null }]);
|
||||
setTracks(p => [...p, { id:'merged_'+Date.now(), name:'Merged_mix.wav', buffer:mb, startTime: 0, height: 96, volumeDb:0, pan:0, muted:false, solo:false, color:['#0f766e','#1d4ed8'][p.length%2], markers:[], serverFileId:null }]);
|
||||
showToast('Merged all unmuted tracks.','success');
|
||||
};
|
||||
const handleCopyTrack = () => {
|
||||
@@ -2150,13 +2183,13 @@
|
||||
const ctx = getAudioContext();
|
||||
const clipBuffer = ctx.createBuffer(1, len, sr);
|
||||
clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0);
|
||||
clipboardRef.current = { buffer: clipBuffer, name: t.name, volume: t.volume, color: t.color };
|
||||
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, volume: t.volume, color: t.color };
|
||||
clipboardRef.current = { buffer: t.buffer, name: t.name, volumeDb: t.volumeDb, color: t.color };
|
||||
showToast('Copied track to clipboard.', 'info');
|
||||
};
|
||||
const handleCutTrack = () => {
|
||||
@@ -2178,7 +2211,7 @@
|
||||
const ctx = getAudioContext();
|
||||
const clipBuffer = ctx.createBuffer(1, len, sr);
|
||||
clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0);
|
||||
clipboardRef.current = { buffer: clipBuffer, name: t.name, volume: t.volume, color: t.color };
|
||||
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);
|
||||
@@ -2374,6 +2407,51 @@
|
||||
}, [zoom, minZoom, maxDuration, activeTab]);
|
||||
|
||||
// ── Update Playhead ──
|
||||
const startSubTabPlayback = (st, offsetTime) => {
|
||||
const context = getAudioContext();
|
||||
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;
|
||||
|
||||
const gainNode = context.createGain();
|
||||
gainNode.gain.setValueAtTime(1.0, context.currentTime);
|
||||
|
||||
source.connect(gainNode);
|
||||
gainNode.connect(context.destination);
|
||||
|
||||
source.start(context.currentTime, offsetTime);
|
||||
activeSourcesRef.current = [source];
|
||||
startOffsetTimeRef.current = offsetTime;
|
||||
startAudioTimeRef.current = context.currentTime;
|
||||
};
|
||||
|
||||
const updatePlayhead = () => {
|
||||
if (activeTab !== 'main') {
|
||||
const st = subTabs.find(s => s.id === activeTab);
|
||||
@@ -2393,40 +2471,7 @@
|
||||
currentTime: start,
|
||||
isPlaying: true
|
||||
} : s));
|
||||
// Restart subtab buffer segment
|
||||
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.connect(context.destination);
|
||||
source.start(context.currentTime, start);
|
||||
activeSourcesRef.current = [source];
|
||||
startOffsetTimeRef.current = start;
|
||||
startAudioTimeRef.current = context.currentTime;
|
||||
startSubTabPlayback(st, start);
|
||||
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
|
||||
return;
|
||||
}
|
||||
@@ -2434,7 +2479,17 @@
|
||||
|
||||
if (updatedTime >= st.buffer.duration) {
|
||||
stopAllPlayback();
|
||||
setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, currentTime: 0, isPlaying: false } : s));
|
||||
if (isLoopingSelection) {
|
||||
setSubTabs(prev => prev.map(s => s.id === activeTab ? {
|
||||
...s,
|
||||
currentTime: 0,
|
||||
isPlaying: true
|
||||
} : s));
|
||||
startSubTabPlayback(st, 0);
|
||||
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
|
||||
} else {
|
||||
setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, currentTime: 0, isPlaying: false } : s));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2504,6 +2559,18 @@
|
||||
|
||||
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,
|
||||
@@ -2519,11 +2586,8 @@
|
||||
source.buffer = clip.buffer;
|
||||
source.playbackRate.value = clip.speed || 1.0;
|
||||
|
||||
const gainNode = context.createGain();
|
||||
gainNode.gain.setValueAtTime(track.volume, context.currentTime);
|
||||
|
||||
source.connect(gainNode);
|
||||
gainNode.connect(context.destination);
|
||||
gainNode.connect(pannerNode);
|
||||
|
||||
const clipStart = clip.startTime || 0;
|
||||
const clipDuration = clip.buffer.duration / (clip.speed || 1.0);
|
||||
@@ -2556,6 +2620,18 @@
|
||||
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;
|
||||
|
||||
@@ -2563,11 +2639,8 @@
|
||||
source.buffer = clip.buffer;
|
||||
source.playbackRate.value = clip.speed || 1.0;
|
||||
|
||||
const gainNode = context.createGain();
|
||||
gainNode.gain.setValueAtTime(track.volume, context.currentTime);
|
||||
|
||||
source.connect(gainNode);
|
||||
gainNode.connect(context.destination);
|
||||
gainNode.connect(pannerNode);
|
||||
|
||||
const clipStart = clip.startTime || 0;
|
||||
const clipDuration = clip.buffer.duration / (clip.speed || 1.0);
|
||||
@@ -2590,58 +2663,14 @@
|
||||
// Sub-tab playback transport
|
||||
const st = subTabs.find(s => s.id === activeTab);
|
||||
if (!st || !st.buffer) return;
|
||||
const context = getAudioContext();
|
||||
|
||||
if (st.isPlaying) {
|
||||
stopAllPlayback();
|
||||
setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, isPlaying: false } : s));
|
||||
} else {
|
||||
stopAllPlayback();
|
||||
// Clone buffer and apply effects for listening
|
||||
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;
|
||||
|
||||
const gainNode = context.createGain();
|
||||
gainNode.gain.setValueAtTime(1.0, context.currentTime);
|
||||
|
||||
source.connect(gainNode);
|
||||
gainNode.connect(context.destination);
|
||||
|
||||
const startOffset = st.currentTime || 0;
|
||||
source.start(context.currentTime, startOffset);
|
||||
|
||||
activeSourcesRef.current = [source];
|
||||
startOffsetTimeRef.current = startOffset;
|
||||
startAudioTimeRef.current = context.currentTime;
|
||||
|
||||
startSubTabPlayback(st, startOffset);
|
||||
setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, isPlaying: true } : s));
|
||||
}
|
||||
return;
|
||||
@@ -2667,6 +2696,7 @@
|
||||
try { src.stop(); } catch(e) {}
|
||||
});
|
||||
activeSourcesRef.current = [];
|
||||
activeTrackNodesRef.current = {};
|
||||
setIsPlaying(false);
|
||||
setSubTabs(prev => prev.map(s => ({ ...s, isPlaying: false })));
|
||||
};
|
||||
@@ -3249,9 +3279,9 @@
|
||||
});
|
||||
};
|
||||
|
||||
const updateTrackVolume = (trackId, val) => {
|
||||
const updateTrackVolumeDb = (trackId, val) => {
|
||||
const beforeSnap = captureTrackSnapshot(trackId);
|
||||
setTracks(prev => prev.map(t => t.id === trackId ? { ...t, volume: val } : t));
|
||||
setTracks(prev => prev.map(t => t.id === trackId ? { ...t, volumeDb: val } : t));
|
||||
setUndoStack(prev => {
|
||||
const next = [...prev, {
|
||||
action_type: 'VOLUME_CHANGE',
|
||||
@@ -3263,6 +3293,65 @@
|
||||
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) ──
|
||||
@@ -3334,11 +3423,11 @@
|
||||
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
|
||||
const selectColor = colors[tracks.length % colors.length];
|
||||
|
||||
setTracks(prev => [...prev, {
|
||||
id: newId, name: `Track ${newId}`, buffer: null, startTime: 0, height: 96,
|
||||
volume: 0.8, muted: false, solo: false,
|
||||
color: selectColor, markers: [], serverFileId: null
|
||||
}]);
|
||||
setTracks(prev => [...prev, {
|
||||
id: newId, name: `Track ${newId}`, buffer: null, startTime: 0, height: 96,
|
||||
volumeDb: 0, pan: 0, muted: false, solo: false,
|
||||
color: selectColor, markers: [], serverFileId: null
|
||||
}]);
|
||||
showToast(`Đã thêm Track ${newId}.`, 'info');
|
||||
setTimeout(() => lucide.createIcons(), 200);
|
||||
return newId;
|
||||
@@ -3365,7 +3454,7 @@
|
||||
const tracksMeta = activeTracks.map(t => ({
|
||||
track_id: t.id,
|
||||
file_id: serverFileIdMap[t.id],
|
||||
volume: t.volume,
|
||||
volume_db: t.volumeDb,
|
||||
muted: false,
|
||||
clips: [{
|
||||
clip_id: `clip_${t.id}`,
|
||||
@@ -3465,10 +3554,16 @@
|
||||
source.playbackRate.value = clip.speed || 1.0;
|
||||
|
||||
const gain = offlineCtx.createGain();
|
||||
gain.gain.setValueAtTime(t.volume, 0);
|
||||
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(offlineCtx.destination);
|
||||
gain.connect(panner);
|
||||
panner.connect(offlineCtx.destination);
|
||||
|
||||
const clipStart = clip.startTime || 0;
|
||||
source.start(clipStart);
|
||||
@@ -3731,7 +3826,8 @@
|
||||
startTime: snapStart,
|
||||
name: `AI_Cut_${activeTrack.name.replace('.wav', '')}_${snapStart.toFixed(1)}s`
|
||||
}],
|
||||
volume: 0.8,
|
||||
volumeDb: 0,
|
||||
pan: 0,
|
||||
muted: false,
|
||||
solo: false,
|
||||
color: selectColor,
|
||||
@@ -3945,7 +4041,7 @@
|
||||
<header className="h-9 bg-[#2e2e2e] border-b border-[#181818] flex items-center px-1 shrink-0 select-none">
|
||||
{[
|
||||
{ label: 'File', items: [
|
||||
{ label: 'New Project', icon: 'file-plus', shortcut: 'Ctrl+N', action: () => { setTracks([{ id:'1', name:'Track 01', buffer:null, startTime:0, height:96, volume:0.8, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, startTime:0, height:96, volume:0.8, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','info'); } },
|
||||
{ label: 'New Project', icon: 'file-plus', shortcut: 'Ctrl+N', action: () => { setTracks([{ id:'1', name:'Track 01', buffer:null, startTime:0, height:96, volumeDb:0, pan:0, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, startTime:0, height:96, volumeDb:0, pan:0, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','info'); } },
|
||||
{ label: 'Open Project...', icon: 'folder-open', shortcut: 'Ctrl+O', action: () => showToast('Open project dialog','info') },
|
||||
{ label: 'Save Project', icon: 'save', shortcut: 'Ctrl+S', action: () => showToast('Project saved','success') },
|
||||
{ label: 'Save As...', icon: 'save', shortcut: 'Ctrl+Alt+S', action: () => showToast('Save as dialog','info') },
|
||||
@@ -4105,65 +4201,67 @@
|
||||
|
||||
{/* ── Transport Bar + Toolbar Overlay ── */}
|
||||
<div className="h-9 bg-[#222] border-b border-zinc-800 flex items-center px-3 gap-2 shrink-0 select-none">
|
||||
{/* Toolbar Overlay Panel — có thể kéo thả hoặc ghim sau này */}
|
||||
<div className="flex items-center gap-1 bg-zinc-900 border border-zinc-700 rounded px-1.5 py-1 shadow-lg h-7 cursor-grab active:cursor-grabbing"
|
||||
{/* Toolbar Overlay Panel */}
|
||||
<div className="flex items-center gap-1 bg-zinc-900 border border-zinc-700 rounded px-1.5 py-0.5 shadow-lg"
|
||||
title="Kéo để di chuyển toolbar" style={{ cursor: 'grab' }}>
|
||||
<div className="flex items-center gap-0.5 mr-1 text-zinc-600">
|
||||
<i data-lucide="grip-vertical" className="w-3 h-3"></i>
|
||||
</div>
|
||||
<button onClick={() => { setActiveTool('select'); showToast('Select Tool', 'info'); }}
|
||||
className={`p-0.5 rounded ${activeTool === 'select' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`}
|
||||
title="Select Tool">
|
||||
className={`w-7 h-7 flex items-center justify-center rounded ${activeTool === 'select' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`}
|
||||
title="Select Tool (V)">
|
||||
<i data-lucide="mouse-pointer" className="w-3.5 h-3.5"></i>
|
||||
</button>
|
||||
<button onClick={() => { setActiveTool('grab'); showToast('Grab Tool', 'info'); }}
|
||||
className={`p-0.5 rounded ${activeTool === 'grab' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`}
|
||||
title="Grab Tool">
|
||||
className={`w-7 h-7 flex items-center justify-center rounded ${activeTool === 'grab' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`}
|
||||
title="Grab Tool (H)">
|
||||
<i data-lucide="hand" className="w-3.5 h-3.5"></i>
|
||||
</button>
|
||||
<button onClick={() => { setActiveTool('razor'); showToast('Razor Tool', 'info'); }}
|
||||
className={`p-0.5 rounded ${activeTool === 'razor' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`}
|
||||
title="Razor Tool">
|
||||
<svg className="w-3.5 h-3.5 text-orange-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 3h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/>
|
||||
<path d="M4 9h16l-3 9H7z"/>
|
||||
<circle cx="12" cy="6" r="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex items-center gap-0.5 border border-zinc-700 rounded bg-zinc-850 px-0.5">
|
||||
<button onClick={() => { setActiveTool('razor'); showToast('Razor Tool', 'info'); }}
|
||||
className={`w-7 h-7 flex items-center justify-center rounded ${activeTool === 'razor' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`}
|
||||
title="Razor Tool (C)">
|
||||
<svg className="w-3.5 h-3.5 text-orange-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 3h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/>
|
||||
<path d="M4 9h16l-3 9H7z"/>
|
||||
<circle cx="12" cy="6" r="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button onClick={handleGlueTracks}
|
||||
className="w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-purple-400 hover:bg-zinc-800"
|
||||
title="Glue Clips">
|
||||
<i data-lucide="link" className="w-3.5 h-3.5"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => { setActiveTool('pen'); showToast('Pen Tool', 'info'); }}
|
||||
className={`p-0.5 rounded ${activeTool === 'pen' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`}
|
||||
title="Pen Tool">
|
||||
className={`w-7 h-7 flex items-center justify-center rounded ${activeTool === 'pen' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`}
|
||||
title="Pen Tool (P)">
|
||||
<i data-lucide="pen-tool" className="w-3.5 h-3.5"></i>
|
||||
</button>
|
||||
<div className="w-[1px] h-4 bg-zinc-700 mx-0.5"></div>
|
||||
<button onClick={handleGlueTracks}
|
||||
className="p-0.5 rounded text-zinc-400 hover:text-purple-400 hover:bg-zinc-800"
|
||||
title="Glue Clips">
|
||||
<i data-lucide="link" className="w-3.5 h-3.5"></i>
|
||||
</button>
|
||||
<div className="w-[1px] h-5 bg-zinc-700 mx-0.5"></div>
|
||||
<button onClick={handleCutTrack}
|
||||
className="p-0.5 rounded text-zinc-400 hover:text-red-400 hover:bg-zinc-800"
|
||||
className="w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-red-400 hover:bg-zinc-800"
|
||||
title="Cut (Ctrl+X)">
|
||||
<i data-lucide="scissors" className="w-3.5 h-3.5"></i>
|
||||
</button>
|
||||
<button onClick={handleCopyTrack}
|
||||
className="p-0.5 rounded text-zinc-400 hover:text-blue-400 hover:bg-zinc-800"
|
||||
className="w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-blue-400 hover:bg-zinc-800"
|
||||
title="Copy (Ctrl+C)">
|
||||
<i data-lucide="copy" className="w-3.5 h-3.5"></i>
|
||||
</button>
|
||||
<button onClick={handlePasteTrack}
|
||||
className="p-0.5 rounded text-zinc-400 hover:text-emerald-400 hover:bg-zinc-800"
|
||||
className="w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-emerald-400 hover:bg-zinc-800"
|
||||
title="Paste (Ctrl+V)">
|
||||
<i data-lucide="clipboard" className="w-3.5 h-3.5"></i>
|
||||
</button>
|
||||
<div className="w-[1px] h-4 bg-zinc-700 mx-0.5"></div>
|
||||
<div className="w-[1px] h-5 bg-zinc-700 mx-0.5"></div>
|
||||
<button onClick={handleUndo} disabled={undoStack.length === 0}
|
||||
className="p-0.5 rounded text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 disabled:opacity-30"
|
||||
className="w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 disabled:opacity-30"
|
||||
title="Undo (Ctrl+Z)">
|
||||
<i data-lucide="undo" className="w-3.5 h-3.5"></i>
|
||||
</button>
|
||||
<button onClick={handleRedo} disabled={redoStack.length === 0}
|
||||
className="p-0.5 rounded text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 disabled:opacity-30"
|
||||
className="w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 disabled:opacity-30"
|
||||
title="Redo (Ctrl+Y)">
|
||||
<i data-lucide="redo" className="w-3.5 h-3.5"></i>
|
||||
</button>
|
||||
@@ -4204,6 +4302,41 @@
|
||||
}`} title="Bật/Tắt Lặp vùng chọn">
|
||||
<i data-lucide="repeat" className="w-3.5 h-3.5"></i>
|
||||
</button>
|
||||
<span className="text-[9px] text-zinc-500 font-bold uppercase ml-2">Snap</span>
|
||||
<select value={snapValue} onChange={e => setSnapValue(e.target.value)}
|
||||
className="bg-black text-white text-[9px] px-1 py-0.5 rounded border border-zinc-700 focus:outline-none focus:border-cyan-500 font-mono cursor-pointer">
|
||||
<option value="free">Free</option><option value="1">1</option>
|
||||
<option value="1/2">1/2</option><option value="1/4">1/4</option>
|
||||
<option value="1/8">1/8</option><option value="1/16">1/16</option><option value="1/32">1/32</option>
|
||||
</select>
|
||||
<div className="w-[1px] h-5 bg-zinc-800 mx-1"></div>
|
||||
<span className="text-[9px] text-zinc-500 font-bold">Bars:</span>
|
||||
<input type="number" min="1" value={beginBar} onChange={e => {
|
||||
const b = parseInt(e.target.value) || 1;
|
||||
setBeginBar(b);
|
||||
const beatDuration = 60 / parseInt(bpm || 120);
|
||||
const t = (b - 1) * beatDuration * 4;
|
||||
setSelectionStart(t); setSelectionEnd(Math.max(t, selRight || t));
|
||||
}} className="w-10 bg-black text-white text-[9px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" />
|
||||
<span className="text-[9px] text-zinc-500">-</span>
|
||||
<input type="number" min="1" value={endBar} onChange={e => {
|
||||
const b = parseInt(e.target.value) || 1;
|
||||
setEndBar(b);
|
||||
const beatDuration = 60 / parseInt(bpm || 120);
|
||||
const t = (b - 1) * beatDuration * 4;
|
||||
setSelectionEnd(t);
|
||||
setNumberBar(b - beginBar + 1);
|
||||
}} className="w-10 bg-black text-white text-[9px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" />
|
||||
<span className="text-[9px] text-zinc-500">#</span>
|
||||
<input type="number" min="1" value={numberBar} readOnly
|
||||
className="w-10 bg-black text-zinc-400 text-[9px] px-1 py-0.5 rounded border border-zinc-800 text-center font-mono" />
|
||||
<div className="w-[1px] h-5 bg-zinc-800 mx-1"></div>
|
||||
<span className="text-[9px] text-zinc-500">Start:</span>
|
||||
<span className="text-[9px] font-mono text-zinc-200 w-14">{selLeft !== null ? formatTime(selLeft) : '--:--'}</span>
|
||||
<span className="text-[9px] text-zinc-500">End:</span>
|
||||
<span className="text-[9px] font-mono text-zinc-200 w-14">{selRight !== null ? formatTime(selRight) : '--:--'}</span>
|
||||
<span className="text-[9px] text-zinc-500">Len:</span>
|
||||
<span className="text-[9px] font-mono text-amber-400 w-14">{selLeft !== null && selRight !== null ? formatTime(Math.abs(selRight - selLeft)) : '--:--'}</span>
|
||||
<div className="flex-1"></div>
|
||||
<div className="text-xs font-bold font-mono text-zinc-100">{formatTime(currentTime)}</div>
|
||||
</div>
|
||||
@@ -4370,17 +4503,7 @@
|
||||
onScroll={handleTCPScroll}
|
||||
className="w-[300px] shrink-0 z-30 bg-[#262626] overflow-y-scroll no-scrollbar flex flex-col border-r border-zinc-900"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
|
||||
<div className="sticky top-0 z-50 flex items-center h-10 border-b border-zinc-900 bg-[#242424] px-2 justify-between shrink-0">
|
||||
<span className="text-[10px] font-bold text-zinc-500 uppercase">Kênh</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[9px] text-zinc-500 font-bold uppercase">Snap</span>
|
||||
<select value={snapValue} onChange={e => setSnapValue(e.target.value)}
|
||||
className="bg-zinc-850 text-zinc-300 text-[10px] px-1 py-0.5 rounded border border-zinc-800 focus:outline-none focus:border-cyan-550 font-mono">
|
||||
<option value="free">Free</option><option value="1">1</option>
|
||||
<option value="1/2">1/2</option><option value="1/4">1/4</option>
|
||||
<option value="1/8">1/8</option><option value="1/16">1/16</option><option value="1/32">1/32</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="sticky top-0 z-50 flex items-center h-10 border-b border-zinc-900 bg-[#242424] px-2 shrink-0">
|
||||
</div>
|
||||
<div className="sticky top-10 z-40 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] p-2 border-l-4 border-purple-500 shrink-0">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
@@ -4406,8 +4529,24 @@
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold text-zinc-500 font-mono">{(idx+1).toString().padStart(2, '0')}</span>
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: track.color }} />
|
||||
<span className="text-xs font-semibold text-zinc-300 truncate max-w-[100px]" title={track.name}>{track.name}</span>
|
||||
<label onClick={e => { e.stopPropagation(); const el = e.currentTarget.querySelector('input'); if (el) el.click(); }} className="cursor-pointer">
|
||||
<input type="color" value={track.color || '#0f766e'} onChange={e => { e.stopPropagation(); updateTrackColor(track.id, e.target.value); }} className="w-0 h-0 opacity-0 absolute pointer-events-none" />
|
||||
<div className="w-3 h-3 rounded-full border border-zinc-600 hover:scale-110 transition-transform" style={{ backgroundColor: track.color }} />
|
||||
</label>
|
||||
{editingTrackName === track.id ? (
|
||||
<input type="text" value={editNameInput} autoFocus
|
||||
onChange={e => setEditNameInput(e.target.value)}
|
||||
onBlur={() => { updateTrackName(track.id, editNameInput || track.name); setEditingTrackName(null); }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { updateTrackName(track.id, editNameInput || track.name); setEditingTrackName(null); } if (e.key === 'Escape') setEditingTrackName(null); }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="text-xs font-semibold bg-black text-zinc-200 border border-cyan-500 rounded px-1 py-0 w-24 outline-none" />
|
||||
) : (
|
||||
<span className="text-xs font-semibold text-zinc-300 truncate max-w-[80px] cursor-text hover:text-cyan-300 transition-colors"
|
||||
title="Click to rename"
|
||||
onClick={e => { e.stopPropagation(); setEditingTrackName(track.id); setEditNameInput(track.name); }}>
|
||||
{track.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={e => { e.stopPropagation(); toggleTrackMute(track.id); }}
|
||||
@@ -4418,20 +4557,23 @@
|
||||
className="p-0.5 rounded text-red-500 hover:bg-red-950/40 hover:text-red-400 transition"><i data-lucide="trash-2" className="w-3.5 h-3.5"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[9px] text-zinc-400" onClick={e => e.stopPropagation()}>
|
||||
<span className="font-semibold uppercase text-[8px] text-zinc-500">Synth:</span>
|
||||
<button onClick={() => generateSynthToTrack(track.id, 'kick')} className="px-1 bg-zinc-800 hover:bg-zinc-700 rounded border border-zinc-700">Kick</button>
|
||||
<button onClick={() => generateSynthToTrack(track.id, 'synth')} className="px-1 bg-zinc-800 hover:bg-zinc-700 rounded border border-zinc-700">Synth</button>
|
||||
<div className="flex flex-col gap-0.5 text-[10px]" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-8 text-right text-zinc-500 text-[9px]">Vol:</span>
|
||||
<input type="range" min="-50" max="7" step="0.5" value={track.volumeDb ?? 0} onChange={e => updateTrackVolumeDb(track.id, parseFloat(e.target.value))} className="flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-cyan-500" style={{ height: '4px' }} />
|
||||
<span className="w-12 text-right font-mono text-zinc-300 text-[9px]">{track.volumeDb ?? 0}dB</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-8 text-right text-zinc-500 text-[9px]">Pan:</span>
|
||||
<input type="range" min="-100" max="100" step="1" value={track.pan ?? 0} onChange={e => updateTrackPan(track.id, parseInt(e.target.value))} className="flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-purple-500" style={{ height: '4px' }} />
|
||||
<span className="w-12 text-right font-mono text-zinc-300 text-[9px]">{track.pan > 0 ? 'R' + track.pan : track.pan < 0 ? 'L' + Math.abs(track.pan) : 'C'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<VolumeKnob value={track.volume} onChange={v => updateTrackVolume(track.id, v)} />
|
||||
<span className="text-[10px] font-mono text-zinc-500">Gain: {Math.round(track.volume * 100)}%</span>
|
||||
</div>
|
||||
<div>
|
||||
<input type="file" id={`upload-${track.id}`} accept="audio/*" className="hidden" onChange={e => loadFileOnTrack(track.id, e.target.files[0])} />
|
||||
<label htmlFor={`upload-${track.id}`} className="px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded text-[10px] flex items-center gap-1 cursor-pointer border border-zinc-700"><i data-lucide="upload" className="w-3 h-3"></i> File</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1" onClick={e => e.stopPropagation()}>
|
||||
<input type="file" id={`upload-${track.id}`} accept="audio/*" className="hidden" onChange={e => loadFileOnTrack(track.id, e.target.files[0])} />
|
||||
<label htmlFor={`upload-${track.id}`} className="px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded text-[10px] flex items-center gap-1 cursor-pointer border border-zinc-700 font-medium"><i data-lucide="upload" className="w-3 h-3"></i> File</label>
|
||||
<button onClick={e => { e.stopPropagation(); showToast('FX panel for track ' + track.id, 'info'); }} className="px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-purple-400 border border-purple-900 rounded text-[10px] font-bold flex items-center gap-1"><i data-lucide="wand-2" className="w-3 h-3"></i> FX: <span className="text-zinc-500 font-normal">None</span></button>
|
||||
<button onClick={() => generateSynthToTrack(track.id, 'synth')} className="px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-amber-400 border border-amber-800 rounded text-[10px] font-bold flex items-center gap-1"><i data-lucide="music" className="w-3 h-3"></i> Synth: <span className="text-zinc-500 font-normal">None</span></button>
|
||||
</div>
|
||||
<div onMouseDown={e => handleTrackResizeMouseDown(e, track.id)}
|
||||
className="absolute bottom-0 left-0 right-0 h-1 cursor-ns-resize z-30 hover:bg-cyan-500/50 transition-colors" onClick={e => e.stopPropagation()} />
|
||||
@@ -4518,14 +4660,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (() => {
|
||||
const st = subTabs.find(s => s.id === activeTab);
|
||||
if (!st) return null;
|
||||
const subTrack = tracks.find(t => t.id === st.trackId);
|
||||
const vTrack = subTrack ? { ...subTrack, buffer: st.buffer, isSubTab: true } : null;
|
||||
return (
|
||||
<>
|
||||
{/* ══ TCP PANEL (sub-tab, single track) ══ */}
|
||||
) : (() => {
|
||||
const st = subTabs.find(s => s.id === activeTab);
|
||||
if (!st) return null;
|
||||
const subTrack = tracks.find(t => t.id === st.trackId);
|
||||
const vTrack = subTrack ? { ...subTrack, buffer: st.buffer, isSubTab: true } : null;
|
||||
const subTabTimelineWidth = Math.max(zoom * (st.buffer ? st.buffer.duration : 0), viewportWidth);
|
||||
return (
|
||||
<>
|
||||
{/* ══ TCP PANEL (sub-tab, single track) ══ */}
|
||||
<div className="w-[300px] shrink-0 z-30 bg-[#262626] overflow-y-scroll no-scrollbar flex flex-col border-r border-zinc-900"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
|
||||
<div className="sticky top-0 z-50 flex items-center h-10 border-b border-zinc-900 bg-[#242424] px-2 justify-between shrink-0">
|
||||
@@ -4539,8 +4682,24 @@
|
||||
<div key={vTrack.id} className="flex-1 flex flex-col p-2.5 bg-[#1e1e1e] border-r border-zinc-900 border-l-4 border-l-cyan-500">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: vTrack.color }} />
|
||||
<span className="text-xs font-semibold text-zinc-300 truncate max-w-[100px]">{vTrack.name}</span>
|
||||
<label onClick={e => { e.stopPropagation(); const el = e.currentTarget.querySelector('input'); if (el) el.click(); }} className="cursor-pointer">
|
||||
<input type="color" value={vTrack.color || '#0f766e'} onChange={e => { e.stopPropagation(); updateTrackColor(vTrack.id, e.target.value); }} className="w-0 h-0 opacity-0 absolute pointer-events-none" />
|
||||
<div className="w-3 h-3 rounded-full border border-zinc-600 hover:scale-110 transition-transform" style={{ backgroundColor: vTrack.color }} />
|
||||
</label>
|
||||
{editingTrackName === vTrack.id ? (
|
||||
<input type="text" value={editNameInput} autoFocus
|
||||
onChange={e => setEditNameInput(e.target.value)}
|
||||
onBlur={() => { updateTrackName(vTrack.id, editNameInput || vTrack.name); setEditingTrackName(null); }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { updateTrackName(vTrack.id, editNameInput || vTrack.name); setEditingTrackName(null); } if (e.key === 'Escape') setEditingTrackName(null); }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="text-xs font-semibold bg-black text-zinc-200 border border-cyan-500 rounded px-1 py-0 w-24 outline-none" />
|
||||
) : (
|
||||
<span className="text-xs font-semibold text-zinc-300 truncate max-w-[80px] cursor-text hover:text-cyan-300 transition-colors"
|
||||
title="Click to rename"
|
||||
onClick={e => { e.stopPropagation(); setEditingTrackName(vTrack.id); setEditNameInput(vTrack.name); }}>
|
||||
{vTrack.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={e => { e.stopPropagation(); toggleTrackMute(vTrack.id); }}
|
||||
@@ -4549,9 +4708,17 @@
|
||||
className={`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border ${soloedTrackId === vTrack.id || vTrack.solo ? 'bg-amber-950 text-amber-400 border-amber-600' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`}>S</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<VolumeKnob value={vTrack.volume} onChange={v => updateTrackVolume(vTrack.id, v)} />
|
||||
<span className="text-[10px] font-mono text-zinc-500">Gain: {Math.round(vTrack.volume * 100)}%</span>
|
||||
<div className="flex flex-col gap-0.5 text-[10px] mb-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-8 text-right text-zinc-500 text-[9px]">Vol:</span>
|
||||
<input type="range" min="-50" max="7" step="0.5" value={vTrack.volumeDb ?? 0} onChange={e => updateTrackVolumeDb(vTrack.id, parseFloat(e.target.value))} className="flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-cyan-500" style={{ height: '4px' }} />
|
||||
<span className="w-12 text-right font-mono text-zinc-300 text-[9px]">{vTrack.volumeDb ?? 0}dB</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-8 text-right text-zinc-500 text-[9px]">Pan:</span>
|
||||
<input type="range" min="-100" max="100" step="1" value={vTrack.pan ?? 0} onChange={e => updateTrackPan(vTrack.id, parseInt(e.target.value))} className="flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-purple-500" style={{ height: '4px' }} />
|
||||
<span className="w-12 text-right font-mono text-zinc-300 text-[9px]">{vTrack.pan > 0 ? 'R' + vTrack.pan : vTrack.pan < 0 ? 'L' + Math.abs(vTrack.pan) : 'C'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[9px] text-zinc-400">
|
||||
<span className="text-[8px] text-zinc-500 font-semibold uppercase">Duration:</span>
|
||||
@@ -4579,10 +4746,10 @@
|
||||
</div>
|
||||
{/* ══ TIMELINE (sub-tab, single track) ══ */}
|
||||
<div ref={timelineWrapperRef} className="flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0">
|
||||
<div style={{ width: `${timelineWidth}px` }} className="relative flex flex-col min-h-full">
|
||||
<div style={{ width: `${subTabTimelineWidth}px` }} className="relative flex flex-col min-h-full">
|
||||
<div className="sticky top-0 z-45 flex h-10 border-b border-zinc-900 bg-[#242424] shrink-0">
|
||||
<div className="flex-1 relative h-full flex items-center select-none cursor-ew-resize overflow-hidden">
|
||||
{Array.from({ length: Math.ceil(maxDuration) }).map((_, i) => {
|
||||
{Array.from({ length: Math.ceil(st.buffer ? st.buffer.duration : 0) }).map((_, i) => {
|
||||
const sec = i; const x = sec * zoom;
|
||||
return (<div key={i} className="absolute h-full border-l border-zinc-800 pl-1 pt-1 text-[9px] font-mono text-zinc-500 pointer-events-none" style={{ left: `${x}px` }}>{formatTime(sec)}</div>);
|
||||
})}
|
||||
@@ -4590,7 +4757,7 @@
|
||||
</div>
|
||||
<div className="sticky top-10 z-35 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] shrink-0">
|
||||
<div className="flex-1 relative h-full overflow-hidden bg-[#1a1a2e]">
|
||||
<TempoTrackLane bpm={parseInt(bpm) || 120} zoom={zoom} timelineWidth={timelineWidth}
|
||||
<TempoTrackLane bpm={parseInt(bpm) || 120} zoom={zoom} timelineWidth={subTabTimelineWidth}
|
||||
onPlayheadSet={setCurrentTime} snapValue={snapValue} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -4608,6 +4775,8 @@
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user