From 9e936144e1244dcc9bb19a0b14a3c1a531579875 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Mon, 20 Jul 2026 15:36:28 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20x=E1=BB=AD=20l=C3=AD=20track/clip=20v?= =?UTF-8?q?=E1=BB=9Bi=20AI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 18_ZOOM_IN.md | 214 ++++++++++++++++++ app/main.py | 9 + app/storage/sonicforge.db | Bin 45056 -> 45056 bytes app/templates/index.html | 457 +++++++++++++++++++++++++++++--------- 4 files changed, 577 insertions(+), 103 deletions(-) create mode 100644 18_ZOOM_IN.md diff --git a/18_ZOOM_IN.md b/18_ZOOM_IN.md new file mode 100644 index 0000000..a5a212f --- /dev/null +++ b/18_ZOOM_IN.md @@ -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. \ No newline at end of file diff --git a/app/main.py b/app/main.py index 878e520..d6342a7 100644 --- a/app/main.py +++ b/app/main.py @@ -58,3 +58,12 @@ async def get_index(): return HTMLResponse(content=f"

SonicForge Studio: index.html not found at {index_path}

", 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) diff --git a/app/storage/sonicforge.db b/app/storage/sonicforge.db index c5c22641e2ac214500070c04896ce25b8439e57f..8555087dd46ec80b86ee5dfe33c11a7535eb2bc4 100644 GIT binary patch delta 486 zcmZp8z|`=7X@WE(=R_H2M$U~11#-$3DaMv*sm4aS$wp~ux+ckK#=1$y25Gu!W{Ih$ zW@eVg#)*?Z%jMOVmrr^6vr(LpD^%RNkyo6PozpVeG}%1W*i<(qCDl;Z#Mn4lH_-sd zHB2(GG*3-T2AYtRn39{B2PPbf^Yb#3)AEbbQ}v2VN>eiPEt3o_jm^!>Q%sZ0jZF+q zO@QVlCMTt(Sy&jQnHif|7^WFnBqf`gm?s$+Dwr7?q@<;!7#Jo3-J52bY-DHvHrMgi z^zw4`i)%M2Z07eR!ZXQC>dc`;xqXwt8vPJrOl15bpv}k<%EU-ah-ff4(7>q$zxi3q M%csWJENUk delta 486 zcmcJLu};G<6h(0W5s*;XGJvW|3?Ly>J8|qhm7$24jqKRZHcBLth|-z%4~iJr7?@co z1E0ap)PDepfd#2wkcH)rN9WwhBA6_K*)9q1_s&T8df9aMl@m&3U}?f!N`*y3wO5K- z?Nk<{kO@3|b*sCQ4SvdLM7Hv1Fx`&AjZG_4GzO3+s5BJ`I;vBXQm*67Ad?)e)%Lom zmNOjHRe`>~xpK#&+p#Tb1a1UqskHE3Y3>0a&4iZ53d SonicForge Studio - Professional DAW Editor + @@ -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 @@
{[ { 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 @@ - - @@ -6681,8 +6914,8 @@
- +
@@ -6695,15 +6928,16 @@ onDrop={e => { e.preventDefault(); if (e.dataTransfer.files[0]) loadFileOnTrack(track.id, e.dataTransfer.files[0]); }} onMouseEnter={() => setHoveredTrackId(track.id)}> SR: {st.buffer ? st.buffer.sampleRate : 0} Hz
+
+ + + +