fix: sửa lỗi zoom in full màn hình
This commit is contained in:
+165
@@ -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
@@ -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
@@ -175,6 +175,10 @@
|
||||
onTrackLaneMouseDown,
|
||||
onContextMenu,
|
||||
onClipDragStart,
|
||||
activeTool,
|
||||
onSplitTrackAtTime,
|
||||
snapValue,
|
||||
bpm,
|
||||
}) => {
|
||||
const canvasRef = useRef(null);
|
||||
|
||||
@@ -193,13 +197,34 @@
|
||||
ctx.fillStyle = isSelected ? '#2a2a2a' : (track.id % 2 === 0 ? '#181818' : '#1d1d1d');
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// Grid lines
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.02)';
|
||||
// Grid lines based on Snap value
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)';
|
||||
ctx.lineWidth = 1;
|
||||
const stepSeconds = zoom > 120 ? 0.5 : 1;
|
||||
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;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
@@ -208,16 +233,46 @@
|
||||
}
|
||||
|
||||
// Draw waveform lane
|
||||
if (track.buffer) {
|
||||
const data = track.buffer.getChannelData(0);
|
||||
const sampleRate = track.buffer.sampleRate;
|
||||
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 > 0) {
|
||||
clips.forEach(clip => {
|
||||
const data = clip.buffer.getChannelData(0);
|
||||
const sampleRate = clip.buffer.sampleRate;
|
||||
const totalSamples = data.length;
|
||||
const duration = totalSamples / sampleRate;
|
||||
|
||||
const xStart = (track.startTime || 0) * zoom;
|
||||
const xStart = (clip.startTime || 0) * zoom;
|
||||
const wClip = duration * zoom;
|
||||
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
|
||||
if (markers && markers.length > 0) {
|
||||
markers.forEach(m => {
|
||||
@@ -229,8 +284,8 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Peak waveform drawing only within clip bounds
|
||||
ctx.strokeStyle = isSelected ? '#06b6d4' : '#6ee7b7';
|
||||
// 3. Peak waveform drawing only within clip bounds
|
||||
ctx.strokeStyle = isSelected ? '#22d3ee' : '#a7f3d0';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
const drawXStart = Math.max(0, Math.floor(xStart));
|
||||
@@ -251,13 +306,14 @@
|
||||
}
|
||||
|
||||
const mid = height / 2;
|
||||
const peakHeight = maxVal * (height * 0.4);
|
||||
const peakHeight = maxVal * (clipHeight * 0.45);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px, mid - peakHeight);
|
||||
ctx.lineTo(px, mid + peakHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
ctx.fillStyle = '#444';
|
||||
ctx.font = '12px Inter, sans-serif';
|
||||
@@ -277,12 +333,39 @@
|
||||
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 (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
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) => {
|
||||
// Ignore right-click for local selection drag (context menu handles it)
|
||||
if (e.button === 2) return;
|
||||
@@ -293,13 +376,45 @@
|
||||
const time = Math.max(0, x / zoom);
|
||||
onSelectTrack(track.id);
|
||||
|
||||
// Check for Alt+Click drag clip
|
||||
const isOverClip = track.buffer && time >= (track.startTime || 0) && time < (track.startTime || 0) + track.buffer.duration;
|
||||
if (isOverClip && e.altKey) {
|
||||
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 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.stopPropagation();
|
||||
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;
|
||||
}
|
||||
@@ -325,7 +440,7 @@
|
||||
);
|
||||
};
|
||||
|
||||
const TempoTrackLane = ({ bpm, zoom, timelineWidth, onPlayheadSet }) => {
|
||||
const TempoTrackLane = ({ bpm, zoom, timelineWidth, onPlayheadSet, snapValue }) => {
|
||||
const canvasRef = useRef(null);
|
||||
|
||||
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.font = 'bold 10px Inter, sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(`${bpm} BPM`, width - 6, 12);
|
||||
|
||||
}, [bpm, zoom, timelineWidth]);
|
||||
}, [bpm, zoom, timelineWidth, snapValue]);
|
||||
|
||||
return (
|
||||
<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: '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 [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
|
||||
const [bpm, setBpm] = useState(localStorage.getItem('studio_bpm') || '120');
|
||||
const [selectedTrackId, setSelectedTrackId] = useState('1');
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
@@ -546,6 +718,12 @@
|
||||
// buffer is captured via reference copy for undo; we store a clone for redo
|
||||
buffer: track.buffer,
|
||||
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 targetTrack = tracks.find(t => t.id === targetTrackId);
|
||||
if (targetTrack && targetTrack.buffer) {
|
||||
const targetSr = targetTrack.buffer.sampleRate;
|
||||
const clipSr = clipBuffer.sampleRate;
|
||||
// Resample clipboard to target sample rate if needed
|
||||
let pasteData;
|
||||
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 newClip = {
|
||||
id: 'clip_' + Date.now() + '_' + Math.floor(Math.random() * 1000),
|
||||
startTime: pasteTime,
|
||||
buffer: clipBuffer,
|
||||
name: name || 'Pasted Clip'
|
||||
};
|
||||
|
||||
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) {
|
||||
// Track exists but no buffer — use clipboard sample rate
|
||||
const newClipBuffer = ctx.createBuffer(1, clipBuffer.length, clipBuffer.sampleRate);
|
||||
newClipBuffer.copyToChannel(clipBuffer.getChannelData(0), 0);
|
||||
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));
|
||||
setTracks(p => p.map(t => {
|
||||
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 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);
|
||||
showToast('Đã dán vào track.', 'success');
|
||||
showToast('Đã dán clip vào track.', 'success');
|
||||
return targetTrackId;
|
||||
}
|
||||
|
||||
// No matching track — create a new one
|
||||
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
|
||||
const newId = 'track_pasted_' + Date.now();
|
||||
const newClipBuffer = ctx.createBuffer(1, clipBuffer.length, clipBuffer.sampleRate);
|
||||
newClipBuffer.copyToChannel(clipBuffer.getChannelData(0), 0);
|
||||
setTracks(prev => [...prev, {
|
||||
id: newId, name: `Pasted_${name || 'track'}`, buffer: newClipBuffer,
|
||||
id: newId,
|
||||
name: `Pasted_${name || 'track'}`,
|
||||
buffer: clipBuffer,
|
||||
startTime: pasteTime,
|
||||
clips: [newClip],
|
||||
volume: volume || 0.8, muted: false, solo: false,
|
||||
color: color || colors[prev.length % colors.length],
|
||||
markers: [], serverFileId: null,
|
||||
@@ -1157,7 +1305,7 @@
|
||||
maxDurationRef.current = maxDuration;
|
||||
|
||||
const minZoom = useMemo(() => {
|
||||
return Math.max(20, viewportWidth / maxDuration);
|
||||
return viewportWidth / maxDuration;
|
||||
}, [viewportWidth, maxDuration]);
|
||||
|
||||
const timelineWidth = useMemo(() => {
|
||||
@@ -1339,16 +1487,24 @@
|
||||
const hasSolo = tracks.some(t => t.solo) || soloedTrackId !== null;
|
||||
|
||||
tracks.forEach(track => {
|
||||
if (!track.buffer) return;
|
||||
|
||||
const isPlayable = hasSolo
|
||||
? (track.id === soloedTrackId || track.solo)
|
||||
: !track.muted;
|
||||
|
||||
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();
|
||||
source.buffer = track.buffer;
|
||||
source.buffer = clip.buffer;
|
||||
|
||||
const gainNode = context.createGain();
|
||||
gainNode.gain.setValueAtTime(track.volume, context.currentTime);
|
||||
@@ -1356,30 +1512,41 @@
|
||||
source.connect(gainNode);
|
||||
gainNode.connect(context.destination);
|
||||
|
||||
const trackStart = track.startTime || 0;
|
||||
const trackDuration = track.buffer.duration;
|
||||
const trackEnd = trackStart + trackDuration;
|
||||
const clipStart = clip.startTime || 0;
|
||||
const clipDuration = clip.buffer.duration;
|
||||
const clipEnd = clipStart + clipDuration;
|
||||
|
||||
if (offsetTime < trackStart) {
|
||||
const delay = trackStart - offsetTime;
|
||||
if (offsetTime < clipStart) {
|
||||
const delay = clipStart - offsetTime;
|
||||
source.start(context.currentTime + delay, 0);
|
||||
activeSourcesRef.current.push(source);
|
||||
} else if (offsetTime < trackEnd) {
|
||||
const playOffset = offsetTime - trackStart;
|
||||
} else if (offsetTime < clipEnd) {
|
||||
const playOffset = offsetTime - clipStart;
|
||||
source.start(context.currentTime, playOffset);
|
||||
activeSourcesRef.current.push(source);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Solo playback for Local Selection Loop (LOOP_MAKER.md §2.2)
|
||||
const startLocalTrackPlayback = (trackId, offsetTime) => {
|
||||
const context = getAudioContext();
|
||||
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();
|
||||
source.buffer = track.buffer;
|
||||
source.buffer = clip.buffer;
|
||||
|
||||
const gainNode = context.createGain();
|
||||
gainNode.gain.setValueAtTime(track.volume, context.currentTime);
|
||||
@@ -1387,19 +1554,20 @@
|
||||
source.connect(gainNode);
|
||||
gainNode.connect(context.destination);
|
||||
|
||||
const trackStart = track.startTime || 0;
|
||||
const trackDuration = track.buffer.duration;
|
||||
const trackEnd = trackStart + trackDuration;
|
||||
const clipStart = clip.startTime || 0;
|
||||
const clipDuration = clip.buffer.duration;
|
||||
const clipEnd = clipStart + clipDuration;
|
||||
|
||||
if (offsetTime < trackStart) {
|
||||
const delay = trackStart - offsetTime;
|
||||
if (offsetTime < clipStart) {
|
||||
const delay = clipStart - offsetTime;
|
||||
source.start(context.currentTime + delay, 0);
|
||||
activeSourcesRef.current.push(source);
|
||||
} else if (offsetTime < trackEnd) {
|
||||
const playOffset = offsetTime - trackStart;
|
||||
} else if (offsetTime < clipEnd) {
|
||||
const playOffset = offsetTime - clipStart;
|
||||
source.start(context.currentTime, playOffset);
|
||||
activeSourcesRef.current.push(source);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handlePlayPause = () => {
|
||||
@@ -1534,20 +1702,31 @@
|
||||
draggedClipRef.current = draggedClip;
|
||||
const hoveredTrackIdRef = useRef(null);
|
||||
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);
|
||||
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);
|
||||
|
||||
setDraggedClip({
|
||||
trackId: trackId,
|
||||
clipId: clipId,
|
||||
clickOffset: clickOffset,
|
||||
buffer: track.buffer,
|
||||
name: track.name,
|
||||
volume: track.volume,
|
||||
color: track.color,
|
||||
buffer: clip.buffer,
|
||||
name: clip.name,
|
||||
beforeSnap: beforeSnap
|
||||
});
|
||||
};
|
||||
@@ -1564,22 +1743,50 @@
|
||||
const scrollLeft = wrapper.scrollLeft;
|
||||
const mouseX = e.clientX - rect.left + scrollLeft;
|
||||
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;
|
||||
|
||||
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) {
|
||||
return { ...t, buffer: null, startTime: 0 };
|
||||
}
|
||||
if (t.id === targetTrackId) {
|
||||
const updatedClips = (t.clips || []).filter(c => c.id !== drag.clipId);
|
||||
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}`
|
||||
};
|
||||
}
|
||||
// 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,
|
||||
startTime: newStart,
|
||||
name: drag.name,
|
||||
volume: drag.volume,
|
||||
color: drag.color
|
||||
name: drag.name
|
||||
}];
|
||||
}
|
||||
return {
|
||||
...t,
|
||||
clips: updatedClips,
|
||||
buffer: updatedClips[0].buffer,
|
||||
startTime: updatedClips[0].startTime,
|
||||
name: updatedClips[0].name
|
||||
};
|
||||
}
|
||||
return t;
|
||||
@@ -1594,7 +1801,7 @@
|
||||
const drag = draggedClipRef.current;
|
||||
if (!drag) return;
|
||||
|
||||
const afterSnap = captureTrackSnapshot(drag.trackId);
|
||||
const afterSnap = captureTrackSnapshotRef.current(drag.trackId);
|
||||
pushAction('MOVE_CLIP', drag.trackId, drag.beforeSnap, afterSnap);
|
||||
|
||||
setDraggedClip(null);
|
||||
@@ -1863,6 +2070,7 @@
|
||||
}]);
|
||||
showToast(`Đã thêm Track ${newId}.`, 'info');
|
||||
setTimeout(() => lucide.createIcons(), 200);
|
||||
return newId;
|
||||
};
|
||||
|
||||
// ── Server-side Export ──
|
||||
@@ -2226,22 +2434,35 @@
|
||||
};
|
||||
|
||||
// ── Split Track at Playhead ──
|
||||
const handleSplitTrack = (trackId) => {
|
||||
const handleSplitTrackAtTime = (trackId, clipId, time) => {
|
||||
const track = tracks.find(t => t.id === trackId);
|
||||
if (!track || !track.buffer) return;
|
||||
if (!track) return;
|
||||
|
||||
const trackStart = track.startTime || 0;
|
||||
const relCurrentTime = Math.max(0, currentTime - trackStart);
|
||||
const cutTime = findZeroCrossing(track.buffer, relCurrentTime);
|
||||
const sr = track.buffer.sampleRate;
|
||||
const cutSample = Math.floor(cutTime * sr);
|
||||
const originalData = track.buffer.getChannelData(0);
|
||||
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 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) {
|
||||
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;
|
||||
}
|
||||
|
||||
const beforeSnap = captureTrackSnapshot(trackId);
|
||||
|
||||
const ctx = getAudioContext();
|
||||
const b1 = ctx.createBuffer(1, cutSample, sr);
|
||||
b1.copyToChannel(originalData.subarray(0, cutSample), 0);
|
||||
@@ -2249,24 +2470,130 @@
|
||||
const b2 = ctx.createBuffer(1, originalData.length - cutSample, sr);
|
||||
b2.copyToChannel(originalData.subarray(cutSample), 0);
|
||||
|
||||
setTracks(prev => {
|
||||
const idx = prev.findIndex(t => t.id === trackId);
|
||||
const updated = [...prev];
|
||||
updated[idx] = { ...track, name: `${track.name} (Part 1)`, buffer: b1 };
|
||||
|
||||
const newTrack = {
|
||||
...track,
|
||||
id: 'track_split_' + Date.now(),
|
||||
name: `${track.name} (Part 2)`,
|
||||
buffer: b2,
|
||||
startTime: trackStart + (cutSample / sr),
|
||||
markers: [],
|
||||
serverFileId: null,
|
||||
const clip1 = {
|
||||
id: 'clip_' + Date.now() + '_p1',
|
||||
name: `${clip.name.replace(' (Part 1)', '').replace(' (Part 2)', '')} (Part 1)`,
|
||||
buffer: b1,
|
||||
startTime: clip.startTime
|
||||
};
|
||||
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 ──
|
||||
@@ -2442,9 +2769,71 @@
|
||||
<div className="flex-1 flex overflow-y-auto select-none daw-bg relative" style={{ display: activeTab !== 'main' ? 'none' : '' }}>
|
||||
{/* 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="h-8 border-b border-zinc-900 bg-[#242424] flex items-center px-4 justify-between sticky top-0 z-30">
|
||||
<span className="text-[10px] font-bold text-zinc-500 uppercase">Danh Sách Kênh</span>
|
||||
<span className="text-[9px] bg-cyan-950 text-cyan-400 px-1 rounded font-bold uppercase">Chọn Click</span>
|
||||
<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 shrink-0 mr-2">Kênh</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 className="flex flex-col divide-y divide-[#141414]">
|
||||
@@ -2580,7 +2969,7 @@
|
||||
{/* Tempo Track Lane - LOOP_EDITOR_2.md §6 */}
|
||||
<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}
|
||||
onPlayheadSet={setCurrentTime} />
|
||||
onPlayheadSet={setCurrentTime} snapValue={snapValue} />
|
||||
</div>
|
||||
|
||||
{tracks.map((track) => {
|
||||
@@ -2602,6 +2991,10 @@
|
||||
onTrackLaneMouseDown={handleTrackLaneMouseDown}
|
||||
onContextMenu={handleContextMenu}
|
||||
onClipDragStart={handleClipDragStart}
|
||||
activeTool={activeTool}
|
||||
onSplitTrackAtTime={handleSplitTrackAtTime}
|
||||
snapValue={snapValue}
|
||||
bpm={bpm}
|
||||
selectionMode={selectionMode}
|
||||
localSelectionTrackId={localSelectionTrackId}
|
||||
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 xuống đây hoặc Click để tạo Track mới
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 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 && (
|
||||
<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"
|
||||
|
||||
Reference in New Issue
Block a user