feat: xử lí track/clip với AI
This commit is contained in:
+354
-103
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SonicForge Studio - Professional DAW Editor</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
|
||||
@@ -207,6 +208,7 @@
|
||||
onClipStretchStart,
|
||||
onSelectionEdgeDragStart,
|
||||
setSelectedClipId,
|
||||
selectedClipId,
|
||||
activeTool,
|
||||
onSplitTrackAtTime,
|
||||
onEditClipInSubTab,
|
||||
@@ -290,13 +292,17 @@
|
||||
const xEnd = xStart + wClip;
|
||||
|
||||
// 1. Draw Clip Layer Background & Border
|
||||
ctx.fillStyle = track.color ? track.color + '22' : 'rgba(6, 182, 212, 0.12)';
|
||||
ctx.strokeStyle = track.color || '#06b6d4';
|
||||
ctx.lineWidth = 1.5;
|
||||
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 ? 3 : 1.5;
|
||||
|
||||
const clipTop = 8;
|
||||
const clipHeight = height - 16;
|
||||
|
||||
// Check if this clip is the selected one
|
||||
const clipIdentifier = clip.id === 'default' ? 'default_' + track.id : clip.id;
|
||||
const isClipSelected = selectedClipId && selectedClipId.trackId === track.id && selectedClipId.clipId === clipIdentifier;
|
||||
|
||||
ctx.beginPath();
|
||||
if (ctx.roundRect) {
|
||||
ctx.roundRect(xStart, clipTop, wClip, clipHeight, 4);
|
||||
@@ -531,7 +537,7 @@
|
||||
onClipDragStart(track.id, clickedClip.id, time - clickedClip.startTime, e.ctrlKey);
|
||||
}
|
||||
} else {
|
||||
onPlayheadSet(time);
|
||||
onPlayheadSet(time, e.shiftKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -558,7 +564,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
onPlayheadSet(time);
|
||||
onPlayheadSet(time, e.shiftKey);
|
||||
if (onTrackLaneMouseDown) {
|
||||
onTrackLaneMouseDown(track.id, time, e);
|
||||
}
|
||||
@@ -699,7 +705,7 @@
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left + scrollLeft;
|
||||
const time = Math.max(0, x / zoom);
|
||||
onPlayheadSet(time);
|
||||
onPlayheadSet(time, e.shiftKey);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
@@ -2335,7 +2341,7 @@
|
||||
|
||||
const App = () => {
|
||||
// ── State Definitions ──
|
||||
const [tracks, setTracks] = useState([
|
||||
const [tracks, setTracks] = useState([
|
||||
{
|
||||
id: '1',
|
||||
name: 'Track 01',
|
||||
@@ -3015,7 +3021,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(); handleImportSFS(); 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 === '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; }
|
||||
@@ -4513,7 +4519,49 @@
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
// ── Selection ──
|
||||
// ── Playhead set with seek+play & shift+click selection ──
|
||||
const handlePlayheadSet = (time, shiftKey) => {
|
||||
if (shiftKey) {
|
||||
// Shift+click: extend selection from currentTime to clicked position
|
||||
const selStart = Math.min(currentTime, time);
|
||||
const selEnd = Math.max(currentTime, time);
|
||||
setSelectionStart(selStart);
|
||||
setSelectionEnd(selEnd);
|
||||
setSelectionMode('global');
|
||||
clearLocalSelection();
|
||||
if (isLoopingSelection) {
|
||||
// If looping, restart loop from new selection start
|
||||
stopAllPlayback();
|
||||
setCurrentTime(selStart);
|
||||
if (isPlaying) {
|
||||
setTimeout(() => {
|
||||
startOffsetTimeRef.current = selStart;
|
||||
startAudioTimeRef.current = getAudioContext().currentTime;
|
||||
startTrackPlayback(selStart);
|
||||
setIsPlaying(true);
|
||||
}, 50);
|
||||
}
|
||||
} else if (isPlaying) {
|
||||
// Continue playing, just update the playhead visually
|
||||
setCurrentTime(time);
|
||||
} else {
|
||||
setCurrentTime(time);
|
||||
}
|
||||
} else 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);
|
||||
@@ -4521,23 +4569,23 @@
|
||||
setLocalSelectionEnd(null);
|
||||
};
|
||||
|
||||
const handleRulerMouseDown = (e) => {
|
||||
const wrapper = timelineWrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const scrollLeft = wrapper.scrollLeft;
|
||||
const mouseX = e.clientX - rect.left + scrollLeft;
|
||||
const rawTime = mouseX / zoom;
|
||||
const time = snapValue !== 'free' ? snapTime(rawTime, snapValue, bpm) : rawTime;
|
||||
|
||||
clearLocalSelection();
|
||||
setSelectionMode('global');
|
||||
rulerDragStartRef.current = time;
|
||||
isDraggingRulerRef.current = true;
|
||||
setSelectionStart(time);
|
||||
setSelectionEnd(time);
|
||||
setCurrentTime(time);
|
||||
};
|
||||
const handleRulerMouseDown = (e) => {
|
||||
const wrapper = timelineWrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const scrollLeft = wrapper.scrollLeft;
|
||||
const mouseX = e.clientX - rect.left + scrollLeft;
|
||||
const rawTime = mouseX / zoom;
|
||||
const time = snapValue !== 'free' ? snapTime(rawTime, snapValue, bpm) : rawTime;
|
||||
|
||||
clearLocalSelection();
|
||||
setSelectionMode('global');
|
||||
rulerDragStartRef.current = time;
|
||||
isDraggingRulerRef.current = true;
|
||||
setSelectionStart(time);
|
||||
setSelectionEnd(time);
|
||||
handlePlayheadSet(time, e.shiftKey);
|
||||
};
|
||||
|
||||
// Global Ruler mousemove is tracked via document listener set up in useEffect
|
||||
useEffect(() => {
|
||||
@@ -5577,45 +5625,173 @@
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
// ── Mark Selection ──
|
||||
const handleAIScan = async () => {
|
||||
const activeTrack = tracks.find(t => t.id === selectedTrackId);
|
||||
// ── 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 quét Loop.", "warning");
|
||||
showToast("Vui lòng chọn một Track có âm thanh để AI phân tích.", "warning");
|
||||
return;
|
||||
}
|
||||
setAnalysisState({ status: 'AI Loop Scan đang quét ma trận Chroma...', data: null, isRunning: true });
|
||||
showToast("AI Scan đang tìm kiếm đoạn Loop tối ưu...", "info");
|
||||
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 {
|
||||
let loopRegion = { start_time: 1.4589, end_time: 5.4592, score: 0.892 };
|
||||
if (window.SonicAPI && activeTrack.serverFileId) {
|
||||
const res = await window.SonicAPI.aiScan(activeTrack.id, activeTrack.serverFileId);
|
||||
if (res && res.suggested_loops && res.suggested_loops.length > 0) {
|
||||
loopRegion = res.suggested_loops[0];
|
||||
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); }
|
||||
}
|
||||
} else {
|
||||
const snapStart = findZeroCrossing(activeTrack.buffer, 0.0);
|
||||
const snapEnd = findZeroCrossing(activeTrack.buffer, Math.min(activeTrack.buffer.duration, 4.0));
|
||||
loopRegion = { start_time: snapStart, end_time: snapEnd, score: 0.95 };
|
||||
}
|
||||
detectedBPM = Math.round(Math.min(240, Math.max(60, detectedBPM)));
|
||||
|
||||
const zStart = findZeroCrossing(activeTrack.buffer, loopRegion.start_time);
|
||||
const zEnd = findZeroCrossing(activeTrack.buffer, loopRegion.end_time);
|
||||
const beatDuration = 60 / detectedBPM;
|
||||
const barDuration = beatDuration * 4;
|
||||
const totalDuration = buffer.duration;
|
||||
|
||||
const mStart = { id: 'm_ai_start_' + Date.now(), time: zStart, label: 'AI Loop Start (0V)', color: '#06b6d4' };
|
||||
const mEnd = { id: 'm_ai_end_' + Date.now(), time: zEnd, label: 'AI Loop End (0V)', color: '#a855f7' };
|
||||
// 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, mStart, mEnd] };
|
||||
return { ...t, markers: [...existingMarkers, ...barMarkers] };
|
||||
}));
|
||||
|
||||
setSelectionStart(zStart);
|
||||
setSelectionEnd(zEnd);
|
||||
setAnalysisState({ status: `Đã ghim AI Loop: ${zStart.toFixed(3)}s - ${zEnd.toFixed(3)}s (Zero-Crossing 0V)`, data: { bpm: bpm }, isRunning: false });
|
||||
showToast(`AI Loop Scan hoàn tất: Đã ghim 2 Markers [${zStart.toFixed(3)}s -> ${zEnd.toFixed(3)}s]`, "success");
|
||||
// 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');
|
||||
@@ -5703,9 +5879,12 @@
|
||||
showToast(`Đã tạo 2 Markers tại đầu và cuối dải chọn (Snap Zero-Crossing)`, "success");
|
||||
};
|
||||
|
||||
// ── AI Cut to New Track (server-side with client fallback) ──
|
||||
// ── AI Cut to New Track (Music Theory Loop Detection) ──
|
||||
const handleAICutToNewTrack = () => {
|
||||
const activeTrack = tracks.find(t => t.id === 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ó dữ liệu âm thanh trước.", "warning");
|
||||
return;
|
||||
@@ -5715,56 +5894,113 @@
|
||||
return;
|
||||
}
|
||||
|
||||
setAnalysisState({ status: 'AI đang phân tích điểm Zero-crossing...', data: null, isRunning: true });
|
||||
showToast("AI đang dò tìm Zero-crossing...", "info");
|
||||
|
||||
const buffer = activeTrack.buffer;
|
||||
const sampleRate = buffer.sampleRate;
|
||||
const channelData = buffer.getChannelData(0);
|
||||
|
||||
const snapStart = findZeroCrossing(buffer, selectionStats.start);
|
||||
const snapEnd = findZeroCrossing(buffer, selectionStats.end);
|
||||
|
||||
const startSample = Math.max(0, Math.min(channelData.length - 1, Math.floor(snapStart * sampleRate)));
|
||||
const endSample = Math.max(0, Math.min(channelData.length, Math.floor(snapEnd * sampleRate)));
|
||||
const sliceLength = endSample - startSample;
|
||||
|
||||
if (sliceLength <= 0) {
|
||||
showToast("Dải cắt không hợp lệ hoặc khoảng thời gian quá ngắn.", "error");
|
||||
setAnalysisState({ status: 'Thất bại', data: null, isRunning: false });
|
||||
return;
|
||||
}
|
||||
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 slicedBuffer = context.createBuffer(1, sliceLength, sampleRate);
|
||||
const slicedData = slicedBuffer.getChannelData(0);
|
||||
slicedData.set(channelData.subarray(startSample, endSample));
|
||||
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: `AI_Cut_${activeTrack.name.replace('.wav', '')}_${snapStart.toFixed(1)}s.wav`,
|
||||
name: `Loop_${barNum}bar_${activeTrack.name.replace('.wav', '').slice(0, 10)}_${snapLoopStart.toFixed(1)}s.wav`,
|
||||
buffer: slicedBuffer,
|
||||
startTime: snapStart,
|
||||
channelInfo: activeTrack.channelInfo ? { ...activeTrack.channelInfo } : null,
|
||||
startTime: snapLoopStart,
|
||||
clips: [{
|
||||
id: 'clip_' + newId,
|
||||
buffer: slicedBuffer,
|
||||
startTime: snapStart,
|
||||
name: `AI_Cut_${activeTrack.name.replace('.wav', '')}_${snapStart.toFixed(1)}s`
|
||||
startTime: snapLoopStart,
|
||||
name: `Loop_${barNum}bar_${snapLoopStart.toFixed(1)}s`
|
||||
}],
|
||||
volumeDb: 0,
|
||||
pan: 0,
|
||||
muted: false,
|
||||
solo: false,
|
||||
volumeDb: 0, pan: 0, muted: false, solo: false,
|
||||
color: selectColor,
|
||||
markers: [
|
||||
{ id: Date.now() + '_s', time: 0 },
|
||||
{ id: Date.now() + '_e', time: snapEnd - snapStart }
|
||||
{ id: Date.now() + '_e', time: snapLoopEnd - snapLoopStart }
|
||||
],
|
||||
serverFileId: null,
|
||||
};
|
||||
@@ -5772,27 +6008,24 @@
|
||||
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);
|
||||
}
|
||||
if (idx !== -1) { updated.splice(idx + 1, 0, newTrack); }
|
||||
else { updated.push(newTrack); }
|
||||
return updated;
|
||||
});
|
||||
|
||||
setSelectedTrackId(newId);
|
||||
setAnalysisState({
|
||||
status: 'Phân đoạn AI hoàn tất!',
|
||||
data: { bpm: 120, bars: 4, timeSig: '4/4', detectedKey: 'Am' },
|
||||
status: `AI Cut: ${detectedBPM} BPM, ${barsCount} bars loop (Zero-Crossing aligned)`,
|
||||
data: { bpm: detectedBPM, bars: barsCount, timeSig: '4/4' },
|
||||
isRunning: false
|
||||
});
|
||||
showToast(`AI đã cắt & gộp thành công vào Track mới (Zero-Crossing aligned)`, "success");
|
||||
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 giải mã dải cắt: " + err.message, "error");
|
||||
setAnalysisState({ status: 'Lỗi biên tập', data: null, isRunning: false });
|
||||
showToast("Lỗi khi AI Cut: " + err.message, "error");
|
||||
setAnalysisState({ status: 'Lỗi AI Cut', data: null, isRunning: false });
|
||||
}
|
||||
}, 1000);
|
||||
}, 800);
|
||||
};
|
||||
|
||||
// ── Split Track at Playhead ──
|
||||
@@ -5972,7 +6205,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, 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: '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() },
|
||||
@@ -6422,11 +6655,11 @@
|
||||
<button onClick={handleAIScan} className="py-1 bg-purple-900 hover:bg-purple-800 text-purple-100 font-bold rounded text-[9px] border border-purple-700 flex items-center justify-center gap-1">
|
||||
<span className="inline-flex items-center shrink-0"><i data-lucide="map-pin" className="w-3 h-3"></i></span> AI Scan
|
||||
</button>
|
||||
<button onClick={handleAICutToNewTrack} disabled={analysisState.isRunning} className="py-1 bg-purple-700 hover:bg-purple-600 text-white font-bold rounded text-[9px] flex items-center justify-center gap-1">
|
||||
<button onClick={handleAICutToNewTrack} disabled={analysisState.isRunning} className="py-1 bg-fuchsia-800 hover:bg-fuchsia-700 text-white font-bold rounded text-[9px] flex items-center justify-center gap-1">
|
||||
<span className="inline-flex items-center shrink-0"><i data-lucide="scissors" className="w-3 h-3"></i></span> AI Cut
|
||||
</button>
|
||||
<button onClick={triggerAIAnalysis} disabled={analysisState.isRunning} className="py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-200 font-bold rounded text-[9px] border border-zinc-600 flex items-center justify-center gap-1">
|
||||
<span className="inline-flex items-center shrink-0"><i data-lucide="sparkles" className="w-3 h-3"></i></span> Analyze
|
||||
<button onClick={handleAIAnalysicLoop} disabled={analysisState.isRunning} className="py-1 bg-violet-800 hover:bg-violet-700 text-violet-100 font-bold rounded text-[9px] border border-violet-600 flex items-center justify-center gap-1">
|
||||
<span className="inline-flex items-center shrink-0"><i data-lucide="sparkles" className="w-3 h-3"></i></span> AI Analysic Loop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -6681,8 +6914,8 @@
|
||||
</div>
|
||||
<div className="sticky top-10 z-35 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] shrink-0">
|
||||
<div className="flex-1 relative h-full overflow-hidden bg-[#1a1a2e]">
|
||||
<TempoTrackLane bpm={parseInt(bpm) || 120} zoom={zoom} timelineWidth={timelineWidth}
|
||||
onPlayheadSet={setCurrentTime} snapValue={snapValue} />
|
||||
<TempoTrackLane bpm={parseInt(bpm) || 120} zoom={zoom} timelineWidth={timelineWidth}
|
||||
onPlayheadSet={handlePlayheadSet} snapValue={snapValue} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full">
|
||||
@@ -6695,15 +6928,16 @@
|
||||
onDrop={e => { e.preventDefault(); if (e.dataTransfer.files[0]) loadFileOnTrack(track.id, e.dataTransfer.files[0]); }}
|
||||
onMouseEnter={() => setHoveredTrackId(track.id)}>
|
||||
<WaveformLane track={track} zoom={zoom} timelineWidth={timelineWidth}
|
||||
onSelectRange={handleSelectRange} onPlayheadSet={setCurrentTime}
|
||||
onSelectRange={handleSelectRange} onPlayheadSet={handlePlayheadSet}
|
||||
isSelected={isSelected} onSelectTrack={setSelectedTrackId} markers={track.markers}
|
||||
onTrackLaneMouseDown={handleTrackLaneMouseDown}
|
||||
onContextMenu={handleContextMenu}
|
||||
onClipDragStart={handleClipDragStart}
|
||||
onClipStretchStart={handleClipStretchStart}
|
||||
onSelectionEdgeDragStart={handleSelectionEdgeDragStart}
|
||||
setSelectedClipId={setSelectedClipId}
|
||||
activeTool={activeTool}
|
||||
setSelectedClipId={setSelectedClipId}
|
||||
selectedClipId={selectedClipId}
|
||||
activeTool={activeTool}
|
||||
onSplitTrackAtTime={handleSplitTrackAtTime}
|
||||
onEditClipInSubTab={handleEditClipInSubTab}
|
||||
snapValue={snapValue} bpm={bpm}
|
||||
@@ -6848,6 +7082,23 @@
|
||||
<span>SR:</span>
|
||||
<span className="font-mono text-zinc-300">{st.buffer ? st.buffer.sampleRate : 0} Hz</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1 mb-2">
|
||||
<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-[12px] border border-purple-700 flex items-center justify-center gap-1">
|
||||
<span className="inline-flex items-center shrink-0"><i data-lucide="map-pin" className="w-3 h-3"></i></span> Scan
|
||||
</button>
|
||||
<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-[12px] flex items-center justify-center gap-1">
|
||||
<span className="inline-flex items-center shrink-0"><i data-lucide="scissors" className="w-3 h-3"></i></span> Cut
|
||||
</button>
|
||||
<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-[12px] border border-violet-600 flex items-center justify-center gap-1 col-span-2">
|
||||
<span className="inline-flex items-center shrink-0"><i data-lucide="sparkles" className="w-3 h-3"></i></span> AI Analysic Loop
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => exportSubTabBuffer(st.id)}
|
||||
className="w-full py-1.5 mb-1.5 bg-blue-700 hover:bg-blue-600 text-white font-bold rounded text-[14px] flex items-center justify-center gap-1 transition">
|
||||
<span className="inline-flex items-center shrink-0"><i data-lucide="download" className="w-4 h-4"></i></span> Export
|
||||
|
||||
Reference in New Issue
Block a user