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
+214
View File
@@ -0,0 +1,214 @@
# Technical Specification: Ultra-Zoom & Sample-Level Waveform Rendering (Sample-Level Waveform Zoom)
This document defines the technical solution, data flow schema, and graphical optimization algorithms across both the Frontend (HTML5 Canvas) and Backend (Python / Docker) to implement an Ultra-Zoom Waveform feature. This architecture renders discrete sample nodes interconnected by a continuous line vector for absolute Zero-Crossing alignment, referencing the design principles.
---
## 1. What is Sample-Level Zoom?
When displaying an audio waveform at a macro scale (Zoom Out), a single pixel column on the display represents hundreds or thousands of acoustic samples ($N$ samples/pixel). Consequently, the engine deploys a Peak Waveform algorithm that connects the maximum (Max) and minimum (Min) amplitude values within that segment using vertical lines.
However, when a operator scales the viewport magnification beyond a specific threshold (e.g., a zoom ratio of $Z \ge 100,000\text{ pixels/second}$):
* A single discrete audio sample occupies a large horizontal footprint on the display (e.g., $5 \rightarrow 15\text{ pixels/sample}$).
* The rendering engine must hot-swap its routine from standard vertical peak columns to a **Continuous Polyline with Sample Nodes** loop. Every discrete acoustic sample $x[n]$ is mapped as an independent circle node, with chronologically adjacent nodes joined by a smooth continuous path.
---
## 2. Frontend Layout Architecture (HTML5 Canvas & Web Audio API)
To render thousands of vector coordinate indices fluidly during rapid zooming and scrolling/dragging gestures without locking up the browser thread (Freeze UI), the system integrates the following memory pipeline:
```text
VIEWPORT SLICING ENGINE
┌────────────────────────────────────────────────────────────────────────┐
│ [ Web Audio Buffer (Full track - Millions of raw sample values) ] │
│ │ │
│ ▼ (Extract visible boundary region only) │
│ [ Visible Sample Array (Restricted to ~200 - 1,000 samples in view) ] │
│ │ │
│ ▼ (High-speed GPU-accelerated Canvas draw) │
│ [ HTML5 Canvas Render: ctx.arc() & ctx.lineTo() ] ──► Screen Viewport │
└────────────────────────────────────────────────────────────────────────┘
```
### 2.1. Viewport Slicing Technique
The rendering engine must never iterate through the total sample length of the audio file during a drawing pass. The slice generator isolates only the data segments that correspond directly to the physical visible screen dimensions (visible viewport boundary):
* **Visible Starting Timestamp:**
$$T_{\text{start}} = \frac{\text{scrollLeft}}{\text{Zoom}}$$
* **Visible Terminating Timestamp:**
$$T_{\text{end}} = \frac{\text{scrollLeft} + W_{\text{viewport}}}{\text{Zoom}}$$
* **Starting Array Index Offset:**
$$n_{\text{start}} = \lfloor T_{\text{start}} \times f_s \rfloor$$
* **Terminating Array Index Offset:**
$$n_{\text{end}} = \lceil T_{\text{end}} \times f_s \rceil$$
### 2.2. Sample Node Graph Canvas Algorithm
For every absolute sample index $x[i]$ contained within the sliced viewport interval $[n_{\text{start}}, n_{\text{end}}]$, the coordinate translation layer maps the raw data into physical pixel coordinates $(X, Y)$ on the Canvas:
$$X_i = \left( \frac{i}{f_s} \right) \times \text{Zoom} - \text{scrollLeft}$$
$$Y_i = \text{mid}_Y + x[i] \cdot \left( \text{height} \times 0.42 \right)$$
*Where:* $\text{mid}_Y$ maps the horizontal center zero axis (-Inf. dB line), and $x[i] \in [-1.0, 1.0]$ tracks the floating-point sample amplitude value.
### JavaScript Redraw Core Script (React / JS Context)
```javascript
function drawSampleLevelWaveform(ctx, canvasWidth, canvasHeight, audioBuffer, scrollLeft, zoom) {
const data = audioBuffer.getChannelData(0); // Query Left channel data stream
const fs = audioBuffer.sampleRate;
const midY = canvasHeight / 2;
const ampHeight = canvasHeight * 0.42; // Clamps drawing ceiling bounds to 84% of total height
// 1. Viewport Slicing Matrix Execution
const tStart = scrollLeft / zoom;
const tEnd = (scrollLeft + canvasWidth) / zoom;
const nStart = Math.max(0, Math.floor(tStart * fs));
const nEnd = Math.min(data.length, Math.ceil(tEnd * fs));
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
// Set up standard studio charcoal theme background canvas
ctx.fillStyle = '#1e1e1e';
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
// Overlay symmetrical decibel gridding lines (-6.0 dB, -Inf, -6.0 dB)
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
ctx.lineWidth = 1;
[-0.501, 0, 0.501].forEach(val => {
const y = midY + (val * ampHeight);
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(canvasWidth, y);
ctx.stroke();
});
// 2. Continuous Vector Polyline Redraw Configuration
ctx.strokeStyle = '#5bc0be'; // Professional sleek light cyan accent theme
ctx.lineWidth = 1.5;
ctx.beginPath();
let isFirst = true;
for (let i = nStart; i < nEnd; i++) {
const xPixel = (i / fs) * zoom - scrollLeft;
const yPixel = midY + (data[i] * ampHeight);
if (isFirst) {
ctx.moveTo(xPixel, yPixel);
isFirst = false;
} else {
ctx.lineTo(xPixel, yPixel);
}
}
ctx.stroke();
// 3. Highlight Discrete Sample Nodes (Luminous node nodes circles)
ctx.fillStyle = '#6ee7b7'; // Vivid green emerald node color
for (let i = nStart; i < nEnd; i++) {
const xPixel = (i / fs) * zoom - scrollLeft;
const yPixel = midY + (data[i] * ampHeight);
// Render point node indicators if the physical pixel delta spacing is >= 4px (Prevents GPU thread thrashing)
const nextXPixel = ((i + 1) / fs) * zoom - scrollLeft;
if (nextXPixel - xPixel >= 4) {
ctx.beginPath();
ctx.arc(xPixel, yPixel, 2, 0, 2 * Math.PI);
ctx.fill();
}
}
}
```
---
## 3. Backend Architecture (Python / NumPy / Docker)
When an operator triggers editing transformations, loop boundary indexing (AI Scan Loops), or an AI Cut on the user interface, precise timestamp scalars (seconds) are pushed to the backend stack. The FastAPI routing layer and Celery task worker process the input metrics via NumPy using sample-accurate precision to eliminate clicking audio defects.
### 3.1. High-Performance Vectorized Zero-Crossing Analysis via NumPy
This algorithm targets the exact index offset location where an algebraic sign-inversion occurs (crossing the absolute 0 baseline) closest to the user's cursor selection coordinate:
```python
import numpy as np
def find_exact_zero_crossing_sample(y: np.ndarray, sr: int, target_time: float, search_window_ms: float = 40.0) -> int:
"""
Scans the signal buffer matrix to extract the exact sample index where amplitude
crosses the absolute 0 axis closest to target_time. Mitigates signal phase fracture.
"""
target_sample = int(target_time * sr)
window_samples = int((search_window_ms / 1000.0) * sr)
# Establish local window limits
start_idx = max(0, target_sample - window_samples // 2)
end_idx = min(len(y) - 2, target_sample + window_samples // 2)
y_segment = y[start_idx:end_idx]
# Vectorized loop matching physical phase boundaries: y[i] * y[i+1] <= 0
# This evaluates ultra-fast directly on NumPy's optimized underlying C-layer
zero_crossings = np.where(y_segment[:-1] * y_segment[1:] <= 0)[0]
if len(zero_crossings) == 0:
# Fallback: if no phase inversion is detected (extended silence), return the minimum absolute sample value
abs_min_idx = np.argmin(np.abs(y_segment))
return start_idx + abs_min_idx
# Translate the localized coordinate index back to global absolute buffer sample indices
absolute_crossings = zero_crossings + start_idx
# Isolate the index that maps closest to the original physical target_sample address
distances = np.abs(absolute_crossings - target_sample)
best_sample_index = absolute_crossings[np.argmin(distances)]
return int(best_sample_index)
```
### 3.2. Fade-Free Zero-Crossing Splicing Workflow
Once the exact boundary indices ($N_{\text{start\_zero}}$, $N_{\text{end\_zero}}$) are located using the zero-crossing analyzer:
1. **Slicing Operation:**
```python
y_cut = y[N_start_zero : N_end_zero]
```
2. **Merging & Track Insertion:** The sliced audio block is appended straight into the signal array of the destination track. Because both the initial and terminating boundaries of the cut segment are locked perfectly to a theoretical value of $0\text{V}$, splicing this array into any other silent segment preserves absolute physical phase continuity.
3. **Bypassing Fade Modulators:** The physical transient profiles (**Transients**) of percussive assets (Kick Drums, Snares, Claps) remain $100\%$ unwarped. This completely preserves the crisp, punchy acoustic characteristics of the source audio data.
---
## 4. Performance Optimization Manual
* **Double Buffering (Offscreen Canvas Rendering Canvas):** Under extreme magnification scales, client-side horizontal scrolling modifications (`onScroll`) trigger continuous drawing passes. To mitigate visual performance drop, the vector graphs should map onto an un-rendered buffer area (**Offscreen Canvas**) before executing a single block copy to the viewport canvas using the command `ctx.drawImage()`. This eliminates screen tearing or viewport flickering.
* **Throttle Rendering Threads:** Wrap interface redraw handlers inside an explicit `requestAnimationFrame()` loop. This throttles the drawing passes to synchronize exactly with the screen hardware refresh rate metrics (typically $60\text{Hz}$ or $120\text{Hz}$), which avoids drawing redundant frames when CPU threads are under heavy loads handling audio decoding.
---
Giúp bạn tìm hiểu thêm về cấu trúc này, bạn có muốn khám phá sâu hơn khía cạnh nào không?
* **Optimizing Audio Codecs:** Cách tối ưu cấu trúc lưu trữ và nén dữ liệu nhị phân khi truyền tải mảng mảng số lớn giữa Docker Server và Web Client.
* **PyQt6 High-Frequency Redraw:** Thiết lập vòng lặp vẽ đồ thị `QPainter` đa luồng trên ứng dụng Desktop Python mà không bị treo hàng đợi Event Loop.
* **Cubic Spline Interpolation:** Công thức toán học nội suy mượt nâng cao thay thế cho đường thẳng tuyến tính (Linear Polyline) để bo cong sóng âm mịn hơn.
+9
View File
@@ -58,3 +58,12 @@ async def get_index():
return HTMLResponse(content=f"<h1>SonicForge Studio: index.html not found at {index_path}</h1>", status_code=404)
with open(index_path, "r", encoding="utf-8") as file:
return HTMLResponse(content=file.read(), status_code=200)
@app.get("/favicon.svg")
async def get_favicon():
import os
favicon_path = os.path.join(settings.TEMPLATES_DIR, "favicon.svg")
if os.path.exists(favicon_path):
from fastapi.responses import FileResponse
return FileResponse(favicon_path, media_type="image/svg+xml")
return HTMLResponse(content="", status_code=404)
Binary file not shown.
+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