fix: sửa lỗi zoom in full màn hình

This commit is contained in:
2026-07-18 20:49:50 +07:00
parent 2dbd5828ae
commit d14a11342a
3 changed files with 970 additions and 205 deletions
+165
View File
@@ -0,0 +1,165 @@
# Technical Specification: Grid Snapping System (Grid Snapping Specification)
This document defines the graphical user interface design and coordinate/signal processing algorithms required to build a synchronized grid snapping feature across both the Web Frontend and the Dockerized Python Desktop Backend.
---
## 1. Toolbar UI Design Upgrade
The toolbar layout has been expanded to double its physical vertical height. This increase in interactive space allows for larger navigation buttons and the integration of a dedicated Snap controller.
```text
+───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────+
| [Pro Toolbar - Height: 64px] |
| +──────────+ +──────────+ +──────────+ +──────────+ +───────────────────────────────────────────────────────────────+ |
| | Cut | | Copy | | Paste | | Snap: | | Transport Monitor | |
| | [Ctrl+X]| | [Ctrl+C]| | [Ctrl+V]| | [1/4 ▼] | | [Tempo: 120 BPM] [Time Signature: 4/4] [Bar:Beat 1.3.00] | |
| +──────────+ +──────────+ +──────────+ +──────────+ +───────────────────────────────────────────────────────────────+ |
+───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────+
```
### 1.1. Snap Dropdown Configuration
* **Placement:** Located immediately following the *Paste* button on the primary toolbar row.
* **UI Syntax:** `Snap: <Dropdown_Widget>`
* **Dropdown Option Matrix:**
* `free`: Disables snapping; allows unrestricted pixel-by-pixel dragging.
* `1`: Snaps to the beginning of each complete measure (Whole Bar / 1/1).
* `1/2`: Divides the bar into 2 subdivisions (Half Note).
* `1/4`: Divides the bar into 4 subdivisions (Quarter Note / 1 Beat).
* `1/8`: Divides the bar into 8 subdivisions (Eighth Note).
* `1/16`: Divides the bar into 16 subdivisions (Sixteenth Note).
* `1/32`: Divides the bar into 32 subdivisions (Thirty-second Note).
---
## 2. DSP Grid Math: Grid Subdivision & Time Interval Calculations
To calculate the absolute temporal duration between individual grid lanes, the time metrics must be derived dynamically from the project's master tempo.
Let:
* $B$ be the project tempo (Beats Per Minute, e.g., $120\text{ BPM}$).
* $T_{\text{beat}}$ be the duration of a single beat (seconds).
* $T_{\text{bar}}$ be the duration of a full measure/bar (seconds)—assuming a standard $4/4$ time signature (4 beats per bar).
The foundational constants are established as follows:
$$T_{\text{beat}} = \frac{60}{B} \quad (\text{seconds})$$
$$T_{\text{bar}} = 4 \times T_{\text{beat}} = \frac{240}{B} \quad (\text{seconds})$$
*Example:* At a tempo of $120\text{ BPM}$, a single $1\text{ Bar}$ measure spans exactly $2.0\text{ seconds}$.
### 2.1. Determining Grid Time Interval Modifiers ($\Delta t$)
Based on the user's active choice inside the snap dropdown selection ($S \in \{\text{free}, 1, 1/2, 1/4, 1/8, 1/16, 1/32\}$), the exact grid time step $\Delta t$ (seconds) is mapped as follows:
$$\Delta t = \begin{cases} 0 & S = \text{free} \\ T_{\text{bar}} & S = 1 \\ \frac{T_{\text{bar}}}{2} & S = 1/2 \\ \frac{T_{\text{bar}}}{4} = T_{\text{beat}} & S = 1/4 \\ \frac{T_{\text{bar}}}{8} & S = 1/8 \\ \frac{T_{\text{bar}}}{16} & S = 1/16 \\ \frac{T_{\text{bar}}}{32} & S = 1/32 \end{cases}$$
---
## 3. Snapping Coordinate Calculation
When a user executes a drag-and-drop event on an audio clip, or updates a timeline marker position, the system continuously converts raw cursor values into aligned coordinates.
```text
Grid Line 1 (k * dt) Grid Line 2 ((k+1) * dt)
│ │
├───────────────○─────────────┤
│ [ Cursor dragging action ]
Raw Time (t_raw)
▼ [ Apply Snap round() function ]
────────────────┼─────────────►
Snapped Time (t_snapped)
```
### 3.1. Pixel to Snap-Time Translation Workflow
1. Intercept the actual client-side horizontal cursor position $X_{\text{raw}}$ (pixels).
2. Convert it into a raw timeline duration metric $t_{\text{raw}}$ (seconds) utilizing the current scaling zoom factor $Z$ (pixels/second):
$$t_{\text{raw}} = \frac{X_{\text{raw}}}{Z}$$
3. Apply the rounding constraint formula to lock the raw timestamp to the absolute nearest grid marker:
$$t_{\text{snapped}} = \begin{cases} t_{\text{raw}} & S = \text{free} \\ \text{round}\left( \frac{t_{\text{raw}}}{\Delta t} \right) \times \Delta t & S \neq \text{free} \end{cases}$$
4. Map the snapped timeline index $t_{\text{snapped}}$ back to the layout canvas system coordinates to paint the element at its snapped visual boundary:
$$X_{\text{snapped}} = t_{\text{snapped}} \times Z$$
---
## 4. Python Porting Blueprint
When translating this architectural logic into a desktop Python core environment using frameworks like PyQt6, the snapping evaluations are tied directly into the tracking loop inside the `mouseMoveEvent` handler.
```python
# [PYTHON PORTING BLUEPRINT] - Integrating the Snap algorithm into Python UI layer
import numpy as np
class AudioSnapEngine:
def __init__(self, bpm: float = 120.0):
self.bpm = bpm
def calculate_grid_step(self, snap_option: str) -> float:
"""
Calculates the grid's target duration step (seconds) based on Tempo and Snap selection.
"""
if snap_option == "free":
return 0.0
# 1 Bar in a standard 4/4 signature equals 240 / BPM seconds
t_bar = 240.0 / self.bpm
fraction_map = {
"1": 1.0,
"1/2": 2.0,
"1/4": 4.0,
"1/8": 8.0,
"1/16": 16.0,
"1/32": 32.0
}
division = fraction_map.get(snap_option, 4.0)
return float(t_bar / division)
def snap_time(self, raw_time_seconds: float, snap_option: str) -> float:
"""
Hard-clamps a raw timestamp to the nearest grid milestone. Prevents negative index overflows.
"""
dt = self.calculate_grid_step(snap_option)
if dt == 0.0:
return max(0.0, raw_time_seconds)
# Find the nearest integer index k of the target grid lane: raw_time / dt
k = round(raw_time_seconds / dt)
snapped_time = k * dt
return max(0.0, snapped_time)
```
---
## 5. Visual Grid Alignment
To maintain an intuitive environment for multi-channel editing, whenever a snapping constraint value other than `free` is engaged:
* The rendering engine overlays thin, low-contrast vertical grid lines (`rgba(255, 255, 255, 0.05)`) over the background profile of every active Waveform Lane.
* These marker lines are projected onto every timeline axis point that satisfies a whole multiple increment of $\Delta t$.
* Displaying these alignment indicators ensures that users can visually anticipate bounding snapping positions before releasing their mouse track buttons.
+191
View File
@@ -0,0 +1,191 @@
# Technical Specification: Multi-Channel Layout Synchronization & Scroll Management (Unified DAW Layout & Sync Scroll)
This document analyzes and defines the structural hierarchy of the graphical user interface based on the real-world interface analysis. This specification serves to guide Frontend interface programming and porting to a Python Desktop application running inside a Docker container.
---
## 1. Structural Wireframe
Based on the visual analysis, the layout composition is split into vertically static and dynamic zones:
```text
+───────────────────────────────────────────────────────────────────────────────+
| [ZONE A - STATIC] HEADER ZONE (Sticky - Permanently fixed when scrolling down)|
| +────────────────────+──────────────────────────────────────────────────────+ |
| | Channels & Tools | Time Ruler Scale | |
| |--------------------|------------------------------------------------------| |
| | Tempo Track Header | Tempo Grid Lane (120 BPM) | |
| +────────────────────+──────────────────────────────────────────────────────+ |
+───────────────────────────────────────────────────────────────────────────────+
| [ZONE B - DYNAMIC] TRACKS SCROLL WORKSPACE (Synchronized vertical scroll) |
| +────────────────────+──────────────────────────────────────────────────────+ |
| | TCP - Track 01 | Waveform Lane - Track 01 | |
| | TCP - Track 02 | Waveform Lane - Track 02 | |
| | TCP - Track 03 | Waveform Lane - Track 03 | |
| | ... | ... | |
| +────────────────────+──────────────────────────────────────────────────────+ |
+───────────────────────────────────────────────────────────────────────────────+ ▲
│ [Vertical Scrollbar]
│ (Single unified scroll)
```
---
## 2. Layout Specifications
### 2.1. Fixed Header Zone (Green Border Area - Sticky Header)
* **Visual Scope:** Encompasses the toolbar, the time ruler scale, and the Tempo Track Lane (indicated by the green bounding border in `image_fbbd4e.png`).
* **Graphical Sticky Behavior:**
* When a user adds dozens of tracks and scrolls downward, this entire zone must remain anchored to the top of the screen and is not permitted to slide out of view.
* This ensures that users can continuously track the Ruler Seconds and the master project tempo (Tempo BPM) while editing tracks located deeper down the timeline.
### 2.2. Absolute Horizontal Row Alignment (Red Border Area - Row Alignment)
* **Interaction Scope:** The exact matching pair consisting of the left Track Control Panel (TCP) and the right Waveform Lane of the same track (e.g., Track 4 inside the red border of `image_fbbd4e.png`).
* **Row Alignment Rules:**
* The corresponding TCP and Waveform Lane must have identical heights ($H = 96\text{ px}$).
* These two elements must be wrapped within a single parent row container (`Flex Row` or `Grid Row`) to guarantee that during vertical scrolling, both move simultaneously along the exact same vertical axis coordinate ($Y$).
* Row misalignment must be strictly avoided (e.g., situations where the Track 4 TCP sits higher or lower than the Track 4 Waveform lane).
### 2.3. Single Vertical Scrollbar Mandate
* **Issue to Avoid:** Separating the TCP into an independent scrollable column and the Timeline into another independent scrollable column. Doing so leads to scroll-position desynchronization errors when a user drags the scrollbar.
* **Design Standard:**
* Only a single unified Vertical Scrollbar is permitted to appear on the absolute far right of the application window (as directed by the two red arrows in `image_fbbd4e.png`).
* This vertical scrollbar moves the entire dynamic wrapper (**Tracks Scroll Workspace**), scrolling both TCPs and Waveform Lanes up or down in sync.
---
## 3. Implementation Guide
### 3.1. Web Frontend Integration (HTML / Tailwind CSS)
To group everything into one scrollbar while keeping the Tempo Track anchored at the top, use `position: sticky` and wrap the dynamic track list inside a single container:
```html
<!-- Main Container (Entire Editor Wrapper) -->
<div class="flex flex-col h-full overflow-hidden">
<!-- [ZONE A] Top Anchored Sticky Header Zone -->
<div class="sticky top-0 z-40 bg-[#242424] border-b border-[#141414] shrink-0">
<!-- Toolbar & Time Ruler -->
<div class="h-8 flex">
<div class="w-[300px] border-r border-zinc-900 px-4 flex items-center">CHANNELS</div>
<div class="flex-1 relative h-full">...Ruler Numbers...</div>
</div>
<!-- Tempo Track (Green Border Area) -->
<div class="h-[44px] flex border-t border-zinc-800 bg-[#212121]">
<div class="w-[300px] border-r border-zinc-900 px-4 flex items-center justify-between">
<span class="font-bold text-zinc-400">Tempo Track</span>
<span class="bg-zinc-800 px-1.5 py-0.5 rounded text-[10px]">120 BPM</span>
</div>
<div class="flex-1">...Tempo Grid Lines...</div>
</div>
</div>
<!-- [ZONE B] Dynamic Track Workspace (Single global vertical scrollbar on the far right) -->
<div class="flex-1 overflow-y-auto bg-[#1a1a1a]">
<div class="flex flex-col divide-y divide-[#141414]">
<!-- Track Row Container (Absolute Horizontal Row Alignment) -->
<div class="h-[96px] flex hover:bg-zinc-800/20 transition-colors">
<!-- Left: TCP -->
<div class="w-[300px] border-r border-zinc-900 p-2.5 flex-shrink-0">
...Controls (Mute, Solo, Volume, File Name)...
</div>
<!-- Right: Waveform Lane -->
<div class="flex-1 relative overflow-hidden">
...Waveform Canvas...
</div>
</div>
<!-- Add more track rows repeating the structure above... -->
</div>
</div>
</div>
```
### 3.2. Desktop App Integration (Python PyQt6)
When engineering this user interface using the Qt framework in Python, utilize a `QScrollArea` to encapsulate a `QWidget` managed by a layout of rows to control the single scrollbar behavior:
```python
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QLabel
from PyQt6.QtCore import Qt
class MasterDAWWidget(QWidget):
def __init__(self):
super().__init__()
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(0, 0, 0, 0)
self.main_layout.setSpacing(0)
# 1. Initialize Fixed Header (Toolbar, Ruler, Tempo)
self.header_widget = QWidget()
self.header_widget.setFixedHeight(76) # 32px Ruler + 44px Tempo
self.setup_header_ui()
self.main_layout.addWidget(self.header_widget)
# 2. Initialize Scroll Area for dynamic track rows
self.scroll_area = QScrollArea()
self.scroll_area.setWidgetResizable(True)
# Force a single vertical scrollbar on the far right
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
# Widget container hosting the track list inside the Scroll Area
self.tracks_container = QWidget()
self.tracks_layout = QVBoxLayout(self.tracks_container)
self.tracks_layout.setContentsMargins(0, 0, 0, 0)
self.tracks_layout.setSpacing(0)
self.tracks_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.scroll_area.setWidget(self.tracks_container)
self.main_layout.addWidget(self.scroll_area)
def add_track_row(self, track_id, track_name):
"""
Appends a new track row. Uses QHBoxLayout to lock the TCP and Waveform Lane
into absolute horizontal sync within the row.
"""
row_widget = QWidget()
row_widget.setFixedHeight(96) # Rigid constraint for the entire row
row_layout = QHBoxLayout(row_widget)
row_layout.setContentsMargins(0, 0, 0, 0)
row_layout.setSpacing(0)
# Left: Track Control Panel (TCP)
tcp_widget = QWidget()
tcp_widget.setFixedWidth(300)
# Setup TCP UI components...
row_layout.addWidget(tcp_widget)
# Right: Waveform Lane
waveform_widget = QWidget()
# Setup Waveform Canvas Painter...
row_layout.addWidget(waveform_widget)
self.tracks_layout.addWidget(row_widget)
```
---
## 4. Layout Architecture Advantages
* **Fluid User Experience:** Eliminates row-stuttering or scrolling layout shifts between the control panels and audio visuals when a user scrolls through long track stacks rapidly.
* **Flawless Python Porting Compatibility:** By wrapping the TCP and the Waveform Canvas inside a common row (`QHBoxLayout` in Qt or `Flex Row` in Web), the core widget tree hierarchy remains incredibly lean. This design removes the need to write custom coordinate bridging code to bind two separate scroll engines together.
* **Clean Interface Aesthetics:** Safely protects the pixel rendering mapping ratios of the fixed time grids at the top, precisely matching the professional DAW interface conventions observed
+556 -147
View File
@@ -175,6 +175,10 @@
onTrackLaneMouseDown, onTrackLaneMouseDown,
onContextMenu, onContextMenu,
onClipDragStart, onClipDragStart,
activeTool,
onSplitTrackAtTime,
snapValue,
bpm,
}) => { }) => {
const canvasRef = useRef(null); const canvasRef = useRef(null);
@@ -193,13 +197,34 @@
ctx.fillStyle = isSelected ? '#2a2a2a' : (track.id % 2 === 0 ? '#181818' : '#1d1d1d'); ctx.fillStyle = isSelected ? '#2a2a2a' : (track.id % 2 === 0 ? '#181818' : '#1d1d1d');
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
// Grid lines // Grid lines based on Snap value
ctx.strokeStyle = 'rgba(255, 255, 255, 0.02)'; ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)';
ctx.lineWidth = 1; ctx.lineWidth = 1;
const stepSeconds = zoom > 120 ? 0.5 : 1;
const totalSec = width / zoom; const totalSec = width / zoom;
for (let s = 0; s <= totalSec; s += stepSeconds) { let gridSpacing = 1.0; // default 1 second
if (snapValue && snapValue !== 'free') {
const beatDuration = 60 / parseFloat(bpm || 120);
let divisor = 1;
if (snapValue === '1') divisor = 1;
else if (snapValue === '1/2') divisor = 0.5;
else if (snapValue === '1/4') divisor = 0.25;
else if (snapValue === '1/8') divisor = 0.125;
else if (snapValue === '1/16') divisor = 0.0625;
else if (snapValue === '1/32') divisor = 0.03125;
gridSpacing = beatDuration * divisor;
} else {
gridSpacing = 60 / parseFloat(bpm || 120); // default to 1 beat
}
// Guard: if lines are too close, scale grid spacing by multiples of 2
let drawSpacing = gridSpacing;
while (drawSpacing * zoom < 10) {
drawSpacing *= 2;
}
for (let s = 0; s <= totalSec; s += drawSpacing) {
const x = s * zoom; const x = s * zoom;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(x, 0); ctx.moveTo(x, 0);
@@ -208,16 +233,46 @@
} }
// Draw waveform lane // Draw waveform lane
if (track.buffer) { const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
const data = track.buffer.getChannelData(0); id: 'default',
const sampleRate = track.buffer.sampleRate; buffer: track.buffer,
startTime: track.startTime || 0,
name: track.name
}] : []);
if (clips.length > 0) {
clips.forEach(clip => {
const data = clip.buffer.getChannelData(0);
const sampleRate = clip.buffer.sampleRate;
const totalSamples = data.length; const totalSamples = data.length;
const duration = totalSamples / sampleRate; const duration = totalSamples / sampleRate;
const xStart = (track.startTime || 0) * zoom; const xStart = (clip.startTime || 0) * zoom;
const wClip = duration * zoom; const wClip = duration * zoom;
const xEnd = xStart + wClip; 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;
const clipTop = 8;
const clipHeight = height - 16;
ctx.beginPath();
if (ctx.roundRect) {
ctx.roundRect(xStart, clipTop, wClip, clipHeight, 4);
} else {
ctx.rect(xStart, clipTop, wClip, clipHeight);
}
ctx.fill();
ctx.stroke();
// 2. Draw Clip Label
ctx.fillStyle = '#e4e4e7';
ctx.font = 'bold 9px sans-serif';
ctx.fillText(clip.name || 'Clip', xStart + 8, clipTop + 14);
// Draw markers // Draw markers
if (markers && markers.length > 0) { if (markers && markers.length > 0) {
markers.forEach(m => { markers.forEach(m => {
@@ -229,8 +284,8 @@
}); });
} }
// Peak waveform drawing only within clip bounds // 3. Peak waveform drawing only within clip bounds
ctx.strokeStyle = isSelected ? '#06b6d4' : '#6ee7b7'; ctx.strokeStyle = isSelected ? '#22d3ee' : '#a7f3d0';
ctx.lineWidth = 1; ctx.lineWidth = 1;
const drawXStart = Math.max(0, Math.floor(xStart)); const drawXStart = Math.max(0, Math.floor(xStart));
@@ -251,13 +306,14 @@
} }
const mid = height / 2; const mid = height / 2;
const peakHeight = maxVal * (height * 0.4); const peakHeight = maxVal * (clipHeight * 0.45);
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(px, mid - peakHeight); ctx.moveTo(px, mid - peakHeight);
ctx.lineTo(px, mid + peakHeight); ctx.lineTo(px, mid + peakHeight);
ctx.stroke(); ctx.stroke();
} }
});
} else { } else {
ctx.fillStyle = '#444'; ctx.fillStyle = '#444';
ctx.font = '12px Inter, sans-serif'; ctx.font = '12px Inter, sans-serif';
@@ -277,12 +333,39 @@
ctx.strokeRect(hlLeft, 0, hlWidth, height); ctx.strokeRect(hlLeft, 0, hlWidth, height);
} }
}, [track, zoom, timelineWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight]); }, [track, zoom, timelineWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight, snapValue, bpm]);
return ( return (
<canvas <canvas
ref={canvasRef} ref={canvasRef}
className="w-full h-full cursor-crosshair" className="w-full h-full cursor-crosshair"
onMouseMove={(e) => {
if (!canvasRef.current) return;
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 = 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
}] : []);
const hoveredClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration);
const isOverClip = !!hoveredClip;
if (activeTool === 'grab') {
canvasRef.current.style.cursor = isOverClip ? 'grab' : 'default';
} else if (activeTool === 'razor') {
canvasRef.current.style.cursor = isOverClip ? 'cell' : 'not-allowed';
} else {
// select tool
canvasRef.current.style.cursor = (isOverClip && e.altKey) ? 'grab' : 'crosshair';
}
}}
onMouseDown={(e) => { onMouseDown={(e) => {
// Ignore right-click for local selection drag (context menu handles it) // Ignore right-click for local selection drag (context menu handles it)
if (e.button === 2) return; if (e.button === 2) return;
@@ -293,13 +376,45 @@
const time = Math.max(0, x / zoom); const time = Math.max(0, x / zoom);
onSelectTrack(track.id); onSelectTrack(track.id);
// Check for Alt+Click drag clip const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
const isOverClip = track.buffer && time >= (track.startTime || 0) && time < (track.startTime || 0) + track.buffer.duration; id: 'default',
if (isOverClip && e.altKey) { buffer: track.buffer,
startTime: track.startTime || 0,
name: track.name
}] : []);
const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration);
if (activeTool === 'razor') {
if (clickedClip) {
e.preventDefault();
e.stopPropagation();
if (onSplitTrackAtTime) {
onSplitTrackAtTime(track.id, clickedClip.id, time);
}
}
return;
}
if (activeTool === 'grab') {
if (clickedClip) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (onClipDragStart) { if (onClipDragStart) {
onClipDragStart(track.id, time - (track.startTime || 0)); onClipDragStart(track.id, clickedClip.id, time - clickedClip.startTime);
}
} else {
onPlayheadSet(time);
}
return;
}
// Check for click drag clip (no Alt key required if select tool is in fallback alt-mode)
if (clickedClip && e.altKey) {
e.preventDefault();
e.stopPropagation();
if (onClipDragStart) {
onClipDragStart(track.id, clickedClip.id, time - clickedClip.startTime);
} }
return; return;
} }
@@ -325,7 +440,7 @@
); );
}; };
const TempoTrackLane = ({ bpm, zoom, timelineWidth, onPlayheadSet }) => { const TempoTrackLane = ({ bpm, zoom, timelineWidth, onPlayheadSet, snapValue }) => {
const canvasRef = useRef(null); const canvasRef = useRef(null);
useEffect(() => { useEffect(() => {
@@ -375,12 +490,39 @@
} }
} }
// Draw snap sub-ticks at the bottom
if (snapValue && snapValue !== 'free') {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
ctx.lineWidth = 0.8;
let divisor = 1;
if (snapValue === '1') divisor = 1;
else if (snapValue === '1/2') divisor = 0.5;
else if (snapValue === '1/4') divisor = 0.25;
else if (snapValue === '1/8') divisor = 0.125;
else if (snapValue === '1/16') divisor = 0.0625;
else if (snapValue === '1/32') divisor = 0.03125;
const snapInterval = beatDuration * divisor;
if (snapInterval * zoom >= 4) {
for (let t = 0; t <= totalSec; t += snapInterval) {
const onBeat = Math.abs((t / beatDuration) - Math.round(t / beatDuration)) < 0.001;
if (!onBeat) {
const x = t * zoom;
ctx.beginPath();
ctx.moveTo(x, height - 6);
ctx.lineTo(x, height);
ctx.stroke();
}
}
}
}
ctx.fillStyle = 'rgba(255, 255, 255, 0.35)'; ctx.fillStyle = 'rgba(255, 255, 255, 0.35)';
ctx.font = 'bold 10px Inter, sans-serif'; ctx.font = 'bold 10px Inter, sans-serif';
ctx.textAlign = 'right'; ctx.textAlign = 'right';
ctx.fillText(`${bpm} BPM`, width - 6, 12); ctx.fillText(`${bpm} BPM`, width - 6, 12);
}, [bpm, zoom, timelineWidth]); }, [bpm, zoom, timelineWidth, snapValue]);
return ( return (
<canvas <canvas
@@ -448,10 +590,40 @@
{ id: '1', name: 'Track 01', buffer: null, startTime: 0, volume: 0.8, muted: false, solo: false, color: '#0f766e', markers: [], serverFileId: null }, { 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 }, { id: '2', name: 'Track 02', buffer: null, startTime: 0, 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 } const [draggedClip, setDraggedClip] = useState(null); // { trackId, clickOffset, buffer, name, volume, color }
const [hoveredTrackId, setHoveredTrackId] = useState(null); const [hoveredTrackId, setHoveredTrackId] = useState(null);
const [activeTool, setActiveTool] = useState('select'); // 'select' | 'grab' | 'razor'
const [snapValue, setSnapValue] = useState('free'); // 'free', '1', '1/2', '1/4', '1/8', '1/16', '1/32'
const snapTime = (time, snapVal, bpmVal) => {
if (snapVal === 'free') return time;
const beatDuration = 60 / parseFloat(bpmVal || 120);
let divisor = 1;
if (snapVal === '1') divisor = 1;
else if (snapVal === '1/2') divisor = 0.5;
else if (snapVal === '1/4') divisor = 0.25;
else if (snapVal === '1/8') divisor = 0.125;
else if (snapVal === '1/16') divisor = 0.0625;
else if (snapVal === '1/32') divisor = 0.03125;
const gridSpacing = beatDuration * divisor;
return Math.round(time / gridSpacing) * gridSpacing;
};
const snapValueRef = useRef(snapValue);
snapValueRef.current = snapValue;
const bpmRef = useRef(bpm);
bpmRef.current = bpm;
useEffect(() => {
setTimeout(() => {
if (window.lucide) {
window.lucide.createIcons();
}
}, 50);
}, [activeTool]);
// BMP for Tempo Track - LOOP_EDITOR_2.md §6 // BMP for Tempo Track - LOOP_EDITOR_2.md §6
const [bpm, setBpm] = useState(localStorage.getItem('studio_bpm') || '120');
const [selectedTrackId, setSelectedTrackId] = useState('1'); const [selectedTrackId, setSelectedTrackId] = useState('1');
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
@@ -546,6 +718,12 @@
// buffer is captured via reference copy for undo; we store a clone for redo // buffer is captured via reference copy for undo; we store a clone for redo
buffer: track.buffer, buffer: track.buffer,
startTime: track.startTime || 0, startTime: track.startTime || 0,
clips: track.clips ? track.clips.map(c => ({
id: c.id,
buffer: c.buffer,
startTime: c.startTime,
name: c.name
})) : null
}; };
}; };
@@ -892,81 +1070,51 @@
const ctx = getAudioContext(); const ctx = getAudioContext();
const targetTrack = tracks.find(t => t.id === targetTrackId); const targetTrack = tracks.find(t => t.id === targetTrackId);
if (targetTrack && targetTrack.buffer) { const newClip = {
const targetSr = targetTrack.buffer.sampleRate; id: 'clip_' + Date.now() + '_' + Math.floor(Math.random() * 1000),
const clipSr = clipBuffer.sampleRate; startTime: pasteTime,
// Resample clipboard to target sample rate if needed buffer: clipBuffer,
let pasteData; name: name || 'Pasted Clip'
let pasteSamples; };
if (clipSr === targetSr) {
const newClipBuffer = ctx.createBuffer(1, clipBuffer.length, clipSr);
newClipBuffer.copyToChannel(clipBuffer.getChannelData(0), 0);
pasteData = newClipBuffer.getChannelData(0);
pasteSamples = clipBuffer.length;
} else {
pasteSamples = Math.round(clipBuffer.length * targetSr / clipSr);
const resampled = ctx.createBuffer(1, pasteSamples, targetSr);
const out = resampled.getChannelData(0);
const src = clipBuffer.getChannelData(0);
const srcLen = clipBuffer.length;
for (let i = 0; i < pasteSamples; i++) {
const pos = (i / pasteSamples) * srcLen;
const idx = Math.floor(pos);
const frac = pos - idx;
const s0 = src[Math.min(idx, srcLen - 1)];
const s1 = src[Math.min(idx + 1, srcLen - 1)];
out[i] = s0 + (s1 - s0) * frac;
}
pasteData = out;
}
const targetStart = targetTrack.startTime || 0;
const relativePasteTime = pasteTime - targetStart;
let newStartTime = targetStart;
let combined;
const origLen = targetTrack.buffer.length;
const origData = targetTrack.buffer.getChannelData(0);
if (relativePasteTime >= 0) {
const insertSample = Math.floor(relativePasteTime * targetSr);
const newLen = Math.max(origLen, insertSample + pasteSamples);
combined = ctx.createBuffer(1, newLen, targetSr);
const combinedData = combined.getChannelData(0);
for (let i = 0; i < origLen; i++) combinedData[i] = origData[i];
for (let i = 0; i < pasteSamples; i++) combinedData[insertSample + i] = pasteData[i];
} else {
newStartTime = pasteTime;
const prependSamples = Math.floor((targetStart - pasteTime) * targetSr);
const newLen = Math.max(prependSamples + origLen, pasteSamples);
combined = ctx.createBuffer(1, newLen, targetSr);
const combinedData = combined.getChannelData(0);
for (let i = 0; i < origLen; i++) combinedData[prependSamples + i] = origData[i];
for (let i = 0; i < pasteSamples; i++) combinedData[i] = pasteData[i];
}
setTracks(p => p.map(t => t.id === targetTrackId ? { ...t, buffer: combined, startTime: newStartTime } : t));
setCurrentTime(pasteTime);
showToast('Đã dán vào track.', 'success');
return targetTrackId;
}
if (targetTrack) { if (targetTrack) {
// Track exists but no buffer — use clipboard sample rate setTracks(p => p.map(t => {
const newClipBuffer = ctx.createBuffer(1, clipBuffer.length, clipBuffer.sampleRate); if (t.id === targetTrackId) {
newClipBuffer.copyToChannel(clipBuffer.getChannelData(0), 0); const existingClips = t.clips && t.clips.length > 0 ? t.clips : (t.buffer ? [{
setTracks(p => p.map(t => t.id === targetTrackId ? { ...t, buffer: newClipBuffer, startTime: pasteTime, name: `Pasted_${name || t.name}`, volume: volume || t.volume, color: color || t.color } : t)); id: 'default_' + t.id,
buffer: t.buffer,
startTime: t.startTime || 0,
name: t.name
}] : []);
const updatedClips = [...existingClips, newClip];
return {
...t,
clips: updatedClips,
buffer: updatedClips[0].buffer,
startTime: updatedClips[0].startTime,
name: name || t.name,
volume: volume || t.volume,
color: color || t.color
};
}
return t;
}));
setCurrentTime(pasteTime); setCurrentTime(pasteTime);
showToast('Đã dán vào track.', 'success'); showToast('Đã dán clip vào track.', 'success');
return targetTrackId; return targetTrackId;
} }
// No matching track — create a new one // No matching track — create a new one
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
const newId = 'track_pasted_' + Date.now(); const newId = 'track_pasted_' + Date.now();
const newClipBuffer = ctx.createBuffer(1, clipBuffer.length, clipBuffer.sampleRate);
newClipBuffer.copyToChannel(clipBuffer.getChannelData(0), 0);
setTracks(prev => [...prev, { setTracks(prev => [...prev, {
id: newId, name: `Pasted_${name || 'track'}`, buffer: newClipBuffer, id: newId,
name: `Pasted_${name || 'track'}`,
buffer: clipBuffer,
startTime: pasteTime, startTime: pasteTime,
clips: [newClip],
volume: volume || 0.8, muted: false, solo: false, volume: volume || 0.8, muted: false, solo: false,
color: color || colors[prev.length % colors.length], color: color || colors[prev.length % colors.length],
markers: [], serverFileId: null, markers: [], serverFileId: null,
@@ -1157,7 +1305,7 @@
maxDurationRef.current = maxDuration; maxDurationRef.current = maxDuration;
const minZoom = useMemo(() => { const minZoom = useMemo(() => {
return Math.max(20, viewportWidth / maxDuration); return viewportWidth / maxDuration;
}, [viewportWidth, maxDuration]); }, [viewportWidth, maxDuration]);
const timelineWidth = useMemo(() => { const timelineWidth = useMemo(() => {
@@ -1339,16 +1487,24 @@
const hasSolo = tracks.some(t => t.solo) || soloedTrackId !== null; const hasSolo = tracks.some(t => t.solo) || soloedTrackId !== null;
tracks.forEach(track => { tracks.forEach(track => {
if (!track.buffer) return;
const isPlayable = hasSolo const isPlayable = hasSolo
? (track.id === soloedTrackId || track.solo) ? (track.id === soloedTrackId || track.solo)
: !track.muted; : !track.muted;
if (!isPlayable) return; if (!isPlayable) return;
const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
id: 'default',
buffer: track.buffer,
startTime: track.startTime || 0,
name: track.name
}] : []);
clips.forEach(clip => {
if (!clip.buffer) return;
const source = context.createBufferSource(); const source = context.createBufferSource();
source.buffer = track.buffer; source.buffer = clip.buffer;
const gainNode = context.createGain(); const gainNode = context.createGain();
gainNode.gain.setValueAtTime(track.volume, context.currentTime); gainNode.gain.setValueAtTime(track.volume, context.currentTime);
@@ -1356,30 +1512,41 @@
source.connect(gainNode); source.connect(gainNode);
gainNode.connect(context.destination); gainNode.connect(context.destination);
const trackStart = track.startTime || 0; const clipStart = clip.startTime || 0;
const trackDuration = track.buffer.duration; const clipDuration = clip.buffer.duration;
const trackEnd = trackStart + trackDuration; const clipEnd = clipStart + clipDuration;
if (offsetTime < trackStart) { if (offsetTime < clipStart) {
const delay = trackStart - offsetTime; const delay = clipStart - offsetTime;
source.start(context.currentTime + delay, 0); source.start(context.currentTime + delay, 0);
activeSourcesRef.current.push(source); activeSourcesRef.current.push(source);
} else if (offsetTime < trackEnd) { } else if (offsetTime < clipEnd) {
const playOffset = offsetTime - trackStart; const playOffset = offsetTime - clipStart;
source.start(context.currentTime, playOffset); source.start(context.currentTime, playOffset);
activeSourcesRef.current.push(source); activeSourcesRef.current.push(source);
} }
}); });
});
}; };
// Solo playback for Local Selection Loop (LOOP_MAKER.md §2.2) // Solo playback for Local Selection Loop (LOOP_MAKER.md §2.2)
const startLocalTrackPlayback = (trackId, offsetTime) => { const startLocalTrackPlayback = (trackId, offsetTime) => {
const context = getAudioContext(); const context = getAudioContext();
const track = tracks.find(t => t.id === trackId); const track = tracks.find(t => t.id === trackId);
if (!track || !track.buffer) return; if (!track) return;
const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
id: 'default',
buffer: track.buffer,
startTime: track.startTime || 0,
name: track.name
}] : []);
clips.forEach(clip => {
if (!clip.buffer) return;
const source = context.createBufferSource(); const source = context.createBufferSource();
source.buffer = track.buffer; source.buffer = clip.buffer;
const gainNode = context.createGain(); const gainNode = context.createGain();
gainNode.gain.setValueAtTime(track.volume, context.currentTime); gainNode.gain.setValueAtTime(track.volume, context.currentTime);
@@ -1387,19 +1554,20 @@
source.connect(gainNode); source.connect(gainNode);
gainNode.connect(context.destination); gainNode.connect(context.destination);
const trackStart = track.startTime || 0; const clipStart = clip.startTime || 0;
const trackDuration = track.buffer.duration; const clipDuration = clip.buffer.duration;
const trackEnd = trackStart + trackDuration; const clipEnd = clipStart + clipDuration;
if (offsetTime < trackStart) { if (offsetTime < clipStart) {
const delay = trackStart - offsetTime; const delay = clipStart - offsetTime;
source.start(context.currentTime + delay, 0); source.start(context.currentTime + delay, 0);
activeSourcesRef.current.push(source); activeSourcesRef.current.push(source);
} else if (offsetTime < trackEnd) { } else if (offsetTime < clipEnd) {
const playOffset = offsetTime - trackStart; const playOffset = offsetTime - clipStart;
source.start(context.currentTime, playOffset); source.start(context.currentTime, playOffset);
activeSourcesRef.current.push(source); activeSourcesRef.current.push(source);
} }
});
}; };
const handlePlayPause = () => { const handlePlayPause = () => {
@@ -1534,20 +1702,31 @@
draggedClipRef.current = draggedClip; draggedClipRef.current = draggedClip;
const hoveredTrackIdRef = useRef(null); const hoveredTrackIdRef = useRef(null);
hoveredTrackIdRef.current = hoveredTrackId; hoveredTrackIdRef.current = hoveredTrackId;
const captureTrackSnapshotRef = useRef(null);
captureTrackSnapshotRef.current = captureTrackSnapshot;
const handleClipDragStart = (trackId, clickOffset) => { const handleClipDragStart = (trackId, clipId, clickOffset) => {
const track = tracks.find(t => t.id === trackId); const track = tracks.find(t => t.id === trackId);
if (!track || !track.buffer) return; if (!track) return;
const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
id: 'default',
buffer: track.buffer,
startTime: track.startTime || 0,
name: track.name
}] : []);
const clip = clips.find(c => c.id === clipId);
if (!clip) return;
const beforeSnap = captureTrackSnapshot(trackId); const beforeSnap = captureTrackSnapshot(trackId);
setDraggedClip({ setDraggedClip({
trackId: trackId, trackId: trackId,
clipId: clipId,
clickOffset: clickOffset, clickOffset: clickOffset,
buffer: track.buffer, buffer: clip.buffer,
name: track.name, name: clip.name,
volume: track.volume,
color: track.color,
beforeSnap: beforeSnap beforeSnap: beforeSnap
}); });
}; };
@@ -1564,22 +1743,50 @@
const scrollLeft = wrapper.scrollLeft; const scrollLeft = wrapper.scrollLeft;
const mouseX = e.clientX - rect.left + scrollLeft; const mouseX = e.clientX - rect.left + scrollLeft;
const time = mouseX / zoom; const time = mouseX / zoom;
const newStart = Math.max(0, time - drag.clickOffset); const rawStart = Math.max(0, time - drag.clickOffset);
const newStart = snapTime(rawStart, snapValueRef.current, bpmRef.current);
const targetTrackId = hoveredTrackIdRef.current || drag.trackId; const targetTrackId = hoveredTrackIdRef.current || drag.trackId;
setTracks(prev => prev.map(t => { setTracks(prev => prev.map(t => {
// Clear the clip from its previous track if it moved to a new track
if (t.id === drag.trackId && drag.trackId !== targetTrackId) { if (t.id === drag.trackId && drag.trackId !== targetTrackId) {
return { ...t, buffer: null, startTime: 0 }; const updatedClips = (t.clips || []).filter(c => c.id !== drag.clipId);
}
if (t.id === targetTrackId) {
return { return {
...t, ...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}`
};
}
// Update/set clip on target track
if (t.id === targetTrackId) {
const existingClips = t.clips && t.clips.length > 0 ? t.clips : (t.buffer ? [{
id: 'default_' + t.id,
buffer: t.buffer,
startTime: t.startTime || 0,
name: t.name
}] : []);
const hasClip = existingClips.some(c => c.id === drag.clipId);
let updatedClips;
if (hasClip) {
updatedClips = existingClips.map(c => c.id === drag.clipId ? { ...c, startTime: newStart } : c);
} else {
updatedClips = [...existingClips, {
id: drag.clipId,
buffer: drag.buffer, buffer: drag.buffer,
startTime: newStart, startTime: newStart,
name: drag.name, name: drag.name
volume: drag.volume, }];
color: drag.color }
return {
...t,
clips: updatedClips,
buffer: updatedClips[0].buffer,
startTime: updatedClips[0].startTime,
name: updatedClips[0].name
}; };
} }
return t; return t;
@@ -1594,7 +1801,7 @@
const drag = draggedClipRef.current; const drag = draggedClipRef.current;
if (!drag) return; if (!drag) return;
const afterSnap = captureTrackSnapshot(drag.trackId); const afterSnap = captureTrackSnapshotRef.current(drag.trackId);
pushAction('MOVE_CLIP', drag.trackId, drag.beforeSnap, afterSnap); pushAction('MOVE_CLIP', drag.trackId, drag.beforeSnap, afterSnap);
setDraggedClip(null); setDraggedClip(null);
@@ -1863,6 +2070,7 @@
}]); }]);
showToast(`Đã thêm Track ${newId}.`, 'info'); showToast(`Đã thêm Track ${newId}.`, 'info');
setTimeout(() => lucide.createIcons(), 200); setTimeout(() => lucide.createIcons(), 200);
return newId;
}; };
// ── Server-side Export ── // ── Server-side Export ──
@@ -2226,22 +2434,35 @@
}; };
// ── Split Track at Playhead ── // ── Split Track at Playhead ──
const handleSplitTrack = (trackId) => { const handleSplitTrackAtTime = (trackId, clipId, time) => {
const track = tracks.find(t => t.id === trackId); const track = tracks.find(t => t.id === trackId);
if (!track || !track.buffer) return; if (!track) return;
const trackStart = track.startTime || 0; const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
const relCurrentTime = Math.max(0, currentTime - trackStart); id: 'default',
const cutTime = findZeroCrossing(track.buffer, relCurrentTime); buffer: track.buffer,
const sr = track.buffer.sampleRate; startTime: track.startTime || 0,
const cutSample = Math.floor(cutTime * sr); name: track.name
const originalData = track.buffer.getChannelData(0); }] : []);
const targetClipId = clipId || (clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration)?.id);
if (!targetClipId) return;
const clip = clips.find(c => c.id === targetClipId);
if (!clip || !clip.buffer) return;
const relTime = Math.max(0, time - clip.startTime);
const sr = clip.buffer.sampleRate;
const cutSample = Math.floor(relTime * sr);
const originalData = clip.buffer.getChannelData(0);
if (cutSample <= 0 || cutSample >= originalData.length) { if (cutSample <= 0 || cutSample >= originalData.length) {
showToast("Vị trí Playhead nằm ngoài dải biên tập.", "warning"); showToast("Vị trí cắt nằm ngoài dải âm thanh của clip.", "warning");
return; return;
} }
const beforeSnap = captureTrackSnapshot(trackId);
const ctx = getAudioContext(); const ctx = getAudioContext();
const b1 = ctx.createBuffer(1, cutSample, sr); const b1 = ctx.createBuffer(1, cutSample, sr);
b1.copyToChannel(originalData.subarray(0, cutSample), 0); b1.copyToChannel(originalData.subarray(0, cutSample), 0);
@@ -2249,24 +2470,130 @@
const b2 = ctx.createBuffer(1, originalData.length - cutSample, sr); const b2 = ctx.createBuffer(1, originalData.length - cutSample, sr);
b2.copyToChannel(originalData.subarray(cutSample), 0); b2.copyToChannel(originalData.subarray(cutSample), 0);
setTracks(prev => { const clip1 = {
const idx = prev.findIndex(t => t.id === trackId); id: 'clip_' + Date.now() + '_p1',
const updated = [...prev]; name: `${clip.name.replace(' (Part 1)', '').replace(' (Part 2)', '')} (Part 1)`,
updated[idx] = { ...track, name: `${track.name} (Part 1)`, buffer: b1 }; buffer: b1,
startTime: clip.startTime
const newTrack = {
...track,
id: 'track_split_' + Date.now(),
name: `${track.name} (Part 2)`,
buffer: b2,
startTime: trackStart + (cutSample / sr),
markers: [],
serverFileId: null,
}; };
updated.splice(idx + 1, 0, newTrack);
return updated; const clip2 = {
id: 'clip_' + Date.now() + '_p2',
name: `${clip.name.replace(' (Part 1)', '').replace(' (Part 2)', '')} (Part 2)`,
buffer: b2,
startTime: clip.startTime + (cutSample / sr)
};
setTracks(prev => prev.map(t => {
if (t.id === trackId) {
const remainingClips = clips.filter(c => c.id !== targetClipId);
const updatedClips = [...remainingClips, clip1, clip2];
return {
...t,
clips: updatedClips,
buffer: updatedClips[0]?.buffer || null,
startTime: updatedClips[0]?.startTime || 0,
name: updatedClips[0]?.name || t.name
};
}
return t;
}));
setTimeout(() => {
const afterSnap = captureTrackSnapshot(trackId);
pushAction('SPLIT_CLIP', trackId, beforeSnap, afterSnap);
}, 50);
showToast(`Đã chia nhỏ clip tại ${formatTime(time)}.`, "info");
};
const handleSplitTrack = (trackId) => {
handleSplitTrackAtTime(trackId, null, currentTime);
};
// ── Glue (Merge) Clips on Selected Track ──
const handleGlueTracks = () => {
const track = tracks.find(t => t.id === selectedTrackId);
if (!track) {
showToast('Vui lòng chọn một track để thực hiện gộp (glue).', 'warning');
return;
}
const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
id: 'default',
buffer: track.buffer,
startTime: track.startTime || 0,
name: track.name
}] : []);
if (clips.length < 2) {
showToast('Cần ít nhất 2 clip trên track này để gộp (glue).', 'warning');
return;
}
const beforeSnap = captureTrackSnapshot(track.id);
const ctx = getAudioContext();
const sr = clips[0].buffer.sampleRate;
let minStart = Infinity;
let maxEnd = -Infinity;
clips.forEach(c => {
const start = c.startTime || 0;
const end = start + c.buffer.duration;
minStart = Math.min(minStart, start);
maxEnd = Math.max(maxEnd, end);
}); });
showToast("Đã chia nhỏ track tại vị trí Playhead.", "info");
const newDur = maxEnd - minStart;
const newBuffer = ctx.createBuffer(1, Math.ceil(newDur * sr), sr);
const newData = newBuffer.getChannelData(0);
clips.forEach(c => {
const data = c.buffer.getChannelData(0);
const offset = Math.floor(((c.startTime || 0) - minStart) * sr);
for (let i = 0; i < data.length; i++) {
if (offset + i < newData.length) {
newData[offset + i] += data[i];
}
}
});
let maxPeak = 0;
for (let i = 0; i < newData.length; i++) {
const abs = Math.abs(newData[i]);
if (abs > maxPeak) maxPeak = abs;
}
if (maxPeak > 1.0) {
for (let i = 0; i < newData.length; i++) newData[i] /= maxPeak;
}
const mergedClip = {
id: 'clip_merged_' + Date.now(),
name: `${track.name.replace(' (Part 1)', '').replace(' (Part 2)', '')} (Glued)`,
buffer: newBuffer,
startTime: minStart
};
setTracks(prev => prev.map(t => {
if (t.id === track.id) {
return {
...t,
clips: [mergedClip],
buffer: newBuffer,
startTime: minStart,
name: mergedClip.name
};
}
return t;
}));
setTimeout(() => {
const afterSnap = captureTrackSnapshot(track.id);
pushAction('GLUE', track.id, beforeSnap, afterSnap);
}, 50);
showToast(`Đã gộp ${clips.length} clips thành công.`, 'success');
}; };
// ── Save AI config to localStorage ── // ── Save AI config to localStorage ──
@@ -2442,9 +2769,71 @@
<div className="flex-1 flex overflow-y-auto select-none daw-bg relative" style={{ display: activeTab !== 'main' ? 'none' : '' }}> <div className="flex-1 flex overflow-y-auto select-none daw-bg relative" style={{ display: activeTab !== 'main' ? 'none' : '' }}>
{/* TCP Left Column */} {/* TCP Left Column */}
<div className="w-[300px] flex flex-col daw-panel border-r border-zinc-900 z-10 select-none shrink-0"> <div className="w-[300px] flex flex-col daw-panel border-r border-zinc-900 z-10 select-none shrink-0">
<div className="h-8 border-b border-zinc-900 bg-[#242424] flex items-center px-4 justify-between sticky top-0 z-30"> <div className="h-8 border-b border-zinc-900 bg-[#242424] flex items-center px-2 justify-between sticky top-0 z-30 select-none">
<span className="text-[10px] font-bold text-zinc-500 uppercase">Danh Sách Kênh</span> <span className="text-[10px] font-bold text-zinc-500 uppercase shrink-0 mr-2">Kênh</span>
<span className="text-[9px] bg-cyan-950 text-cyan-400 px-1 rounded font-bold uppercase">Chọn Click</span>
{/* Timeline Toolbar */}
<div className="flex items-center gap-0.5 bg-zinc-900 border border-zinc-800 rounded px-1 py-0.5 shadow-sm">
<button
onClick={() => { setActiveTool('select'); showToast('Công cụ chọn (Select Tool) đã kích hoạt.', 'info'); }}
className={`p-1 rounded transition text-xs flex items-center justify-center ${activeTool === 'select' ? 'bg-cyan-950/80 text-cyan-400 border border-cyan-800/50 font-bold' : 'text-zinc-400 hover:bg-zinc-800 border border-transparent'}`}
title="Select Tool: Chọn khoảng, Đặt playhead (Giữ Alt kéo để di chuyển nhanh clip)"
>
<i data-lucide="mouse-pointer" className="w-3 h-3"></i>
</button>
<button
onClick={() => { setActiveTool('grab'); showToast('Công cụ di chuyển (Hand Tool) đã kích hoạt.', 'info'); }}
className={`p-1 rounded transition text-xs flex items-center justify-center ${activeTool === 'grab' ? 'bg-cyan-950/80 text-cyan-400 border border-cyan-700/50 font-bold' : 'text-zinc-400 hover:bg-zinc-800 border border-transparent'}`}
title="Grab Tool: Click kéo trực tiếp để di chuyển clip"
>
<i data-lucide="hand" className="w-3 h-3"></i>
</button>
<button
onClick={() => { setActiveTool('razor'); showToast('Công cụ chia đoạn (Razor Tool) đã kích hoạt.', 'info'); }}
className={`p-1 rounded transition text-xs flex items-center justify-center ${activeTool === 'razor' ? 'bg-cyan-950/80 text-cyan-400 border border-cyan-700/50 font-bold' : 'text-zinc-400 hover:bg-zinc-800 border border-transparent'}`}
title="Razor Tool: Click trên clip để chia nhỏ tại điểm click"
>
{/* Biểu tượng lưỡi dao cạo - dùng custom SVG hoặc scissors */}
<svg className="w-3 h-3 text-orange-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 3h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/>
<path d="M4 9h16l-3 9H7z"/>
<circle cx="12" cy="6" r="1"/>
</svg>
</button>
{/* Separator */}
<div className="w-[1px] h-3 bg-zinc-800 mx-0.5"></div>
{/* Quick Actions (Glue, Cut, Copy, Paste) */}
<button
onClick={handleGlueTracks}
className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-purple-400 transition"
title="Glue: Gộp track hiện tại với track liền dưới"
>
<i data-lucide="link" className="w-3 h-3"></i>
</button>
<button
onClick={handleCutTrack}
className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-red-400 transition"
title="Cut Clip (Ctrl+X)"
>
<i data-lucide="scissors" className="w-3 h-3"></i>
</button>
<button
onClick={handleCopyTrack}
className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-blue-400 transition"
title="Copy Clip (Ctrl+C)"
>
<i data-lucide="copy" className="w-3 h-3"></i>
</button>
<button
onClick={handlePasteTrack}
className="p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-emerald-400 transition"
title="Paste Clip (Ctrl+V)"
>
<i data-lucide="clipboard" className="w-3 h-3"></i>
</button>
</div>
</div> </div>
<div className="flex flex-col divide-y divide-[#141414]"> <div className="flex flex-col divide-y divide-[#141414]">
@@ -2580,7 +2969,7 @@
{/* Tempo Track Lane - LOOP_EDITOR_2.md §6 */} {/* Tempo Track Lane - LOOP_EDITOR_2.md §6 */}
<div className="h-[40px] w-full relative flex-shrink-0 border-b border-purple-500/30"> <div className="h-[40px] w-full relative flex-shrink-0 border-b border-purple-500/30">
<TempoTrackLane bpm={parseInt(bpm) || 120} zoom={zoom} timelineWidth={timelineWidth} <TempoTrackLane bpm={parseInt(bpm) || 120} zoom={zoom} timelineWidth={timelineWidth}
onPlayheadSet={setCurrentTime} /> onPlayheadSet={setCurrentTime} snapValue={snapValue} />
</div> </div>
{tracks.map((track) => { {tracks.map((track) => {
@@ -2602,6 +2991,10 @@
onTrackLaneMouseDown={handleTrackLaneMouseDown} onTrackLaneMouseDown={handleTrackLaneMouseDown}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
onClipDragStart={handleClipDragStart} onClipDragStart={handleClipDragStart}
activeTool={activeTool}
onSplitTrackAtTime={handleSplitTrackAtTime}
snapValue={snapValue}
bpm={bpm}
selectionMode={selectionMode} selectionMode={selectionMode}
localSelectionTrackId={localSelectionTrackId} localSelectionTrackId={localSelectionTrackId}
localSelLeft={localSelectionStart !== null && localSelectionEnd !== null ? Math.min(localSelectionStart, localSelectionEnd) : null} localSelLeft={localSelectionStart !== null && localSelectionEnd !== null ? Math.min(localSelectionStart, localSelectionEnd) : null}
@@ -2620,6 +3013,22 @@
); );
})} })}
{/* Drop zone to create a new track during drag */}
<div
className="h-[48px] w-full border-t border-dashed border-zinc-800 flex items-center justify-center text-xs text-zinc-500 hover:bg-zinc-900/20 cursor-pointer select-none shrink-0"
onMouseEnter={() => {
if (draggedClipRef.current) {
const newId = addNewTrack();
setHoveredTrackId(newId);
}
}}
onClick={addNewTrack}
>
<span className="flex items-center gap-1 text-zinc-400">
<i data-lucide="plus" className="w-3.5 h-3.5"></i> Kéo clip xung đây hoc Click đ to Track mi
</span>
</div>
{/* Selection Overlay - only for Global mode (LOOP_EDITOR.md §1.2: local draws on canvas per-track) */} {/* 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 && ( {selectionMode !== 'local' && selLeft !== null && selRight !== null && selRight > selLeft && (
<div className="absolute top-8 bottom-0 border-l border-r border-amber-500 bg-amber-500/10 z-20 selection-interactive-box cursor-grab active:cursor-grabbing" <div className="absolute top-8 bottom-0 border-l border-r border-amber-500 bg-amber-500/10 z-20 selection-interactive-box cursor-grab active:cursor-grabbing"