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;i