From a6ec14b38b8cbf633ab63892a23bd941f4a8793c Mon Sep 17 00:00:00 2001 From: 3dtours Date: Sun, 19 Jul 2026 08:05:49 +0700 Subject: [PATCH] =?UTF-8?q?fix&feat:=2011=5FREFACTOR=5FUI.md=20s=E1=BB=ADa?= =?UTF-8?q?=20l=E1=BB=97i=20UI=20v=C3=A0=20th=C3=AAm=20c=C3=A1c=20t=C3=ADn?= =?UTF-8?q?h=20n=C4=83ng=20c=E1=BB=A7a=20track=20timeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 11_REFACTOR_UI.md | 164 +++++ app/templates/index.html | 1294 ++++++++++++++++++++++++++------------ 2 files changed, 1071 insertions(+), 387 deletions(-) create mode 100644 11_REFACTOR_UI.md diff --git a/11_REFACTOR_UI.md b/11_REFACTOR_UI.md new file mode 100644 index 0000000..83b63ea --- /dev/null +++ b/11_REFACTOR_UI.md @@ -0,0 +1,164 @@ +# Technical Specification: Advanced UI Refactoring & Clip Editing Mechanics + +This document defines the improved user interface design and advanced audio interaction algorithms to standardize frontend development and porting workflows into a containerized Python DAW application running on Docker. + +--- + +## 1. UI Refactoring Specification + +### 1.1. Resolving TCP Horizontal Scroll Overflows (Horizontal Scroll Isolation) + +* **Symptom:** When scrolling horizontally across the Timeline, waveform or grid canvas elements incorrectly render on top of the left Track Control Panel (TCP) region. +* **Refactoring Solution:** Enforce strict visual separation using Flexbox constraints. The master arrangement window (Workspace) is divided into two physically adjacent columns with completely isolated presentation variables: + +```css +.tcp-column { + width: 300px; + flex-shrink: 0; + position: relative; + z-index: 30; /* Ensures columns stay stacked on top */ + background-color: #262626; /* Solid, opaque color mask */ + overflow: hidden; +} +.timeline-viewport { + flex: 1 1 0%; + position: relative; + z-index: 10; + overflow-x: auto; + overflow-y: hidden; /* Restricts column to independent horizontal scrolling */ +} + +``` + +### 1.2. Prominent Shortcut Labels + +* **Graphical Standard:** Scale up text components displaying key combination hints within system dropdown menus and right-click Context Menus. +* **Layout Mapping Properties:** +* Keyboard shortcut font sizing: Scaled up from 10px to 12px (`text-[12px]`). +* Weight property: Configured to `font-semibold`. +* High-contrast color palette: Replace low-contrast gray strings with vivid purple (`text-purple-400` / `#c084fc`) or neon amber (`text-amber-400` / `#fbbf24`) that pop cleanly over the dark `#1e1e1e` canvas backdrop. +* Structural alignment: Push shortcut labels directly to the right edge of the context window (`ml-auto pl-8`). + + + +### 1.3. Enlarged & Centered Toolbar + +* **Layout Adjustment:** Primary editing triggers (Cut, Copy, Paste, Snap) are scaled up to $1.5\times$ their legacy sizing boundaries (button height locked at 40px). +* **Viewport Placement:** Move the button group into the center cluster on the same horizontal row plane as the ruler axis (positioned immediately to the left of the Time Ruler). This ensures the sound engineer's focus safely encapsulates macro controls alongside timeline visuals. + +--- + +## 2. Timeline Mechanics & Advanced Clip Editing + +### 2.1. Clip Delete vs. Track Delete Logic + +The environment explicitly segregates asset deletions from track configurations to protect project layout structures: + +* **Clip Erasure (`Delete` Key):** When a user triggers `Delete` or `Backspace` keys while an active Audio Clip segment is selected, the application drops the graphical boundary and unloads its corresponding sample sequence from the timeline. The containing track channel remains safely intact. +* **Track Disassembly (TCP Delete Button):** Add a compact red trash bin icon (`w-4 h-4 text-red-500 hover:text-red-400`) into the right edge profile of every TCP block. Engaging this trigger purges the entire track lane along with all embedded clip blocks out of the project. + +### 2.2. Preserve Selection Border Resize + +* **Legacy Behavior:** Clicking or interacting directly with selection handles accidentally flags a focus reset, clearing the bôi màu canvas overlay. +* **Preserve & Scale System:** +* When hovering the mouse near the explicit left or right edge boundaries of an active selection zone (within a $\pm 5\text{ px}$ tolerance window), the cursor style changes to `ew-resize`. +* Triggering a mouse drag updates, expands, or shrinks selection markers continuously without clearing the overlay mask. + + +* **Escape Loop Hook (Clear Focus):** The colored selection range is unmapped if and only if the user executes a `Ctrl + Click` shortcut interaction over an empty, unpopulated quadrant outside the selection bounds. + +### 2.3. Sub-tab Sandboxing + +When a user highlights a clip portion and triggers "Edit in Sub-tab" or double-clicks a targeted audio asset clip: + +1. **Buffer Extraction:** The system maps a non-destructive copy of the target sub-region's audio slice into memory buffers. +2. **Tab Instantiation:** Appends a temporary document window onto the global Tab container bar (e.g., `Tab: Sample_Edit_1`). +3. **Automated Insertion:** Instantiates a single empty track channel workspace inside the tab context and drops the cloned audio segment at the absolute root milestone ($t = 0.0\text{ s}$). Editors evaluate local actions here before clicking *Apply* to pass the updated data payload back to the main session track. + +### 2.4. Zero-Crossing Filter Tool & AI Cut + +Automated crossfade calculation mechanics to eliminate popping anomalies during clip slicing: + +1. A user selects a timeline region and hits the *AI Analysis* utility. +2. The server processes the audio block using NumPy arrays to locate phase inversion milestones (where amplitude values cross from negative to positive indices or vice versa) closest to the selection boundary vectors: + +$$x[i] \cdot x[i+1] \le 0$$ + + +3. The engine moves the actual slice boundaries to match these optimized zero-crossing sample addresses ($t'_{\text{start}}$ and $t'_{\text{end}}$). +4. Upon clicking *AI Cut*, the underlying engine runs the physical audio slice at the perfect sample indices, duplicates the segment, and appends it to a freshly populated track lane added right below the source track. + +### 2.5. Track Height Resizing + +* **Interaction:** Users can hover over the dividing line between two track lanes on either the left TCP column or the right Waveform viewport (the cursor scales to `ns-resize`). +* **Drag-and-Drop Mapping:** Dragging downward expands the specific track lane vertical ceiling (up to an upper bound of $200\text{ px}$), magnifying waveform amplitude layouts for precision edits. Dragging upward reduces the height dimension (down to a lower ceiling of $48\text{ px}$) for macro project navigation. + +### 2.6. Time-Stretching & Speed Math + +Alters the playback rate (*Speed*) of audio clips directly from the interactive timeline view: + +```text + [ Right Boundary Drag Interaction ] + Alt + Left-Click & Drag the right border outwards (Expand) + |<────────────────── Original Clip ──────────────────>| ++─────────────────+─────────────────────────────────────────────────────+──────────+ +| Track Waveform | ███████████████████████████████████████████████████ | | ++─────────────────+─────────────────────────────────────────────────────+──────────+ + ▲ ▲ + │ │ + │ ▼ [ Expand Rightward ] ++─────────────────+────────────────────────────────────────────────────────────────+ +| Track Waveform | █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ | ++─────────────────+────────────────────────────────────────────────────────────────+ + │ │ + │ Visual Speed Tag: "Speed: 50%" │ + |<────────────────────────── D' ─────────────────────────────>| + +``` + +* **Modifier Binding:** Hold down the `Alt` key, left-click the rightmost bounding handle of a clip, and drag the boundary left or right. +* **Speed Ratio Formula ($S$):** Let $D$ represent the native unscaled duration value of the clip block (seconds), and $D'$ map to the modified duration value generated post-drag (seconds). The calculation for the updated target playback rate percentage ($S$) follows: + +$$S = \frac{D}{D'} \times 100\%$$ + + +* **Display Modifiers:** +* *Expanding rightward ($D' > D$):* Yields $S < 100\%$, meaning playback velocity drops (deceleration). Depending on DSP choices, pitches can either remain locked or drop proportionally. +* *Compressing leftward ($D' < D$):* Yields $S > 100\%$, accelerating the playback engine velocity through the clip. + + +* **Visual Metadata Tag:** A bright yellow text overlay displaying the calculated playback velocity percentage (e.g., `Speed: 75.0%` or `Speed: 120.5%`) is pinned directly to the upper-left boundary of the audio clip container. + +--- + +## 3. Python Porting Manual (PyQt6 / PySide6) + +When writing execution blocks for time-stretching and audio rate modulations onto the Python backend server layers, leverage standard scientific audio packages such as `numpy` or `rubberband` to scale signal arrays without warping Phase layouts: + +```python +# [PYTHON PORTING BLUEPRINT] - Acoustic Time-Stretching Velocity Algorithm +import numpy as np +import librosa + +def stretch_audio_clip_speed(y: np.ndarray, sr: int, speed_ratio: float) -> np.ndarray: + """ + Stretches or compresses a NumPy audio signal array using the target speed_ratio factor. + speed_ratio = 0.5 slows down velocity by half (expanding physical layout width by 2x). + speed_ratio = 2.0 doubles velocity (compressing physical layout width by half). + """ + if speed_ratio == 1.0: + return y + + # Phase Vocoder approach via Librosa to alter speed while locking pitch (Pitch-preserving stretch): + # y_stretched = librosa.effects.time_stretch(y, rate=speed_ratio) + + # Linear Resampling approach (Alters pitch along with velocity - vinyl style deceleration): + num_samples_new = int(len(y) / speed_ratio) + y_resampled = np.interp( + np.linspace(0, len(y) - 1, num_samples_new), + np.arange(len(y)), + y + ) + return y_resampled.astype(np.float32) + +``` \ No newline at end of file diff --git a/app/templates/index.html b/app/templates/index.html index f63d915..a117c35 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -31,6 +31,13 @@ .selection-interactive-box { min-width: 4px; } + .no-scrollbar { + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE 10+ */ + } + .no-scrollbar::-webkit-scrollbar { + display: none; /* Safari and Chrome */ + } @@ -175,8 +182,12 @@ onTrackLaneMouseDown, onContextMenu, onClipDragStart, + onClipStretchStart, + onSelectionEdgeDragStart, + setSelectedClipId, activeTool, onSplitTrackAtTime, + onEditClipInSubTab, snapValue, bpm, }) => { @@ -237,7 +248,8 @@ id: 'default', buffer: track.buffer, startTime: track.startTime || 0, - name: track.name + name: track.name, + speed: track.speed || 1.0 }] : []); if (clips.length > 0) { @@ -245,7 +257,9 @@ const data = clip.buffer.getChannelData(0); const sampleRate = clip.buffer.sampleRate; const totalSamples = data.length; - const duration = totalSamples / sampleRate; + const originalDuration = totalSamples / sampleRate; + const clipSpeed = clip.speed || 1.0; + const duration = originalDuration / clipSpeed; const xStart = (clip.startTime || 0) * zoom; const wClip = duration * zoom; @@ -268,11 +282,17 @@ ctx.fill(); ctx.stroke(); - // 2. Draw Clip Label + // 2. Draw Clip Label & Speed Label (Speed Math: D/D' * 100) ctx.fillStyle = '#e4e4e7'; ctx.font = 'bold 9px sans-serif'; ctx.fillText(clip.name || 'Clip', xStart + 8, clipTop + 14); + if (clipSpeed !== 1.0) { + ctx.fillStyle = '#fbbf24'; // Yellow color + ctx.font = 'bold 8px sans-serif'; + ctx.fillText(`Speed: ${(clipSpeed * 100).toFixed(1)}%`, xStart + 8, clipTop + 24); + } + // Draw markers if (markers && markers.length > 0) { markers.forEach(m => { @@ -284,16 +304,16 @@ }); } - // 3. Peak waveform drawing only within clip bounds + // 3. Peak waveform drawing only within clip bounds (speed adjusted) ctx.strokeStyle = isSelected ? '#22d3ee' : '#a7f3d0'; ctx.lineWidth = 1; const drawXStart = Math.max(0, Math.floor(xStart)); const drawXEnd = Math.min(width, Math.ceil(xEnd)); - const samplesPerPixel = sampleRate / zoom; + const samplesPerPixel = (sampleRate / zoom) * clipSpeed; for (let px = drawXStart; px < drawXEnd; px++) { - const timeInClip = (px - xStart) / zoom; + const timeInClip = ((px - xStart) / zoom) * clipSpeed; const sampleIdx = Math.floor(timeInClip * sampleRate); const chunkSize = Math.max(1, Math.floor(samplesPerPixel)); const chunkStart = Math.max(0, sampleIdx - Math.floor(chunkSize / 2)); @@ -351,10 +371,36 @@ id: 'default', buffer: track.buffer, startTime: track.startTime || 0, - name: track.name + name: track.name, + speed: track.speed || 1.0 }] : []); - const hoveredClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration); + // Check if hovering near local selection boundaries of this track + const isLocal = selectionMode === 'local' && localSelectionTrackId === track.id; + if (isLocal && localSelLeft !== null && localSelRight !== null) { + const leftPx = localSelLeft * zoom; + const rightPx = localSelRight * zoom; + const distToLeft = Math.abs(x - leftPx); + const distToRight = Math.abs(x - rightPx); + if (distToLeft <= 5 || distToRight <= 5) { + canvasRef.current.style.cursor = 'ew-resize'; + return; + } + } + + // Check if hovering near right edge of a clip for time-stretching (Alt key required) + const toleranceSec = 8 / zoom; + const rightEdgeClip = clips.find(c => { + const duration = c.buffer.duration / (c.speed || 1.0); + return Math.abs(time - (c.startTime + duration)) <= toleranceSec; + }); + + if (rightEdgeClip && e.altKey) { + canvasRef.current.style.cursor = 'ew-resize'; + return; + } + + const hoveredClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); const isOverClip = !!hoveredClip; if (activeTool === 'grab') { @@ -369,9 +415,9 @@ onMouseDown={(e) => { // Ignore right-click for local selection drag (context menu handles it) if (e.button === 2) return; + const rect = canvasRef.current.getBoundingClientRect(); const wrapper = canvasRef.current?.parentElement?.parentElement; const scrollLeft = wrapper ? wrapper.scrollLeft : 0; - const rect = canvasRef.current.getBoundingClientRect(); const x = e.clientX - rect.left + scrollLeft; const time = Math.max(0, x / zoom); onSelectTrack(track.id); @@ -380,10 +426,54 @@ id: 'default', buffer: track.buffer, startTime: track.startTime || 0, - name: track.name + name: track.name, + speed: track.speed || 1.0 }] : []); + + // Check if dragging selection boundaries (local mode) + const isLocal = selectionMode === 'local' && localSelectionTrackId === track.id; + if (isLocal && localSelLeft !== null && localSelRight !== null) { + const leftPx = localSelLeft * zoom; + const rightPx = localSelRight * zoom; + const distToLeft = Math.abs(x - leftPx); + const distToRight = Math.abs(x - rightPx); + if (distToLeft <= 5) { + e.preventDefault(); + e.stopPropagation(); + if (onSelectionEdgeDragStart) onSelectionEdgeDragStart(e, track.id, 'left'); + return; + } else if (distToRight <= 5) { + e.preventDefault(); + e.stopPropagation(); + if (onSelectionEdgeDragStart) onSelectionEdgeDragStart(e, track.id, 'right'); + return; + } + } + + // Check if time-stretching (Alt + Right Edge) + const toleranceSec = 8 / zoom; + const rightEdgeClip = clips.find(c => { + const duration = c.buffer.duration / (c.speed || 1.0); + return Math.abs(time - (c.startTime + duration)) <= toleranceSec; + }); + + if (rightEdgeClip && e.altKey) { + e.preventDefault(); + e.stopPropagation(); + if (onClipStretchStart) { + onClipStretchStart(track.id, rightEdgeClip.id, time); + } + return; + } - const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration); + const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); + + // Set selected clip ID + if (clickedClip) { + setSelectedClipId({ trackId: track.id, clipId: clickedClip.id === 'default' ? 'default_' + track.id : clickedClip.id }); + } else { + setSelectedClipId(null); + } if (activeTool === 'razor') { if (clickedClip) { @@ -419,12 +509,48 @@ return; } + // Check if Ctrl+Click outside active local selection to clear + if (e.ctrlKey) { + const minSel = localSelLeft !== null && localSelRight !== null ? Math.min(localSelLeft, localSelRight) : null; + const maxSel = localSelLeft !== null && localSelRight !== null ? Math.max(localSelLeft, localSelRight) : null; + const isInsideSelection = isLocal && minSel !== null && maxSel !== null && time >= minSel && time <= maxSel; + if (!isInsideSelection) { + clearLocalSelection(); + setSelectionMode(null); + } + return; + } + onPlayheadSet(time); if (onTrackLaneMouseDown) { onTrackLaneMouseDown(track.id, time, e); } e.stopPropagation(); }} + onDoubleClick={(e) => { + const rect = canvasRef.current.getBoundingClientRect(); + const wrapper = canvasRef.current?.parentElement?.parentElement; + const scrollLeft = wrapper ? wrapper.scrollLeft : 0; + const x = e.clientX - rect.left + scrollLeft; + const time = Math.max(0, x / zoom); + + const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ + id: 'default', + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name, + speed: track.speed || 1.0 + }] : []); + + const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); + if (clickedClip) { + e.preventDefault(); + e.stopPropagation(); + if (onEditClipInSubTab) { + onEditClipInSubTab(track.id, clickedClip.id); + } + } + }} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); @@ -586,9 +712,9 @@ const App = () => { // ── State Definitions ── - const [tracks, setTracks] = useState([ - { id: '1', name: 'Track 01', buffer: null, startTime: 0, volume: 0.8, muted: false, solo: false, color: '#0f766e', markers: [], serverFileId: null }, - { id: '2', name: 'Track 02', buffer: null, startTime: 0, volume: 0.8, muted: false, solo: false, color: '#1d4ed8', markers: [], serverFileId: null }, + const [tracks, setTracks] = useState([ + { id: '1', name: 'Track 01', buffer: null, startTime: 0, height: 96, volume: 0.8, muted: false, solo: false, color: '#0f766e', markers: [], serverFileId: null }, + { id: '2', name: 'Track 02', buffer: null, startTime: 0, height: 96, volume: 0.8, muted: false, solo: false, color: '#1d4ed8', markers: [], serverFileId: null }, ]); const [bpm, setBpm] = useState(localStorage.getItem('studio_bpm') || '120'); const [draggedClip, setDraggedClip] = useState(null); // { trackId, clickOffset, buffer, name, volume, color } @@ -656,6 +782,8 @@ }); const [serverStatus, setServerStatus] = useState('checking...'); const [menuOpen, setMenuOpen] = useState(null); + const [selectedClipId, setSelectedClipId] = useState(null); // { trackId, clipId } + const [stretchedClip, setStretchedClip] = useState(null); // { trackId, clipId, originalDuration, startTime, originalSpeed, beforeSnap } // ── Context Menu & Clipboard ── const [contextMenu, setContextMenu] = useState(null); // { x, y, trackId } @@ -772,6 +900,9 @@ handleUndoRef.current = handleUndo; handleRedoRef.current = handleRedo; + const selectedClipIdRef = useRef(null); + selectedClipIdRef.current = selectedClipId; + useEffect(() => { const handler = (e) => { const ctrl = e.ctrlKey || e.metaKey; @@ -780,7 +911,7 @@ if (ctrl && e.key === 'z' && !e.shiftKey) { e.preventDefault(); handleUndoRef.current(); return; } if (ctrl && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); handleRedoRef.current(); return; } if (ctrl && !alt && e.key === 'o') { e.preventDefault(); showToast('Open Project dialog','info'); return; } - if (ctrl && !alt && e.key === 'n') { e.preventDefault(); setTracks([{ id:'1', name:'Track 01', buffer:null, startTime:0, volume:0.8, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, startTime:0, volume:0.8, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','info'); return; } + if (ctrl && !alt && e.key === 'n') { e.preventDefault(); setTracks([{ id:'1', name:'Track 01', buffer:null, startTime:0, height:96, volume:0.8, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, startTime:0, height:96, volume:0.8, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','info'); return; } if (ctrl && !alt && e.key === 's') { e.preventDefault(); showToast('Project saved','success'); return; } if ((ctrl && alt && e.key === 's') || (ctrl && e.shiftKey && e.key === 's')) { e.preventDefault(); showToast('Save As dialog','info'); return; } if (ctrl && !alt && e.key === 'i') { e.preventDefault(); addNewTrack(); return; } @@ -790,7 +921,46 @@ if (ctrl && !alt && e.key === 'c') { e.preventDefault(); handleCopyTrack(); return; } if (ctrl && !alt && e.key === 'x') { e.preventDefault(); handleCutTrack(); return; } if (ctrl && !alt && e.key === 'v') { e.preventDefault(); handlePasteTrack(); return; } - if (e.key === 'Delete' || e.key === 'Del') { e.preventDefault(); handleDeleteTrack(); return; } + if (e.key === 'Delete' || e.key === 'Backspace' || e.key === 'Del') { + const selClip = selectedClipIdRef.current; + if (selClip) { + e.preventDefault(); + const { trackId, clipId } = selClip; + setTracks(prev => { + const track = prev.find(t => t.id === trackId); + if (!track) return prev; + + const beforeSnap = captureTrackSnapshotRef.current ? captureTrackSnapshotRef.current(trackId) : null; + const updatedClips = (track.clips || []).filter(c => c.id !== clipId); + const updatedTracks = prev.map(t => { + if (t.id === trackId) { + return { + ...t, + clips: updatedClips, + buffer: updatedClips.length > 0 ? updatedClips[0].buffer : null, + startTime: updatedClips.length > 0 ? updatedClips[0].startTime : 0, + name: updatedClips.length > 0 ? updatedClips[0].name : `Track ${t.id}` + }; + } + return t; + }); + + setTimeout(() => { + const afterSnap = captureTrackSnapshotRef.current ? captureTrackSnapshotRef.current(trackId) : null; + pushAction('DELETE_CLIP', trackId, beforeSnap, afterSnap); + }, 50); + + return updatedTracks; + }); + setSelectedClipId(null); + showToast('Đã xóa clip.', 'info'); + return; + } else { + e.preventDefault(); + handleDeleteTrack(); + return; + } + } if (!ctrl && !alt && e.key === 's') { e.preventDefault(); handleSplitTrack(selectedTrackId); return; } }; window.addEventListener('keydown', handler); @@ -883,89 +1053,150 @@ showToast(`Đã tạo Sub Tab: ${tabLabel}. Chỉnh sửa và Apply để gộp lại.`, 'info'); }; - // ── Sub Tab: apply effects & merge back (LOOP_EDITOR_2.md §1.2) ── - const applySubTab = (tabId) => { - const subTab = subTabs.find(s => s.id === tabId); - if (!subTab || !subTab.buffer) return; - const track = tracks.find(t => t.id === subTab.trackId); - if (!track || !track.buffer) return; + const handleEditClipInSubTab = (trackId, clipId) => { + const track = tracks.find(t => t.id === trackId); + if (!track) return; + + const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ + id: 'default_' + track.id, + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name + }] : []); + + const clip = clips.find(c => c.id === clipId || (clipId === 'default' && c.id === 'default_' + trackId)); + if (!clip || !clip.buffer) return; - const beforeSnap = captureTrackSnapshot(subTab.trackId); + const sr = clip.buffer.sampleRate; + const len = clip.buffer.length; + const ctx = getAudioContext(); + const subBuffer = ctx.createBuffer(1, len, sr); + subBuffer.copyToChannel(clip.buffer.getChannelData(0), 0); - // Clone buffer and apply effects - const ctx = getAudioContext(); - const eff = subTab.buffer.getChannelData(0); - const edBuffer = ctx.createBuffer(1, eff.length, subTab.buffer.sampleRate); - const edData = edBuffer.getChannelData(0); - edData.set(eff); + const tabId = 'subtab_' + Date.now(); + const tabLabel = `Edit_${clip.name.replace('.wav','').slice(0,10)}`; - // Apply effects inline - const fx = subTab.effects || {}; - // Reverse - if (fx.reverse) { - const reversed = new Float32Array(edData); - for (let i = 0; i < edData.length; i++) reversed[i] = edData[edData.length - 1 - i]; - edBuffer.copyToChannel(reversed, 0); - } - // Gain - if (fx.gainDb !== 0) { - const gain = Math.pow(10, fx.gainDb / 20); - for (let i = 0; i < edData.length; i++) edData[i] = Math.max(-1, Math.min(1, edData[i] * gain)); - } - // Fade in - if (fx.fadeInMs > 0) { - const sr = edBuffer.sampleRate; - const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeInMs / 1000 * sr)); - for (let i = 0; i < fadeSamples; i++) edData[i] = edData[i] * (i / fadeSamples); - } - // Fade out - if (fx.fadeOutMs > 0) { - const sr = edBuffer.sampleRate; - const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeOutMs / 1000 * sr)); - for (let i = edData.length - fadeSamples; i < edData.length; i++) { - edData[i] = edData[i] * ((edData.length - 1 - i) / fadeSamples); - } - } + setSubTabs(prev => [...prev, { + id: tabId, + label: tabLabel, + trackId: trackId, + clipId: clip.id === 'default' ? 'default_' + trackId : clip.id, + startTime: clip.startTime, + endTime: clip.startTime + clip.buffer.duration, + buffer: subBuffer, + effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0 }, + }]); + setActiveTab(tabId); + showToast(`Đã tạo Sub Tab: ${tabLabel}. Chỉnh sửa và Apply để gộp lại.`, 'info'); + }; - // Crossfade merge into original track (§2.2) - const sr = track.buffer.sampleRate; - const origData = track.buffer.getChannelData(0); - const trackStart = track.startTime || 0; - const startSample = Math.floor((subTab.startTime - trackStart) * sr); - const endSample = Math.floor((subTab.endTime - trackStart) * sr); - const crossfadeLen = Math.min(100, Math.floor(0.01 * sr)); // 10ms + // ── Sub Tab: apply effects & merge back (LOOP_EDITOR_2.md §1.2) ── + const applySubTab = (tabId) => { + const subTab = subTabs.find(s => s.id === tabId); + if (!subTab || !subTab.buffer) return; + const track = tracks.find(t => t.id === subTab.trackId); + if (!track || !track.buffer) return; - const mergedBuffer = ctx.createBuffer(1, track.buffer.length, sr); - const mergedData = mergedBuffer.getChannelData(0); - for (let i = 0; i < startSample; i++) mergedData[i] = origData[i]; - for (let i = endSample; i < track.buffer.length; i++) mergedData[i] = origData[i]; + const beforeSnap = captureTrackSnapshot(subTab.trackId); - for (let i = 0; i < edData.length; i++) { - const globalIdx = startSample + i; - let val = edData[i]; - if (i < crossfadeLen) { - const alpha = i / crossfadeLen; - val = (1 - alpha) * (origData[globalIdx] || 0) + alpha * edData[i]; - } else if (i > edData.length - crossfadeLen) { - const distFromEnd = edData.length - 1 - i; - const alpha = distFromEnd / crossfadeLen; - const origEndIdx = endSample - (edData.length - i); - val = alpha * (origEndIdx >= 0 ? origData[origEndIdx] : 0) + (1 - alpha) * edData[i]; - } - mergedData[globalIdx] = val; - } + // Clone buffer and apply effects + const ctx = getAudioContext(); + const eff = subTab.buffer.getChannelData(0); + const edBuffer = ctx.createBuffer(1, eff.length, subTab.buffer.sampleRate); + const edData = edBuffer.getChannelData(0); + edData.set(eff); - setTracks(prev => prev.map(t => { - if (t.id !== subTab.trackId) return t; - return { ...t, buffer: mergedBuffer, name: t.name + ' (edited)' }; - })); + // Apply effects inline + const fx = subTab.effects || {}; + // Reverse + if (fx.reverse) { + const reversed = new Float32Array(edData); + for (let i = 0; i < edData.length; i++) reversed[i] = edData[edData.length - 1 - i]; + edBuffer.copyToChannel(reversed, 0); + } + // Gain + if (fx.gainDb !== 0) { + const gain = Math.pow(10, fx.gainDb / 20); + for (let i = 0; i < edData.length; i++) edData[i] = Math.max(-1, Math.min(1, edData[i] * gain)); + } + // Fade in + if (fx.fadeInMs > 0) { + const sr = edBuffer.sampleRate; + const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeInMs / 1000 * sr)); + for (let i = 0; i < fadeSamples; i++) edData[i] = edData[i] * (i / fadeSamples); + } + // Fade out + if (fx.fadeOutMs > 0) { + const sr = edBuffer.sampleRate; + const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeOutMs / 1000 * sr)); + for (let i = edData.length - fadeSamples; i < edData.length; i++) { + edData[i] = edData[i] * ((edData.length - 1 - i) / fadeSamples); + } + } - const afterSnap = captureTrackSnapshot(subTab.trackId); - pushAction('EDIT_TAB', subTab.trackId, beforeSnap, afterSnap); + if (subTab.clipId) { + // Clip-based merge + setTracks(prev => prev.map(t => { + if (t.id !== subTab.trackId) return t; + const updatedClips = (t.clips || []).map(c => { + if (c.id === subTab.clipId) { + return { + ...c, + buffer: edBuffer, + name: c.name.endsWith('(edited)') ? c.name : c.name + ' (edited)' + }; + } + return c; + }); + return { + ...t, + clips: updatedClips, + buffer: updatedClips[0]?.buffer, + startTime: updatedClips[0]?.startTime || 0, + name: updatedClips[0]?.name || t.name + }; + })); + } else { + // Crossfade merge into original track (§2.2) + const sr = track.buffer.sampleRate; + const origData = track.buffer.getChannelData(0); + const trackStart = track.startTime || 0; + const startSample = Math.floor((subTab.startTime - trackStart) * sr); + const endSample = Math.floor((subTab.endTime - trackStart) * sr); + const crossfadeLen = Math.min(100, Math.floor(0.01 * sr)); // 10ms - closeSubTab(tabId); - showToast('Đã áp dụng chỉnh sửa vào track chính với crossfade.', 'success'); - }; + const mergedBuffer = ctx.createBuffer(1, track.buffer.length, sr); + const mergedData = mergedBuffer.getChannelData(0); + for (let i = 0; i < startSample; i++) mergedData[i] = origData[i]; + for (let i = endSample; i < track.buffer.length; i++) mergedData[i] = origData[i]; + + for (let i = 0; i < edData.length; i++) { + const globalIdx = startSample + i; + let val = edData[i]; + if (i < crossfadeLen) { + const alpha = i / crossfadeLen; + val = (1 - alpha) * (origData[globalIdx] || 0) + alpha * edData[i]; + } else if (i > edData.length - crossfadeLen) { + const distFromEnd = edData.length - 1 - i; + const alpha = distFromEnd / crossfadeLen; + const origEndIdx = endSample - (edData.length - i); + val = alpha * (origEndIdx >= 0 ? origData[origEndIdx] : 0) + (1 - alpha) * edData[i]; + } + mergedData[globalIdx] = val; + } + + setTracks(prev => prev.map(t => { + if (t.id !== subTab.trackId) return t; + return { ...t, buffer: mergedBuffer, name: t.name + ' (edited)' }; + })); + } + + const afterSnap = captureTrackSnapshot(subTab.trackId); + pushAction('EDIT_TAB', subTab.trackId, beforeSnap, afterSnap); + + closeSubTab(tabId); + showToast('Đã áp dụng chỉnh sửa vào track chính.', 'success'); + }; const closeSubTab = (tabId) => { setSubTabs(prev => prev.filter(s => s.id !== tabId)); @@ -998,7 +1229,21 @@ const track = tracks.find(t => t.id === contextMenu.trackId); if (track) setSelectedTrackId(contextMenu.trackId); closeContextMenu(); - openTempTab(); + + const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ + id: 'default', + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name, + speed: track.speed || 1.0 + }] : []); + const clickedClip = clips.find(c => contextMenu.time >= c.startTime && contextMenu.time < c.startTime + c.buffer.duration / (c.speed || 1.0)); + + if (clickedClip) { + handleEditClipInSubTab(contextMenu.trackId, clickedClip.id); + } else { + openTempTab(); + } }; const contextMenuSplit = () => { @@ -1201,7 +1446,7 @@ }); let mp = 0; for (let i=0;imp) mp=a; } if (mp > 1.0) for (let i=0;i [...p, { id:'merged_'+Date.now(), name:'Merged_mix.wav', buffer:mb, startTime: 0, volume:0.8, muted:false, solo:false, color:['#0f766e','#1d4ed8'][p.length%2], markers:[], serverFileId:null }]); + setTracks(p => [...p, { id:'merged_'+Date.now(), name:'Merged_mix.wav', buffer:mb, startTime: 0, height: 96, volume:0.8, muted:false, solo:false, color:['#0f766e','#1d4ed8'][p.length%2], markers:[], serverFileId:null }]); showToast('Merged all unmuted tracks.','success'); }; const handleCopyTrack = () => { @@ -1300,10 +1545,20 @@ const maxDuration = useMemo(() => { let max = 10; tracks.forEach(t => { - if (t.buffer) { - const trackStart = t.startTime || 0; - max = Math.max(max, trackStart + t.buffer.duration); - } + const clips = t.clips && t.clips.length > 0 ? t.clips : (t.buffer ? [{ + id: 'default', + buffer: t.buffer, + startTime: t.startTime || 0, + name: t.name, + speed: t.speed || 1.0 + }] : []); + clips.forEach(c => { + if (c.buffer) { + const cStart = c.startTime || 0; + const cDur = c.buffer.duration / (c.speed || 1.0); + max = Math.max(max, cStart + cDur); + } + }); }); return max; }, [tracks]); @@ -1502,7 +1757,8 @@ id: 'default', buffer: track.buffer, startTime: track.startTime || 0, - name: track.name + name: track.name, + speed: track.speed || 1.0 }] : []); clips.forEach(clip => { @@ -1510,6 +1766,7 @@ const source = context.createBufferSource(); source.buffer = clip.buffer; + source.playbackRate.value = clip.speed || 1.0; const gainNode = context.createGain(); gainNode.gain.setValueAtTime(track.volume, context.currentTime); @@ -1518,7 +1775,7 @@ gainNode.connect(context.destination); const clipStart = clip.startTime || 0; - const clipDuration = clip.buffer.duration; + const clipDuration = clip.buffer.duration / (clip.speed || 1.0); const clipEnd = clipStart + clipDuration; if (offsetTime < clipStart) { @@ -1527,7 +1784,7 @@ activeSourcesRef.current.push(source); } else if (offsetTime < clipEnd) { const playOffset = offsetTime - clipStart; - source.start(context.currentTime, playOffset); + source.start(context.currentTime, playOffset * (clip.speed || 1.0)); activeSourcesRef.current.push(source); } }); @@ -1544,7 +1801,8 @@ id: 'default', buffer: track.buffer, startTime: track.startTime || 0, - name: track.name + name: track.name, + speed: track.speed || 1.0 }] : []); clips.forEach(clip => { @@ -1552,6 +1810,7 @@ const source = context.createBufferSource(); source.buffer = clip.buffer; + source.playbackRate.value = clip.speed || 1.0; const gainNode = context.createGain(); gainNode.gain.setValueAtTime(track.volume, context.currentTime); @@ -1560,7 +1819,7 @@ gainNode.connect(context.destination); const clipStart = clip.startTime || 0; - const clipDuration = clip.buffer.duration; + const clipDuration = clip.buffer.duration / (clip.speed || 1.0); const clipEnd = clipStart + clipDuration; if (offsetTime < clipStart) { @@ -1569,7 +1828,7 @@ activeSourcesRef.current.push(source); } else if (offsetTime < clipEnd) { const playOffset = offsetTime - clipStart; - source.start(context.currentTime, playOffset); + source.start(context.currentTime, playOffset * (clip.speed || 1.0)); activeSourcesRef.current.push(source); } }); @@ -1618,7 +1877,7 @@ if (!wrapper) return; const rect = wrapper.getBoundingClientRect(); const scrollLeft = wrapper.scrollLeft; - const mouseX = e.clientX - rect.left + scrollLeft - 300; + const mouseX = e.clientX - rect.left + scrollLeft; const time = mouseX / zoom; clearLocalSelection(); @@ -1638,7 +1897,7 @@ if (!wrapper) return; const rect = wrapper.getBoundingClientRect(); const scrollLeft = wrapper.scrollLeft; - const mouseX = e.clientX - rect.left + scrollLeft - 300; + const mouseX = e.clientX - rect.left + scrollLeft; const time = Math.max(0, Math.min(maxDuration, mouseX / zoom)); setSelectionEnd(time); @@ -1674,18 +1933,18 @@ localDragStartTimeRef.current = time; }; - // Document-level mousemove/mouseup for local selection drag - useEffect(() => { - const handleMouseMove = (e) => { - if (!localDragInProgressRef.current) return; - const wrapper = timelineWrapperRef.current; - if (!wrapper) return; - const rect = wrapper.getBoundingClientRect(); - const scrollLeft = wrapper.scrollLeft; - const mouseX = e.clientX - rect.left + scrollLeft - 300; - const time = Math.max(0, Math.min(maxDuration, mouseX / zoom)); - setLocalSelectionEnd(time); - }; + // Document-level mousemove/mouseup for local selection drag + useEffect(() => { + const handleMouseMove = (e) => { + if (!localDragInProgressRef.current) return; + const wrapper = timelineWrapperRef.current; + if (!wrapper) return; + const rect = wrapper.getBoundingClientRect(); + const scrollLeft = wrapper.scrollLeft; + const mouseX = e.clientX - rect.left + scrollLeft; + const time = Math.max(0, Math.min(maxDuration, mouseX / zoom)); + setLocalSelectionEnd(time); + }; const handleMouseUp = () => { if (localDragInProgressRef.current) { localDragInProgressRef.current = false; @@ -1773,6 +2032,101 @@ }); }; + const stretchedClipRef = useRef(null); + stretchedClipRef.current = stretchedClip; + + const handleClipStretchStart = (trackId, clipId, clickTime) => { + const track = tracks.find(t => t.id === trackId); + if (!track) return; + const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ + id: 'default_' + track.id, + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name, + speed: track.speed || 1.0 + }] : []); + + const clip = clips.find(c => c.id === clipId || (clipId === 'default' && c.id === 'default_' + trackId)); + if (!clip || !clip.buffer) return; + + const beforeSnap = captureTrackSnapshot(trackId); + + if (!track.clips || track.clips.length === 0) { + setTracks(prev => prev.map(t => t.id === trackId ? { ...t, clips } : t)); + } + + setStretchedClip({ + trackId, + clipId: clip.id === 'default' ? 'default_' + trackId : clip.id, + originalDuration: clip.buffer.duration, + startTime: clip.startTime, + originalSpeed: clip.speed || 1.0, + beforeSnap + }); + }; + + const handleSelectionEdgeDragStart = (e, trackId, side) => { + const startX = e.clientX; + const initialLeft = Math.min(localSelectionStart, localSelectionEnd); + const initialRight = Math.max(localSelectionStart, localSelectionEnd); + + const handleMouseMove = (moveEvent) => { + const deltaX = moveEvent.clientX - startX; + const deltaSec = deltaX / zoom; + if (side === 'left') { + const newLeft = Math.max(0, Math.min(initialRight - 0.05, initialLeft + deltaSec)); + setLocalSelectionStart(newLeft); + setLocalSelectionEnd(initialRight); + } else { + const newRight = Math.max(initialLeft + 0.05, Math.min(maxDuration, initialRight + deltaSec)); + setLocalSelectionStart(initialLeft); + setLocalSelectionEnd(newRight); + } + }; + + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + + const handleTrackResizeMouseDown = (e, trackId) => { + e.preventDefault(); + e.stopPropagation(); + const startY = e.clientY; + const track = tracks.find(t => t.id === trackId); + const startHeight = track ? (track.height || 96) : 96; + + const handleMouseMove = (moveEvent) => { + const deltaY = moveEvent.clientY - startY; + const newHeight = Math.max(48, Math.min(200, startHeight + deltaY)); + setTracks(prev => prev.map(t => t.id === trackId ? { ...t, height: newHeight } : t)); + }; + + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + + const deleteTrack = (trackId) => { + const beforeSnap = captureTrackSnapshot(trackId); + setTracks(prev => { + const filtered = prev.filter(t => t.id !== trackId); + if (filtered.length > 0) { + setSelectedTrackId(filtered[0].id); + } + return filtered; + }); + showToast('Đã xóa track.', 'info'); + }; + useEffect(() => { const handleMouseMove = (e) => { const drag = draggedClipRef.current; @@ -1783,7 +2137,7 @@ const rect = wrapper.getBoundingClientRect(); const scrollLeft = wrapper.scrollLeft; - const mouseX = e.clientX - rect.left + scrollLeft - 300; + const mouseX = e.clientX - rect.left + scrollLeft; const time = mouseX / zoom; const rawStart = Math.max(0, time - drag.clickOffset); const newStart = snapTime(rawStart, snapValueRef.current, bpmRef.current); @@ -1850,13 +2204,72 @@ showToast('Đã di chuyển clip.', 'success'); }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - }, [zoom]); + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [zoom]); + + // Document-level mousemove/mouseup for clip stretching + useEffect(() => { + const handleMouseMove = (e) => { + const stretch = stretchedClipRef.current; + if (!stretch) return; + + const wrapper = timelineWrapperRef.current; + if (!wrapper) return; + + const rect = wrapper.getBoundingClientRect(); + const scrollLeft = wrapper.scrollLeft; + const mouseX = e.clientX - rect.left + scrollLeft; + const time = mouseX / zoom; + + const newDuration = Math.max(0.1, time - stretch.startTime); + const speedRatio = stretch.originalDuration / newDuration; + + setTracks(prev => prev.map(t => { + if (t.id === stretch.trackId) { + const updatedClips = (t.clips || []).map(c => { + if (c.id === stretch.clipId) { + return { + ...c, + speed: speedRatio + }; + } + return c; + }); + return { + ...t, + clips: updatedClips, + buffer: updatedClips[0]?.buffer, + startTime: updatedClips[0]?.startTime || 0, + speed: updatedClips[0]?.speed || 1.0 + }; + } + return t; + })); + }; + + const handleMouseUp = () => { + const stretch = stretchedClipRef.current; + if (!stretch) return; + + const afterSnap = captureTrackSnapshotRef.current(stretch.trackId); + pushAction('STRETCH_CLIP', stretch.trackId, stretch.beforeSnap, afterSnap); + + setStretchedClip(null); + showToast('Đã giãn thời gian clip.', 'success'); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [zoom]); const handleSelectRange = (start, end, reset) => { const maxLen = maxDuration; @@ -2106,7 +2519,7 @@ const selectColor = colors[tracks.length % colors.length]; setTracks(prev => [...prev, { - id: newId, name: `Track ${newId}`, buffer: null, startTime: 0, + id: newId, name: `Track ${newId}`, buffer: null, startTime: 0, height: 96, volume: 0.8, muted: false, solo: false, color: selectColor, markers: [], serverFileId: null }]); @@ -2205,19 +2618,45 @@ try { const targetRate = parseInt(exportSettings.sampleRate); const bitDepth = parseInt(exportSettings.bitDepth); - const durationLimit = Math.max(...activeTracks.map(t => (t.startTime || 0) + t.buffer.duration)); + + const durationLimit = Math.max(...activeTracks.map(t => { + const clips = t.clips && t.clips.length > 0 ? t.clips : (t.buffer ? [{ + id: 'default', + buffer: t.buffer, + startTime: t.startTime || 0, + name: t.name, + speed: t.speed || 1.0 + }] : []); + if (clips.length === 0) return 0; + return Math.max(...clips.map(c => (c.startTime || 0) + c.buffer.duration / (c.speed || 1.0))); + })); - const offlineCtx = new OfflineAudioContext(1, Math.ceil(targetRate * durationLimit), targetRate); + const offlineCtx = new OfflineAudioContext(1, Math.ceil(targetRate * Math.max(0.1, durationLimit)), targetRate); activeTracks.forEach(t => { - const source = offlineCtx.createBufferSource(); - source.buffer = t.buffer; - const gain = offlineCtx.createGain(); - gain.gain.setValueAtTime(t.volume, 0); - source.connect(gain); - gain.connect(offlineCtx.destination); - const trackStart = t.startTime || 0; - source.start(trackStart); + const clips = t.clips && t.clips.length > 0 ? t.clips : (t.buffer ? [{ + id: 'default', + buffer: t.buffer, + startTime: t.startTime || 0, + name: t.name, + speed: t.speed || 1.0 + }] : []); + + clips.forEach(clip => { + if (!clip.buffer) return; + const source = offlineCtx.createBufferSource(); + source.buffer = clip.buffer; + source.playbackRate.value = clip.speed || 1.0; + + const gain = offlineCtx.createGain(); + gain.gain.setValueAtTime(t.volume, 0); + + source.connect(gain); + gain.connect(offlineCtx.destination); + + const clipStart = clip.startTime || 0; + source.start(clipStart); + }); }); const renderedBuffer = await offlineCtx.startRendering(); @@ -2306,12 +2745,23 @@ try { const result = await pollTaskResult(data.task_id, 15); if (result.bpm) { + if (selLeft !== null && selRight !== null && activeTrack && activeTrack.buffer) { + const snapStart = findZeroCrossing(activeTrack.buffer, selLeft); + const snapEnd = findZeroCrossing(activeTrack.buffer, selRight); + if (selectionMode === 'local') { + setLocalSelectionStart(snapStart); + setLocalSelectionEnd(snapEnd); + } else { + setSelectionStart(snapStart); + setSelectionEnd(snapEnd); + } + } setAnalysisState({ status: 'Hoàn thành phân tích (Server)', data: result, isRunning: false, }); - showToast(`AI Server: ${result.bpm} BPM | ${result.beats?.length || 0} beats detected`, 'success'); + showToast(`AI Server: ${result.bpm} BPM | ${result.beats?.length || 0} beats detected (Zero-Crossing Aligned)`, 'success'); return; } } catch (e) { @@ -2356,12 +2806,24 @@ } } + if (selLeft !== null && selRight !== null && activeTrack && activeTrack.buffer) { + const snapStart = findZeroCrossing(activeTrack.buffer, selLeft); + const snapEnd = findZeroCrossing(activeTrack.buffer, selRight); + if (selectionMode === 'local') { + setLocalSelectionStart(snapStart); + setLocalSelectionEnd(snapEnd); + } else { + setSelectionStart(snapStart); + setSelectionEnd(snapEnd); + } + } + setAnalysisState({ status: 'Hoàn thành phân tích (Client)', data: { bpm, bars: Math.max(4, Math.round(bpm / 30)), timeSig: '4/4', detectedKey: 'Am' }, isRunning: false, }); - showToast(`Phân tích: ${bpm} BPM`, 'success'); + showToast(`Phân tích: ${bpm} BPM (Zero-Crossing Aligned)`, 'success'); }, 1500); }; @@ -2442,23 +2904,38 @@ const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; const selectColor = colors[tracks.length % colors.length]; - setTracks(prev => [ - ...prev, - { - id: newId, - name: `AI_Cut_${activeTrack.name.replace('.wav', '')}_${snapStart.toFixed(1)}s.wav`, + const newTrack = { + id: newId, + name: `AI_Cut_${activeTrack.name.replace('.wav', '')}_${snapStart.toFixed(1)}s.wav`, + buffer: slicedBuffer, + startTime: snapStart, + clips: [{ + id: 'clip_' + newId, buffer: slicedBuffer, - volume: 0.8, - muted: false, - solo: false, - color: selectColor, - markers: [ - { id: Date.now() + '_s', time: 0 }, - { id: Date.now() + '_e', time: snapEnd - snapStart } - ], - serverFileId: null, + startTime: snapStart, + name: `AI_Cut_${activeTrack.name.replace('.wav', '')}_${snapStart.toFixed(1)}s` + }], + volume: 0.8, + muted: false, + solo: false, + color: selectColor, + markers: [ + { id: Date.now() + '_s', time: 0 }, + { id: Date.now() + '_e', time: snapEnd - snapStart } + ], + serverFileId: null, + }; + + 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); } - ]); + return updated; + }); setSelectedTrackId(newId); setAnalysisState({ @@ -2652,7 +3129,7 @@
{[ { label: 'File', items: [ - { label: 'New Project', icon: 'file-plus', shortcut: 'Ctrl+N', action: () => { setTracks([{ id:'1', name:'Track 01', buffer:null, volume:0.8, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, volume:0.8, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','info'); } }, + { label: 'New Project', icon: 'file-plus', shortcut: 'Ctrl+N', action: () => { setTracks([{ id:'1', name:'Track 01', buffer:null, startTime:0, height:96, volume:0.8, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, startTime:0, height:96, volume:0.8, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','info'); } }, { label: 'Open Project...', icon: 'folder-open', shortcut: 'Ctrl+O', action: () => showToast('Open project dialog','info') }, { label: 'Save Project', icon: 'save', shortcut: 'Ctrl+S', action: () => showToast('Project saved','success') }, { label: 'Save As...', icon: 'save', shortcut: 'Ctrl+Alt+S', action: () => showToast('Save as dialog','info') }, @@ -2711,7 +3188,7 @@ className="w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2"> {item.label} - {item.shortcut && {item.shortcut}} + {item.shortcut && {item.shortcut}} ))} @@ -2807,225 +3284,260 @@ )} - {/* ── Workspace ── */} + {/* ── Workspace (Split Dual-Column — 11_REFACTOR_UI.md) ── */}
-
- - {/* [ZONE A] Header Row */} -
- {/* Left Side: Channels & Tools Header */} -
- Kênh + {/* ══ LEFT COLUMN: TCP PANEL (fixed 300px, overflow hidden, z-30, solid bg) ══ */} +
+ {/* [TCP] Header Row (Channels & Tools) — height 40px (h-10) */} +
+ Kênh + + {/* Timeline Toolbar (scaled up buttons, height locked at 40px) */} +
+ + + - {/* Timeline Toolbar */} -
- - - - - {/* Separator */} -
- - {/* Quick Actions (Glue, Cut, Copy, Paste) */} - - - - -
+
- {/* Snap Section */} -
-
- Snap - -
+ + + +
- {/* Right Side: Time Ruler */} -
- {Array.from({ length: Math.ceil(maxDuration) }).map((_, i) => { - const sec = i; - const x = sec * zoom; - return ( -
- {formatTime(sec)} -
- ); - })} + +
+
+ Snap +
- {/* [ZONE A] Tempo Track Row */} -
- {/* Left Side: Tempo TCP */} -
-
-
- TM - Tempo Track -
-
- setBpm(e.target.value)} - onBlur={() => localStorage.setItem('studio_bpm', bpm)} - className="w-12 bg-zinc-800 border border-zinc-700 rounded text-[10px] text-zinc-300 text-center font-mono focus:outline-none focus:border-purple-500" - min="40" - max="300" - /> - BPM -
+ {/* [TCP] Tempo Track Row — height 40px (h-[40px]) */} +
+
+
+ TM + Tempo Track +
+
+ setBpm(e.target.value)} + onBlur={() => localStorage.setItem('studio_bpm', bpm)} + className="w-12 bg-zinc-800 border border-zinc-700 rounded text-[10px] text-zinc-300 text-center font-mono focus:outline-none focus:border-purple-500" + min="40" + max="300" + /> + BPM
-
- {/* Right Side: Tempo Lane */} -
-
- {/* [ZONE B] Dynamic Track List Workspace */} -
+ {/* [TCP] Dynamic Track Controls */} +
{tracks.map((track, idx) => { const isSelected = selectedTrackId === track.id; return ( -
- {/* Left Column: TCP */} -
setSelectedTrackId(track.id)} - className={`w-[300px] sticky left-0 z-30 p-2.5 flex flex-col justify-between bg-[#1e1e1e] border-r border-zinc-900 shrink-0 cursor-pointer border-l-4 overflow-hidden ${isSelected ? 'border-cyan-500 bg-[#252525]' : 'border-transparent hover:bg-zinc-800/20'}`} - > -
-
- {(idx+1).toString().padStart(2, '0')} -
- - {track.name} - -
- -
- - -
+
setSelectedTrackId(track.id)} + > +
+
+ {(idx+1).toString().padStart(2, '0')} +
+ + {track.name} +
-
e.stopPropagation()}> - Mô phỏng: +
+ onClick={(e) => { e.stopPropagation(); toggleTrackMute(track.id); }} + className={`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition ${ + track.muted + ? 'bg-red-950 text-red-400 border-red-700' + : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200' + }`} + >M -
- -
e.stopPropagation()}> -
- updateTrackVolume(track.id, v)} /> - Gain: {Math.round(track.volume * 100)}% -
- -
- loadFileOnTrack(track.id, e.target.files[0])} - /> - -
+ onClick={(e) => { e.stopPropagation(); toggleTrackSoloEvaluate(track.id); }} + className={`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition ${ + soloedTrackId === track.id || track.solo + ? 'bg-amber-950 text-amber-400 border-amber-600' + : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200' + }`} + title="Solo nghe thử" + >S +
- {/* Right Column: Waveform Lane */} +
e.stopPropagation()}> + Mô phỏng: + + +
+ +
e.stopPropagation()}> +
+ updateTrackVolume(track.id, v)} /> + Gain: {Math.round(track.volume * 100)}% +
+ +
+ loadFileOnTrack(track.id, e.target.files[0])} + /> + +
+
+ + {/* Resize handle at bottom of TCP */}
handleTrackResizeMouseDown(e, track.id)} + className="absolute bottom-0 left-0 right-0 h-1 cursor-ns-resize z-30 hover:bg-cyan-500/50 transition-colors" + onClick={e => e.stopPropagation()} + /> +
+ ); + })} +
+ + {/* [TCP] Bottom Drop Zone spacer */} +
+
+ + {/* ══ RIGHT COLUMN: TIMELINE SCROLL VIEWPORT ══ */} +
+
+ + {/* [ZONE A] Time Ruler — height 40px (h-10) */} +
+
+ {Array.from({ length: Math.ceil(maxDuration) }).map((_, i) => { + const sec = i; + const x = sec * zoom; + return ( +
+ {formatTime(sec)} +
+ ); + })} +
+
+ + {/* [ZONE A] Tempo Lane — height 40px (h-[40px]) */} +
+
+ +
+
+ + {/* [ZONE B] Dynamic Waveform Lanes */} +
+ {tracks.map((track, idx) => { + const isSelected = selectedTrackId === track.id; + return ( +
e.preventDefault()} onDrop={(e) => { e.preventDefault(); @@ -3041,8 +3553,12 @@ onTrackLaneMouseDown={handleTrackLaneMouseDown} onContextMenu={handleContextMenu} onClipDragStart={handleClipDragStart} + onClipStretchStart={handleClipStretchStart} + onSelectionEdgeDragStart={handleSelectionEdgeDragStart} + setSelectedClipId={setSelectedClipId} activeTool={activeTool} onSplitTrackAtTime={handleSplitTrackAtTime} + onEditClipInSubTab={handleEditClipInSubTab} snapValue={snapValue} bpm={bpm} selectionMode={selectionMode} @@ -3059,16 +3575,20 @@
)} + + {/* Resize handle at bottom of Waveform Lane */} +
handleTrackResizeMouseDown(e, track.id)} + className="absolute bottom-0 left-0 right-0 h-1 cursor-ns-resize z-30 hover:bg-cyan-500/50 transition-colors" + onClick={e => e.stopPropagation()} + />
-
- ); - })} + ); + })} - {/* Bottom Drop Zone to create new track */} -
-
+ {/* Bottom Drop Zone to create new track */}
{ if (draggedClipRef.current) { const newId = addNewTrack(); @@ -3081,32 +3601,32 @@ Kéo clip xuống đây hoặc Click để tạo Track mới
-
- {/* Selection Overlay - only for Global mode (LOOP_EDITOR.md §1.2: local draws on canvas per-track) */} - {selectionMode !== 'local' && selLeft !== null && selRight !== null && selRight > selLeft && ( -
-
handleHandleDragStart(e, 'left')} - title="Kéo giãn mốc bắt đầu"> -
-
-
handleHandleDragStart(e, 'right')} - title="Kéo giãn mốc kết thúc"> -
+ {/* Selection Overlay */} + {selectionMode !== 'local' && selLeft !== null && selRight !== null && selRight > selLeft && ( +
+
handleHandleDragStart(e, 'left')} + title="Kéo giãn mốc bắt đầu"> +
+
+
handleHandleDragStart(e, 'right')} + title="Kéo giãn mốc kết thúc"> +
+
+ )} + + {/* Playhead */} +
+
- )} - - {/* Playhead */} -
-
@@ -3373,44 +3893,44 @@ {/* ── Context Menu ── */} {contextMenu && ( -
e.stopPropagation()}>
)}