feat: xử lí track/clip với AI

This commit is contained in:
2026-07-20 15:36:28 +07:00
parent 2a44b81cf5
commit 9e936144e1
4 changed files with 577 additions and 103 deletions
+354 -103
View File
@@ -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