fix: sửa lỗi audio clip dán từ clipboard không đúng duration
This commit is contained in:
+132
@@ -0,0 +1,132 @@
|
|||||||
|
# Technical Specification: Bug Fix for Clip Visual Stretching
|
||||||
|
|
||||||
|
This document analyzes the root cause and defines the waveform painting algorithm to fix the bug where a short audio clip is incorrectly stretched to fill the viewport when pasted onto a new track, referencing the real-world visual analysis in `image_f05ca7.png`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Root Cause
|
||||||
|
|
||||||
|
Based on `image_f05ca7.png`, the error occurs because the track lane's canvas render logic utilizes the total viewport width ($W_{\text{viewport}}$) as the bounding milestone to distribute and draw the entire sample count of the buffer.
|
||||||
|
|
||||||
|
* **Bug Mechanism:** The system treats the clip's start point as $0$ and the clip's end point as the end of the screen, completely ignoring the clip's actual duration ($T_{\text{clip}}$) and starting time coordinates ($t_{\text{offset}}$) of the pasted segment.
|
||||||
|
* **Consequence:** The short clip is stretched with an incorrect display frequency, falling completely out of sync with the global Time Ruler at the top.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Technical Solution: Coordinate System Alignment
|
||||||
|
|
||||||
|
To display the audio clip at its correct duration and position, every clip on the timeline must be managed using two core attributes:
|
||||||
|
|
||||||
|
* **$t_{\text{offset}}$ (seconds):** The timeline insertion position where the clip starts (the position of the playhead at the moment of pasting).
|
||||||
|
* **$T_{\text{clip}}$ (seconds):** The actual duration of the sliced audio file ($T_{\text{clip}} = \text{samples} / \text{sample\_rate}$).
|
||||||
|
|
||||||
|
```text
|
||||||
|
Global Timeline
|
||||||
|
+───────────────────────────────────────────────────────────────────────────+
|
||||||
|
│ │
|
||||||
|
│ Track 01: [█████████████████████████████████████████████████████████] │
|
||||||
|
│ │
|
||||||
|
│ Track 02: [██████████████] <--- Render only this range │
|
||||||
|
│ ▲ ▲ │
|
||||||
|
│ │ │ │
|
||||||
|
│ t_offset t_offset + T_clip │
|
||||||
|
+───────────────────────────────────────────────────────────────────────────+
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.1. Pixel Mapping Formula
|
||||||
|
|
||||||
|
Let $Z$ be the current zoom level (the number of display pixels per second of audio). The initial rendering coordinate and the physical width of the clip on the Canvas must strictly follow these formulas:
|
||||||
|
|
||||||
|
* **Starting rendering coordinate ($X_{\text{start}}$):**
|
||||||
|
|
||||||
|
$$X_{\text{start}} = t_{\text{offset}} \times Z$$
|
||||||
|
|
||||||
|
|
||||||
|
* **Physical width of the waveform ($W_{\text{clip}}$):**
|
||||||
|
|
||||||
|
$$W_{\text{clip}} = T_{\text{clip}} \times Z$$
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Safe Waveform Render Algorithm (Python & JS)
|
||||||
|
|
||||||
|
The Render Loop must exclusively calculate and draw peak amplitudes within the bound stretching from $X_{\text{start}}$ to $X_{\text{start}} + W_{\text{clip}}$. Any pixel region outside this interval must be painted with an empty background color (transparent or the track's dark background theme).
|
||||||
|
|
||||||
|
### 3.1. Pseudo-code
|
||||||
|
|
||||||
|
```python
|
||||||
|
def render_track_lane(canvas_width, zoom_level, track_clip):
|
||||||
|
# 1. Compute rendering boundaries based on synchronization formulas
|
||||||
|
x_start = track_clip.offset_seconds * zoom_level
|
||||||
|
w_clip = track_clip.duration_seconds * zoom_level
|
||||||
|
x_end = x_start + w_clip
|
||||||
|
|
||||||
|
# 2. Initialize empty background
|
||||||
|
initialize_background(0, canvas_width)
|
||||||
|
|
||||||
|
# 3. Scan the pixel array and only render within the active clip segment
|
||||||
|
for x in range(0, canvas_width):
|
||||||
|
if x < x_start or x > x_end:
|
||||||
|
# Paint empty background color for region with no data
|
||||||
|
draw_background_pixel(x)
|
||||||
|
else:
|
||||||
|
# Map current pixel x coordinate back to sample index in Buffer
|
||||||
|
time_in_clip = (x - x_start) / zoom_level
|
||||||
|
sample_index = int(time_in_clip * track_clip.sample_rate)
|
||||||
|
|
||||||
|
# Calculate amplitude peak and draw symmetrical vertical line
|
||||||
|
amplitude_peak = get_peak_amplitude(track_clip.buffer, sample_index)
|
||||||
|
draw_waveform_vertical_line(x, amplitude_peak)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Guarding Array Boundaries on Python Docker Server
|
||||||
|
|
||||||
|
When a user triggers a cut/paste operation on the Frontend, the JSON data structure dispatched to the Python Server must explicitly specify the destination paste coordinates to prevent index calculation errors:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "paste_clip",
|
||||||
|
"source_clip": {
|
||||||
|
"clip_id": "clip_abc123",
|
||||||
|
"duration_seconds": 24.150,
|
||||||
|
"sample_rate": 44100
|
||||||
|
},
|
||||||
|
"destination": {
|
||||||
|
"track_id": "02",
|
||||||
|
"paste_at_seconds": 15.300
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
On the Python backend (utilizing `pydub` or `numpy`), the binary array insertion is executed at the exact time milestone by zero-padding the preceding segment to align perfectly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
def insert_clip_to_track_array(track_array: np.ndarray, sr: int, clip_array: np.ndarray, paste_sec: float) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Inserts clip_array into track_array at paste_sec without stretching the signal.
|
||||||
|
"""
|
||||||
|
paste_sample = int(paste_sec * sr)
|
||||||
|
clip_length = len(clip_array)
|
||||||
|
|
||||||
|
# Create a new array with a length covering the entire pasted segment
|
||||||
|
required_length = max(len(track_array), paste_sample + clip_length)
|
||||||
|
output_array = np.zeros(required_length, dtype=np.float32)
|
||||||
|
|
||||||
|
# Copy original track data over
|
||||||
|
output_array[0:len(track_array)] = track_array
|
||||||
|
|
||||||
|
# Overwrite the new clip at the precise real-time coordinate position
|
||||||
|
output_array[paste_sample:paste_sample + clip_length] = clip_array
|
||||||
|
|
||||||
|
return output_array
|
||||||
|
|
||||||
|
```
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
# Technical Specification: Minimum Zoom Constraint Specification
|
||||||
|
|
||||||
|
This document defines the algorithm and graphical rendering mechanics (Rendering Logic) to solve the following problem: When zooming out to the absolute minimum, the audio waveforms of all tracks must fit perfectly within the horizontal width of the Editor viewport, as realistically illustrated in `image_f0c1e0.png`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Current State & Design Problem Analysis
|
||||||
|
|
||||||
|
Based on `image_f0c1e0.png`, when a user performs a zoom-out operation to the absolute minimum limit:
|
||||||
|
|
||||||
|
* **Visual Requirement:** The entire audio range from the starting point ($0.00\text{ s}$) to the termination point ($T_{\text{max}}$) must be captured completely within the "Display Width" ($W_{\text{viewport}}$) of the screen.
|
||||||
|
* **Desired Outcomes:**
|
||||||
|
* No redundant horizontal scrollbars appear underneath the timeline.
|
||||||
|
* No massive black voids (dead space) exist on the right side if the track duration is shorter than the viewport bounding container.
|
||||||
|
* All audio tracks are scaled down synchronously in physical size to fully display their respective waveforms from start to finish.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
```text
|
||||||
|
Editor Viewport Width (W_viewport)
|
||||||
|
|<───────────────────────────────────────────────────────────────────────────────────>|
|
||||||
|
+─────────────────────────────────────────────────────────────────────────────────────+
|
||||||
|
| Ruler: 0:00 0:10 0:20 0:30 0:40 0:50 1:00 |
|
||||||
|
+─────────────────────────────────────────────────────────────────────────────────────+
|
||||||
|
| Track 1: [███████████████████████████████████████████████████████████████████████] |
|
||||||
|
| |
|
||||||
|
| Track 2: [███████████████████████████████████████████████████████████████████████] |
|
||||||
|
+─────────────────────────────────────────────────────────────────────────────────────+
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Dynamic Min-Zoom Calculation Algorithm
|
||||||
|
|
||||||
|
To guarantee that the waveforms always fit perfectly even when the user resizes the browser window (or a Python application window), the minimum boundary zoom value ($Z_{\text{min}}$) must be evaluated dynamically.
|
||||||
|
|
||||||
|
Let:
|
||||||
|
|
||||||
|
* **$W_{\text{viewport}}$ (pixels):** The actual horizontal visible width of the timeline container viewport.
|
||||||
|
* **$T_{\text{max}}$ (seconds):** The maximum duration among all active tracks residing on the timeline.
|
||||||
|
* **$Z$ (pixels/second):** The current zoom scale factor (Zoom Level—the number of physical pixels representing $1$ second of audio).
|
||||||
|
|
||||||
|
The bounding minimum zoom level ($Z_{\text{min}}$) is determined by the formula:
|
||||||
|
|
||||||
|
$$Z_{\text{min}} = \frac{W_{\text{viewport}}}{T_{\text{max}}}$$
|
||||||
|
|
||||||
|
### 2.1. Zoom Level Constraints
|
||||||
|
|
||||||
|
Throughout mouse wheel interaction events triggered to adjust $Z$, the system must check bounds and strictly clamp the value within a safe operating spectrum:
|
||||||
|
|
||||||
|
$$Z_{\text{clipped}} = \text{clamp}(Z_{\text{min}}, Z_{\text{target}}, Z_{\text{max}})$$
|
||||||
|
|
||||||
|
*Where:*
|
||||||
|
|
||||||
|
* **$Z_{\text{max}}$:** The upper zoom-in boundary limit (e.g., fixed at $2000\text{ pixels/s}$ to eliminate canvas rendering memory overflow vulnerabilities).
|
||||||
|
* **$Z_{\text{min}}$:** The dynamic lower zoom-out boundary limit (re-calculated based on fluctuations of $W_{\text{viewport}}$ and $T_{\text{max}}$).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Synchronized Implementation Manual (Frontend JS & Python Porting)
|
||||||
|
|
||||||
|
### 3.1. Client-side Integration (JavaScript / React)
|
||||||
|
|
||||||
|
Utilize a `ResizeObserver` to systematically recompute $Z_{\text{min}}$ as soon as the user scales the browser viewport:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Initialize element targeting for the Timeline container frame
|
||||||
|
const timelineWrapper = document.getElementById('timeline-wrapper');
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(entries => {
|
||||||
|
for (let entry of entries) {
|
||||||
|
const viewportWidth = entry.contentRect.width;
|
||||||
|
|
||||||
|
// Compute dynamic Z_min boundary condition
|
||||||
|
const computedMinZoom = viewportWidth / maxDuration;
|
||||||
|
|
||||||
|
// Update state and instantly clamp current zoom so it doesn't fall below Z_min
|
||||||
|
setZoom(prevZoom => {
|
||||||
|
const nextZoom = Math.max(computedMinZoom, prevZoom);
|
||||||
|
return nextZoom;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resizeObserver.observe(timelineWrapper);
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2. Server-side / Desktop App Integration (Python PyQt6 / PySide6)
|
||||||
|
|
||||||
|
When porting this layout system and mathematical constraint model to a Python desktop application context, hook into the `resizeEvent` method of the `QWidget` class to handle container adjustments:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from PyQt6.QtWidgets import QWidget
|
||||||
|
from PyQt6.QtCore import QSize
|
||||||
|
|
||||||
|
class TimelineContainerWidget(QWidget):
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.max_duration_seconds = 60.0 # Track duration benchmark (seconds)
|
||||||
|
self.current_zoom = 100.0 # Current scale metric (pixels/second)
|
||||||
|
self.max_zoom = 2000.0 # Strict upper ceiling for zoom-in operations
|
||||||
|
|
||||||
|
def resizeEvent(self, event):
|
||||||
|
"""
|
||||||
|
Intercepts the window/widget resizing event to refresh the minimum zoom bounds.
|
||||||
|
"""
|
||||||
|
viewport_width = self.width()
|
||||||
|
|
||||||
|
# 1. Evaluate the dynamic Z_min constraint from the new physical container width
|
||||||
|
min_zoom = float(viewport_width) / self.max_duration_seconds
|
||||||
|
|
||||||
|
# 2. Hard clamp the active zoom level to prevent dropping underneath min_zoom
|
||||||
|
if self.current_zoom < min_zoom:
|
||||||
|
self.current_zoom = min_zoom
|
||||||
|
|
||||||
|
# 3. Request a graphical redraw of the waveform lanes
|
||||||
|
self.update_waveform_painter()
|
||||||
|
super().resizeEvent(event)
|
||||||
|
|
||||||
|
def update_waveform_painter(self):
|
||||||
|
# Triggers the QPainter paintEvent routine redraw execution block
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
```
|
||||||
+257
-66
@@ -174,6 +174,7 @@
|
|||||||
localSelRight,
|
localSelRight,
|
||||||
onTrackLaneMouseDown,
|
onTrackLaneMouseDown,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
|
onClipDragStart,
|
||||||
}) => {
|
}) => {
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
|
|
||||||
@@ -212,12 +213,10 @@
|
|||||||
const sampleRate = track.buffer.sampleRate;
|
const sampleRate = track.buffer.sampleRate;
|
||||||
const totalSamples = data.length;
|
const totalSamples = data.length;
|
||||||
const duration = totalSamples / sampleRate;
|
const duration = totalSamples / sampleRate;
|
||||||
const visibleStart = 0;
|
|
||||||
const visibleEnd = Math.max(1, width / zoom);
|
const xStart = (track.startTime || 0) * zoom;
|
||||||
const startSample = Math.floor(visibleStart * sampleRate);
|
const wClip = duration * zoom;
|
||||||
const endSample = Math.min(totalSamples, Math.ceil(visibleEnd * sampleRate));
|
const xEnd = xStart + wClip;
|
||||||
const samplesToDraw = endSample - startSample;
|
|
||||||
const pixelsPerSample = samplesToDraw > 0 ? width / samplesToDraw : 1;
|
|
||||||
|
|
||||||
// Draw markers
|
// Draw markers
|
||||||
if (markers && markers.length > 0) {
|
if (markers && markers.length > 0) {
|
||||||
@@ -230,13 +229,18 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Peak waveform drawing
|
// Peak waveform drawing only within clip bounds
|
||||||
ctx.strokeStyle = isSelected ? '#06b6d4' : '#6ee7b7';
|
ctx.strokeStyle = isSelected ? '#06b6d4' : '#6ee7b7';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
for (let px = 0; px < width; px++) {
|
const drawXStart = Math.max(0, Math.floor(xStart));
|
||||||
const sampleIdx = startSample + Math.floor(px / pixelsPerSample);
|
const drawXEnd = Math.min(width, Math.ceil(xEnd));
|
||||||
const chunkSize = Math.max(1, Math.floor(1 / pixelsPerSample));
|
const samplesPerPixel = sampleRate / zoom;
|
||||||
|
|
||||||
|
for (let px = drawXStart; px < drawXEnd; px++) {
|
||||||
|
const timeInClip = (px - xStart) / zoom;
|
||||||
|
const sampleIdx = Math.floor(timeInClip * sampleRate);
|
||||||
|
const chunkSize = Math.max(1, Math.floor(samplesPerPixel));
|
||||||
const chunkStart = Math.max(0, sampleIdx - Math.floor(chunkSize / 2));
|
const chunkStart = Math.max(0, sampleIdx - Math.floor(chunkSize / 2));
|
||||||
const chunkEnd = Math.min(totalSamples, chunkStart + chunkSize);
|
const chunkEnd = Math.min(totalSamples, chunkStart + chunkSize);
|
||||||
|
|
||||||
@@ -288,6 +292,18 @@
|
|||||||
const x = e.clientX - rect.left + scrollLeft;
|
const x = e.clientX - rect.left + scrollLeft;
|
||||||
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 isOverClip = track.buffer && time >= (track.startTime || 0) && time < (track.startTime || 0) + track.buffer.duration;
|
||||||
|
if (isOverClip && e.altKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onClipDragStart) {
|
||||||
|
onClipDragStart(track.id, time - (track.startTime || 0));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
onPlayheadSet(time);
|
onPlayheadSet(time);
|
||||||
if (onTrackLaneMouseDown) {
|
if (onTrackLaneMouseDown) {
|
||||||
onTrackLaneMouseDown(track.id, time, e);
|
onTrackLaneMouseDown(track.id, time, e);
|
||||||
@@ -429,9 +445,11 @@
|
|||||||
const App = () => {
|
const App = () => {
|
||||||
// ── State Definitions ──
|
// ── State Definitions ──
|
||||||
const [tracks, setTracks] = useState([
|
const [tracks, setTracks] = useState([
|
||||||
{ id: '1', name: 'Track 01', buffer: null, 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, 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 [draggedClip, setDraggedClip] = useState(null); // { trackId, clickOffset, buffer, name, volume, color }
|
||||||
|
const [hoveredTrackId, setHoveredTrackId] = useState(null);
|
||||||
// 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 [bpm, setBpm] = useState(localStorage.getItem('studio_bpm') || '120');
|
||||||
const [selectedTrackId, setSelectedTrackId] = useState('1');
|
const [selectedTrackId, setSelectedTrackId] = useState('1');
|
||||||
@@ -527,6 +545,7 @@
|
|||||||
markers: JSON.parse(JSON.stringify(track.markers || [])),
|
markers: JSON.parse(JSON.stringify(track.markers || [])),
|
||||||
// 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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -576,7 +595,7 @@
|
|||||||
if (ctrl && e.key === 'z' && !e.shiftKey) { e.preventDefault(); handleUndoRef.current(); return; }
|
if (ctrl && e.key === 'z' && !e.shiftKey) { e.preventDefault(); handleUndoRef.current(); return; }
|
||||||
if (ctrl && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); handleRedoRef.current(); return; }
|
if (ctrl && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); handleRedoRef.current(); return; }
|
||||||
if (ctrl && !alt && e.key === 'o') { e.preventDefault(); showToast('Open Project dialog','info'); return; }
|
if (ctrl && !alt && e.key === 'o') { e.preventDefault(); showToast('Open Project dialog','info'); return; }
|
||||||
if (ctrl && !alt && e.key === 'n') { e.preventDefault(); setTracks([{ id:'1', name:'Track 01', buffer:null, volume:0.8, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, volume:0.8, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','info'); return; }
|
if (ctrl && !alt && e.key === 'n') { e.preventDefault(); setTracks([{ id:'1', name:'Track 01', buffer:null, startTime:0, volume:0.8, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, startTime:0, volume:0.8, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','info'); return; }
|
||||||
if (ctrl && !alt && e.key === 's') { e.preventDefault(); showToast('Project saved','success'); return; }
|
if (ctrl && !alt && e.key === 's') { e.preventDefault(); showToast('Project saved','success'); return; }
|
||||||
if ((ctrl && alt && e.key === 's') || (ctrl && e.shiftKey && e.key === 's')) { e.preventDefault(); showToast('Save As dialog','info'); return; }
|
if ((ctrl && alt && e.key === 's') || (ctrl && e.shiftKey && e.key === 's')) { e.preventDefault(); showToast('Save As dialog','info'); return; }
|
||||||
if (ctrl && !alt && e.key === 'i') { e.preventDefault(); addNewTrack(); return; }
|
if (ctrl && !alt && e.key === 'i') { e.preventDefault(); addNewTrack(); return; }
|
||||||
@@ -648,8 +667,11 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sr = t.buffer.sampleRate;
|
const sr = t.buffer.sampleRate;
|
||||||
const startSample = Math.max(0, Math.floor(selLeft * sr));
|
const trackStart = t.startTime || 0;
|
||||||
const endSample = Math.min(t.buffer.length, Math.floor(selRight * sr));
|
const relSelLeft = Math.max(0, selLeft - trackStart);
|
||||||
|
const relSelRight = Math.max(0, selRight - trackStart);
|
||||||
|
const startSample = Math.max(0, Math.floor(relSelLeft * sr));
|
||||||
|
const endSample = Math.min(t.buffer.length, Math.floor(relSelRight * sr));
|
||||||
const len = endSample - startSample;
|
const len = endSample - startSample;
|
||||||
if (len < 100) {
|
if (len < 100) {
|
||||||
showToast('Khoảng chọn quá ngắn.', 'warning');
|
showToast('Khoảng chọn quá ngắn.', 'warning');
|
||||||
@@ -723,8 +745,9 @@
|
|||||||
// Crossfade merge into original track (§2.2)
|
// Crossfade merge into original track (§2.2)
|
||||||
const sr = track.buffer.sampleRate;
|
const sr = track.buffer.sampleRate;
|
||||||
const origData = track.buffer.getChannelData(0);
|
const origData = track.buffer.getChannelData(0);
|
||||||
const startSample = Math.floor(subTab.startTime * sr);
|
const trackStart = track.startTime || 0;
|
||||||
const endSample = Math.floor(subTab.endTime * sr);
|
const startSample = Math.floor((subTab.startTime - trackStart) * sr);
|
||||||
|
const endSample = Math.floor((subTab.endTime - trackStart) * sr);
|
||||||
const crossfadeLen = Math.min(100, Math.floor(0.01 * sr)); // 10ms
|
const crossfadeLen = Math.min(100, Math.floor(0.01 * sr)); // 10ms
|
||||||
|
|
||||||
const mergedBuffer = ctx.createBuffer(1, track.buffer.length, sr);
|
const mergedBuffer = ctx.createBuffer(1, track.buffer.length, sr);
|
||||||
@@ -831,8 +854,11 @@
|
|||||||
const sr = t.buffer.sampleRate;
|
const sr = t.buffer.sampleRate;
|
||||||
const data = t.buffer.getChannelData(0);
|
const data = t.buffer.getChannelData(0);
|
||||||
if (selLeft !== null && selRight !== null && selRight > selLeft) {
|
if (selLeft !== null && selRight !== null && selRight > selLeft) {
|
||||||
const startSample = Math.floor(selLeft * sr);
|
const trackStart = t.startTime || 0;
|
||||||
const endSample = Math.min(data.length, Math.floor(selRight * sr));
|
const relSelLeft = Math.max(0, selLeft - trackStart);
|
||||||
|
const relSelRight = Math.max(0, selRight - trackStart);
|
||||||
|
const startSample = Math.floor(relSelLeft * sr);
|
||||||
|
const endSample = Math.min(data.length, Math.floor(relSelRight * sr));
|
||||||
const len = endSample - startSample;
|
const len = endSample - startSample;
|
||||||
if (len > 0) {
|
if (len > 0) {
|
||||||
const ctx = getAudioContext();
|
const ctx = getAudioContext();
|
||||||
@@ -857,7 +883,7 @@
|
|||||||
contextMenuDelete();
|
contextMenuDelete();
|
||||||
};
|
};
|
||||||
|
|
||||||
const doPaste = (targetTrackId, pasteTime) => {
|
const doPaste = (targetTrackId, pasteTime) => {
|
||||||
if (!clipboardRef.current || !clipboardRef.current.buffer) {
|
if (!clipboardRef.current || !clipboardRef.current.buffer) {
|
||||||
showToast('Clipboard trống.', 'warning');
|
showToast('Clipboard trống.', 'warning');
|
||||||
return null;
|
return null;
|
||||||
@@ -894,15 +920,32 @@
|
|||||||
pasteData = out;
|
pasteData = out;
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertSample = Math.floor(pasteTime * targetSr);
|
const targetStart = targetTrack.startTime || 0;
|
||||||
|
const relativePasteTime = pasteTime - targetStart;
|
||||||
|
|
||||||
|
let newStartTime = targetStart;
|
||||||
|
let combined;
|
||||||
const origLen = targetTrack.buffer.length;
|
const origLen = targetTrack.buffer.length;
|
||||||
const newLen = Math.max(origLen, insertSample + pasteSamples);
|
|
||||||
const combined = ctx.createBuffer(1, newLen, targetSr);
|
|
||||||
const combinedData = combined.getChannelData(0);
|
|
||||||
const origData = targetTrack.buffer.getChannelData(0);
|
const origData = targetTrack.buffer.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];
|
if (relativePasteTime >= 0) {
|
||||||
setTracks(p => p.map(t => t.id === targetTrackId ? { ...t, buffer: combined } : t));
|
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);
|
setCurrentTime(pasteTime);
|
||||||
showToast('Đã dán vào track.', 'success');
|
showToast('Đã dán vào track.', 'success');
|
||||||
return targetTrackId;
|
return targetTrackId;
|
||||||
@@ -911,8 +954,8 @@
|
|||||||
// Track exists but no buffer — use clipboard sample rate
|
// Track exists but no buffer — use clipboard sample rate
|
||||||
const newClipBuffer = ctx.createBuffer(1, clipBuffer.length, clipBuffer.sampleRate);
|
const newClipBuffer = ctx.createBuffer(1, clipBuffer.length, clipBuffer.sampleRate);
|
||||||
newClipBuffer.copyToChannel(clipBuffer.getChannelData(0), 0);
|
newClipBuffer.copyToChannel(clipBuffer.getChannelData(0), 0);
|
||||||
setTracks(p => p.map(t => t.id === targetTrackId ? { ...t, buffer: newClipBuffer, name: `Pasted_${name || t.name}`, volume: volume || t.volume, color: color || t.color } : t));
|
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));
|
||||||
setCurrentTime(0);
|
setCurrentTime(pasteTime);
|
||||||
showToast('Đã dán vào track.', 'success');
|
showToast('Đã dán vào track.', 'success');
|
||||||
return targetTrackId;
|
return targetTrackId;
|
||||||
}
|
}
|
||||||
@@ -923,12 +966,13 @@
|
|||||||
newClipBuffer.copyToChannel(clipBuffer.getChannelData(0), 0);
|
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: newClipBuffer,
|
||||||
|
startTime: pasteTime,
|
||||||
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,
|
||||||
}]);
|
}]);
|
||||||
setSelectedTrackId(newId);
|
setSelectedTrackId(newId);
|
||||||
setCurrentTime(0);
|
setCurrentTime(pasteTime);
|
||||||
showToast('Đã dán track mới từ clipboard.', 'success');
|
showToast('Đã dán track mới từ clipboard.', 'success');
|
||||||
return newId;
|
return newId;
|
||||||
};
|
};
|
||||||
@@ -948,14 +992,17 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ctx = getAudioContext();
|
const ctx = getAudioContext();
|
||||||
const maxDur = Math.max(...activeTracks.map(t => t.buffer.duration));
|
const maxDur = Math.max(...activeTracks.map(t => (t.startTime || 0) + t.buffer.duration));
|
||||||
const sr = activeTracks[0].buffer.sampleRate;
|
const sr = activeTracks[0].buffer.sampleRate;
|
||||||
const merged = ctx.createBuffer(1, Math.ceil(maxDur * sr), sr);
|
const merged = ctx.createBuffer(1, Math.ceil(maxDur * sr), sr);
|
||||||
const mergedData = merged.getChannelData(0);
|
const mergedData = merged.getChannelData(0);
|
||||||
activeTracks.forEach(t => {
|
activeTracks.forEach(t => {
|
||||||
const data = t.buffer.getChannelData(0);
|
const data = t.buffer.getChannelData(0);
|
||||||
|
const startSample = Math.floor((t.startTime || 0) * sr);
|
||||||
for (let i = 0; i < data.length; i++) {
|
for (let i = 0; i < data.length; i++) {
|
||||||
mergedData[i] += data[i] * t.volume;
|
if (startSample + i < mergedData.length) {
|
||||||
|
mergedData[startSample + i] += data[i] * t.volume;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let maxPeak = 0;
|
let maxPeak = 0;
|
||||||
@@ -970,7 +1017,7 @@
|
|||||||
const newId = 'track_merged_' + Date.now();
|
const newId = 'track_merged_' + Date.now();
|
||||||
const names = activeTracks.map(t => t.name).join('+').slice(0, 30);
|
const names = activeTracks.map(t => t.name).join('+').slice(0, 30);
|
||||||
setTracks(prev => [...prev, {
|
setTracks(prev => [...prev, {
|
||||||
id: newId, name: `Merged_${names}.wav`, buffer: merged,
|
id: newId, name: `Merged_${names}.wav`, buffer: merged, startTime: 0,
|
||||||
volume: 0.8, muted: false, solo: false,
|
volume: 0.8, muted: false, solo: false,
|
||||||
color: colors[prev.length % colors.length], markers: [], serverFileId: null,
|
color: colors[prev.length % colors.length], markers: [], serverFileId: null,
|
||||||
}]);
|
}]);
|
||||||
@@ -984,14 +1031,22 @@
|
|||||||
const at = tracks.filter(t => t.buffer && !t.muted);
|
const at = tracks.filter(t => t.buffer && !t.muted);
|
||||||
if (at.length < 2) { showToast('Cần 2+ tracks để merge.','warning'); return; }
|
if (at.length < 2) { showToast('Cần 2+ tracks để merge.','warning'); return; }
|
||||||
const actx = getAudioContext();
|
const actx = getAudioContext();
|
||||||
const maxDur = Math.max(...at.map(t => t.buffer.duration));
|
const maxDur = Math.max(...at.map(t => (t.startTime || 0) + t.buffer.duration));
|
||||||
const sr = at[0].buffer.sampleRate;
|
const sr = at[0].buffer.sampleRate;
|
||||||
const mb = actx.createBuffer(1, Math.ceil(maxDur * sr), sr);
|
const mb = actx.createBuffer(1, Math.ceil(maxDur * sr), sr);
|
||||||
const mdata = mb.getChannelData(0);
|
const mdata = mb.getChannelData(0);
|
||||||
at.forEach(t => { const d = t.buffer.getChannelData(0); for (let i=0;i<d.length;i++) mdata[i] += d[i] * t.volume; });
|
at.forEach(t => {
|
||||||
|
const d = t.buffer.getChannelData(0);
|
||||||
|
const startSample = Math.floor((t.startTime || 0) * sr);
|
||||||
|
for (let i = 0; i < d.length; i++) {
|
||||||
|
if (startSample + i < mdata.length) {
|
||||||
|
mdata[startSample + i] += d[i] * t.volume;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
let mp = 0; for (let i=0;i<mdata.length;i++) { const a=Math.abs(mdata[i]); if (a>mp) mp=a; }
|
let mp = 0; for (let i=0;i<mdata.length;i++) { const a=Math.abs(mdata[i]); if (a>mp) mp=a; }
|
||||||
if (mp > 1.0) for (let i=0;i<mdata.length;i++) mdata[i] /= mp;
|
if (mp > 1.0) for (let i=0;i<mdata.length;i++) mdata[i] /= mp;
|
||||||
setTracks(p => [...p, { id:'merged_'+Date.now(), name:'Merged_mix.wav', buffer:mb, volume:0.8, muted:false, solo:false, color:['#0f766e','#1d4ed8'][p.length%2], markers:[], serverFileId:null }]);
|
setTracks(p => [...p, { id:'merged_'+Date.now(), name:'Merged_mix.wav', buffer:mb, startTime: 0, volume:0.8, muted:false, solo:false, color:['#0f766e','#1d4ed8'][p.length%2], markers:[], serverFileId:null }]);
|
||||||
showToast('Merged all unmuted tracks.','success');
|
showToast('Merged all unmuted tracks.','success');
|
||||||
};
|
};
|
||||||
const handleCopyTrack = () => {
|
const handleCopyTrack = () => {
|
||||||
@@ -1001,8 +1056,11 @@
|
|||||||
const data = t.buffer.getChannelData(0);
|
const data = t.buffer.getChannelData(0);
|
||||||
// If selection exists, copy only the selected region
|
// If selection exists, copy only the selected region
|
||||||
if (selLeft !== null && selRight !== null && selRight > selLeft) {
|
if (selLeft !== null && selRight !== null && selRight > selLeft) {
|
||||||
const startSample = Math.floor(selLeft * sr);
|
const trackStart = t.startTime || 0;
|
||||||
const endSample = Math.min(data.length, Math.floor(selRight * sr));
|
const relSelLeft = Math.max(0, selLeft - trackStart);
|
||||||
|
const relSelRight = Math.max(0, selRight - trackStart);
|
||||||
|
const startSample = Math.floor(relSelLeft * sr);
|
||||||
|
const endSample = Math.min(data.length, Math.floor(relSelRight * sr));
|
||||||
const len = endSample - startSample;
|
const len = endSample - startSample;
|
||||||
if (len > 0) {
|
if (len > 0) {
|
||||||
const ctx = getAudioContext();
|
const ctx = getAudioContext();
|
||||||
@@ -1025,8 +1083,11 @@
|
|||||||
const data = t.buffer.getChannelData(0);
|
const data = t.buffer.getChannelData(0);
|
||||||
// If selection exists, cut only the selected region
|
// If selection exists, cut only the selected region
|
||||||
if (selLeft !== null && selRight !== null && selRight > selLeft) {
|
if (selLeft !== null && selRight !== null && selRight > selLeft) {
|
||||||
const startSample = Math.floor(selLeft * sr);
|
const trackStart = t.startTime || 0;
|
||||||
const endSample = Math.min(data.length, Math.floor(selRight * sr));
|
const relSelLeft = Math.max(0, selLeft - trackStart);
|
||||||
|
const relSelRight = Math.max(0, selRight - trackStart);
|
||||||
|
const startSample = Math.floor(relSelLeft * sr);
|
||||||
|
const endSample = Math.min(data.length, Math.floor(relSelRight * sr));
|
||||||
const len = endSample - startSample;
|
const len = endSample - startSample;
|
||||||
if (len > 0) {
|
if (len > 0) {
|
||||||
// Copy selection to clipboard
|
// Copy selection to clipboard
|
||||||
@@ -1060,6 +1121,20 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Server Health Check ──
|
// ── Server Health Check ──
|
||||||
|
const [viewportWidth, setViewportWidth] = useState(1200);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wrapper = timelineWrapperRef.current;
|
||||||
|
if (!wrapper) return;
|
||||||
|
const observer = new ResizeObserver(entries => {
|
||||||
|
for (let entry of entries) {
|
||||||
|
setViewportWidth(entry.contentRect.width);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(wrapper);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(API_BASE_URL)
|
fetch(API_BASE_URL)
|
||||||
.then(r => { if (r.ok) setServerStatus('connected'); else setServerStatus('error'); })
|
.then(r => { if (r.ok) setServerStatus('connected'); else setServerStatus('error'); })
|
||||||
@@ -1070,7 +1145,10 @@
|
|||||||
const maxDuration = useMemo(() => {
|
const maxDuration = useMemo(() => {
|
||||||
let max = 10;
|
let max = 10;
|
||||||
tracks.forEach(t => {
|
tracks.forEach(t => {
|
||||||
if (t.buffer) max = Math.max(max, t.buffer.duration);
|
if (t.buffer) {
|
||||||
|
const trackStart = t.startTime || 0;
|
||||||
|
max = Math.max(max, trackStart + t.buffer.duration);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return max;
|
return max;
|
||||||
}, [tracks]);
|
}, [tracks]);
|
||||||
@@ -1078,9 +1156,19 @@
|
|||||||
const maxDurationRef = useRef(maxDuration);
|
const maxDurationRef = useRef(maxDuration);
|
||||||
maxDurationRef.current = maxDuration;
|
maxDurationRef.current = maxDuration;
|
||||||
|
|
||||||
const timelineWidth = useMemo(() => Math.max(zoom * maxDuration, 1200), [zoom, maxDuration]);
|
const minZoom = useMemo(() => {
|
||||||
|
return Math.max(20, viewportWidth / maxDuration);
|
||||||
|
}, [viewportWidth, maxDuration]);
|
||||||
|
|
||||||
const minZoom = useMemo(() => Math.max(20, 1200 / maxDuration), [maxDuration]);
|
const timelineWidth = useMemo(() => {
|
||||||
|
return Math.max(zoom * maxDuration, viewportWidth);
|
||||||
|
}, [zoom, maxDuration, viewportWidth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (zoom < minZoom) {
|
||||||
|
setZoom(minZoom);
|
||||||
|
}
|
||||||
|
}, [minZoom]);
|
||||||
|
|
||||||
const playheadLeftPos = useMemo(() => currentTime * zoom, [currentTime, zoom]);
|
const playheadLeftPos = useMemo(() => currentTime * zoom, [currentTime, zoom]);
|
||||||
|
|
||||||
@@ -1268,8 +1356,17 @@
|
|||||||
source.connect(gainNode);
|
source.connect(gainNode);
|
||||||
gainNode.connect(context.destination);
|
gainNode.connect(context.destination);
|
||||||
|
|
||||||
if (offsetTime < track.buffer.duration) {
|
const trackStart = track.startTime || 0;
|
||||||
source.start(0, offsetTime);
|
const trackDuration = track.buffer.duration;
|
||||||
|
const trackEnd = trackStart + trackDuration;
|
||||||
|
|
||||||
|
if (offsetTime < trackStart) {
|
||||||
|
const delay = trackStart - offsetTime;
|
||||||
|
source.start(context.currentTime + delay, 0);
|
||||||
|
activeSourcesRef.current.push(source);
|
||||||
|
} else if (offsetTime < trackEnd) {
|
||||||
|
const playOffset = offsetTime - trackStart;
|
||||||
|
source.start(context.currentTime, playOffset);
|
||||||
activeSourcesRef.current.push(source);
|
activeSourcesRef.current.push(source);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1290,8 +1387,17 @@
|
|||||||
source.connect(gainNode);
|
source.connect(gainNode);
|
||||||
gainNode.connect(context.destination);
|
gainNode.connect(context.destination);
|
||||||
|
|
||||||
if (offsetTime < track.buffer.duration) {
|
const trackStart = track.startTime || 0;
|
||||||
source.start(0, offsetTime);
|
const trackDuration = track.buffer.duration;
|
||||||
|
const trackEnd = trackStart + trackDuration;
|
||||||
|
|
||||||
|
if (offsetTime < trackStart) {
|
||||||
|
const delay = trackStart - offsetTime;
|
||||||
|
source.start(context.currentTime + delay, 0);
|
||||||
|
activeSourcesRef.current.push(source);
|
||||||
|
} else if (offsetTime < trackEnd) {
|
||||||
|
const playOffset = offsetTime - trackStart;
|
||||||
|
source.start(context.currentTime, playOffset);
|
||||||
activeSourcesRef.current.push(source);
|
activeSourcesRef.current.push(source);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1424,6 +1530,85 @@
|
|||||||
};
|
};
|
||||||
}, [zoom, maxDuration]);
|
}, [zoom, maxDuration]);
|
||||||
|
|
||||||
|
const draggedClipRef = useRef(null);
|
||||||
|
draggedClipRef.current = draggedClip;
|
||||||
|
const hoveredTrackIdRef = useRef(null);
|
||||||
|
hoveredTrackIdRef.current = hoveredTrackId;
|
||||||
|
|
||||||
|
const handleClipDragStart = (trackId, clickOffset) => {
|
||||||
|
const track = tracks.find(t => t.id === trackId);
|
||||||
|
if (!track || !track.buffer) return;
|
||||||
|
|
||||||
|
const beforeSnap = captureTrackSnapshot(trackId);
|
||||||
|
|
||||||
|
setDraggedClip({
|
||||||
|
trackId: trackId,
|
||||||
|
clickOffset: clickOffset,
|
||||||
|
buffer: track.buffer,
|
||||||
|
name: track.name,
|
||||||
|
volume: track.volume,
|
||||||
|
color: track.color,
|
||||||
|
beforeSnap: beforeSnap
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (e) => {
|
||||||
|
const drag = draggedClipRef.current;
|
||||||
|
if (!drag) return;
|
||||||
|
|
||||||
|
const wrapper = timelineWrapperRef.current;
|
||||||
|
if (!wrapper) return;
|
||||||
|
|
||||||
|
const rect = wrapper.getBoundingClientRect();
|
||||||
|
const scrollLeft = wrapper.scrollLeft;
|
||||||
|
const mouseX = e.clientX - rect.left + scrollLeft;
|
||||||
|
const time = mouseX / zoom;
|
||||||
|
const newStart = Math.max(0, time - drag.clickOffset);
|
||||||
|
|
||||||
|
const targetTrackId = hoveredTrackIdRef.current || drag.trackId;
|
||||||
|
|
||||||
|
setTracks(prev => prev.map(t => {
|
||||||
|
if (t.id === drag.trackId && drag.trackId !== targetTrackId) {
|
||||||
|
return { ...t, buffer: null, startTime: 0 };
|
||||||
|
}
|
||||||
|
if (t.id === targetTrackId) {
|
||||||
|
return {
|
||||||
|
...t,
|
||||||
|
buffer: drag.buffer,
|
||||||
|
startTime: newStart,
|
||||||
|
name: drag.name,
|
||||||
|
volume: drag.volume,
|
||||||
|
color: drag.color
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (drag.trackId !== targetTrackId) {
|
||||||
|
setDraggedClip(prev => ({ ...prev, trackId: targetTrackId }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
const drag = draggedClipRef.current;
|
||||||
|
if (!drag) return;
|
||||||
|
|
||||||
|
const afterSnap = captureTrackSnapshot(drag.trackId);
|
||||||
|
pushAction('MOVE_CLIP', drag.trackId, drag.beforeSnap, afterSnap);
|
||||||
|
|
||||||
|
setDraggedClip(null);
|
||||||
|
showToast('Đã di chuyển clip.', 'success');
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
}, [zoom]);
|
||||||
|
|
||||||
const handleSelectRange = (start, end, reset) => {
|
const handleSelectRange = (start, end, reset) => {
|
||||||
const maxLen = maxDuration;
|
const maxLen = maxDuration;
|
||||||
const cleanStart = Math.max(0, Math.min(maxLen, start));
|
const cleanStart = Math.max(0, Math.min(maxLen, start));
|
||||||
@@ -1665,20 +1850,20 @@
|
|||||||
showToast(`Đã nạp sóng âm tổng hợp: ${type.toUpperCase()}`, 'success');
|
showToast(`Đã nạp sóng âm tổng hợp: ${type.toUpperCase()}`, 'success');
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Add Track ──
|
// ── Add Track ──
|
||||||
const addNewTrack = () => {
|
const addNewTrack = () => {
|
||||||
const newId = (tracks.length + 1).toString();
|
const newId = (tracks.length + 1).toString();
|
||||||
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
|
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
|
||||||
const selectColor = colors[tracks.length % colors.length];
|
const selectColor = colors[tracks.length % colors.length];
|
||||||
|
|
||||||
setTracks(prev => [...prev, {
|
setTracks(prev => [...prev, {
|
||||||
id: newId, name: `Track ${newId}`, buffer: null,
|
id: newId, name: `Track ${newId}`, buffer: null, startTime: 0,
|
||||||
volume: 0.8, muted: false, solo: false,
|
volume: 0.8, muted: false, solo: false,
|
||||||
color: selectColor, markers: [], serverFileId: null
|
color: selectColor, markers: [], serverFileId: null
|
||||||
}]);
|
}]);
|
||||||
showToast(`Đã thêm Track ${newId}.`, 'info');
|
showToast(`Đã thêm Track ${newId}.`, 'info');
|
||||||
setTimeout(() => lucide.createIcons(), 200);
|
setTimeout(() => lucide.createIcons(), 200);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Server-side Export ──
|
// ── Server-side Export ──
|
||||||
const triggerWavExport = async () => {
|
const triggerWavExport = async () => {
|
||||||
@@ -1705,8 +1890,8 @@
|
|||||||
muted: false,
|
muted: false,
|
||||||
clips: [{
|
clips: [{
|
||||||
clip_id: `clip_${t.id}`,
|
clip_id: `clip_${t.id}`,
|
||||||
start_time_seconds: 0,
|
start_time_seconds: t.startTime || 0,
|
||||||
end_time_seconds: t.buffer.duration,
|
end_time_seconds: (t.startTime || 0) + t.buffer.duration,
|
||||||
loop_count: 1,
|
loop_count: 1,
|
||||||
apply_zero_crossing: true,
|
apply_zero_crossing: true,
|
||||||
fade_in_ms: 0,
|
fade_in_ms: 0,
|
||||||
@@ -1770,9 +1955,9 @@
|
|||||||
try {
|
try {
|
||||||
const targetRate = parseInt(exportSettings.sampleRate);
|
const targetRate = parseInt(exportSettings.sampleRate);
|
||||||
const bitDepth = parseInt(exportSettings.bitDepth);
|
const bitDepth = parseInt(exportSettings.bitDepth);
|
||||||
const durationLimit = Math.max(...activeTracks.map(t => t.buffer.duration));
|
const durationLimit = Math.max(...activeTracks.map(t => (t.startTime || 0) + t.buffer.duration));
|
||||||
|
|
||||||
const offlineCtx = new OfflineAudioContext(1, targetRate * durationLimit, targetRate);
|
const offlineCtx = new OfflineAudioContext(1, Math.ceil(targetRate * durationLimit), targetRate);
|
||||||
|
|
||||||
activeTracks.forEach(t => {
|
activeTracks.forEach(t => {
|
||||||
const source = offlineCtx.createBufferSource();
|
const source = offlineCtx.createBufferSource();
|
||||||
@@ -1781,7 +1966,8 @@
|
|||||||
gain.gain.setValueAtTime(t.volume, 0);
|
gain.gain.setValueAtTime(t.volume, 0);
|
||||||
source.connect(gain);
|
source.connect(gain);
|
||||||
gain.connect(offlineCtx.destination);
|
gain.connect(offlineCtx.destination);
|
||||||
source.start(0);
|
const trackStart = t.startTime || 0;
|
||||||
|
source.start(trackStart);
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderedBuffer = await offlineCtx.startRendering();
|
const renderedBuffer = await offlineCtx.startRendering();
|
||||||
@@ -2044,7 +2230,9 @@
|
|||||||
const track = tracks.find(t => t.id === trackId);
|
const track = tracks.find(t => t.id === trackId);
|
||||||
if (!track || !track.buffer) return;
|
if (!track || !track.buffer) return;
|
||||||
|
|
||||||
const cutTime = findZeroCrossing(track.buffer, currentTime);
|
const trackStart = track.startTime || 0;
|
||||||
|
const relCurrentTime = Math.max(0, currentTime - trackStart);
|
||||||
|
const cutTime = findZeroCrossing(track.buffer, relCurrentTime);
|
||||||
const sr = track.buffer.sampleRate;
|
const sr = track.buffer.sampleRate;
|
||||||
const cutSample = Math.floor(cutTime * sr);
|
const cutSample = Math.floor(cutTime * sr);
|
||||||
const originalData = track.buffer.getChannelData(0);
|
const originalData = track.buffer.getChannelData(0);
|
||||||
@@ -2071,6 +2259,7 @@
|
|||||||
id: 'track_split_' + Date.now(),
|
id: 'track_split_' + Date.now(),
|
||||||
name: `${track.name} (Part 2)`,
|
name: `${track.name} (Part 2)`,
|
||||||
buffer: b2,
|
buffer: b2,
|
||||||
|
startTime: trackStart + (cutSample / sr),
|
||||||
markers: [],
|
markers: [],
|
||||||
serverFileId: null,
|
serverFileId: null,
|
||||||
};
|
};
|
||||||
@@ -2405,12 +2594,14 @@
|
|||||||
loadFileOnTrack(track.id, e.dataTransfer.files[0]);
|
loadFileOnTrack(track.id, e.dataTransfer.files[0]);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={() => setHoveredTrackId(track.id)}
|
||||||
>
|
>
|
||||||
<WaveformLane track={track} zoom={zoom} timelineWidth={timelineWidth}
|
<WaveformLane track={track} zoom={zoom} timelineWidth={timelineWidth}
|
||||||
onSelectRange={handleSelectRange} onPlayheadSet={setCurrentTime}
|
onSelectRange={handleSelectRange} onPlayheadSet={setCurrentTime}
|
||||||
isSelected={isSelected} onSelectTrack={setSelectedTrackId} markers={track.markers}
|
isSelected={isSelected} onSelectTrack={setSelectedTrackId} markers={track.markers}
|
||||||
onTrackLaneMouseDown={handleTrackLaneMouseDown}
|
onTrackLaneMouseDown={handleTrackLaneMouseDown}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
|
onClipDragStart={handleClipDragStart}
|
||||||
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}
|
||||||
|
|||||||
Reference in New Issue
Block a user