fix: sửa lỗi audio clip dán từ clipboard không đúng duration

This commit is contained in:
2026-07-18 20:02:21 +07:00
parent e0742d013c
commit 2dbd5828ae
3 changed files with 515 additions and 67 deletions
+132
View File
@@ -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
View File
@@ -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
View File
@@ -174,6 +174,7 @@
localSelRight,
onTrackLaneMouseDown,
onContextMenu,
onClipDragStart,
}) => {
const canvasRef = useRef(null);
@@ -212,12 +213,10 @@
const sampleRate = track.buffer.sampleRate;
const totalSamples = data.length;
const duration = totalSamples / sampleRate;
const visibleStart = 0;
const visibleEnd = Math.max(1, width / zoom);
const startSample = Math.floor(visibleStart * sampleRate);
const endSample = Math.min(totalSamples, Math.ceil(visibleEnd * sampleRate));
const samplesToDraw = endSample - startSample;
const pixelsPerSample = samplesToDraw > 0 ? width / samplesToDraw : 1;
const xStart = (track.startTime || 0) * zoom;
const wClip = duration * zoom;
const xEnd = xStart + wClip;
// Draw markers
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.lineWidth = 1;
for (let px = 0; px < width; px++) {
const sampleIdx = startSample + Math.floor(px / pixelsPerSample);
const chunkSize = Math.max(1, Math.floor(1 / pixelsPerSample));
const drawXStart = Math.max(0, Math.floor(xStart));
const drawXEnd = Math.min(width, Math.ceil(xEnd));
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 chunkEnd = Math.min(totalSamples, chunkStart + chunkSize);
@@ -288,6 +292,18 @@
const x = e.clientX - rect.left + scrollLeft;
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) {
e.preventDefault();
e.stopPropagation();
if (onClipDragStart) {
onClipDragStart(track.id, time - (track.startTime || 0));
}
return;
}
onPlayheadSet(time);
if (onTrackLaneMouseDown) {
onTrackLaneMouseDown(track.id, time, e);
@@ -429,9 +445,11 @@
const App = () => {
// ── State Definitions ──
const [tracks, setTracks] = useState([
{ 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 },
{ 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 [draggedClip, setDraggedClip] = useState(null); // { trackId, clickOffset, buffer, name, volume, color }
const [hoveredTrackId, setHoveredTrackId] = useState(null);
// BMP for Tempo Track - LOOP_EDITOR_2.md §6
const [bpm, setBpm] = useState(localStorage.getItem('studio_bpm') || '120');
const [selectedTrackId, setSelectedTrackId] = useState('1');
@@ -527,6 +545,7 @@
markers: JSON.parse(JSON.stringify(track.markers || [])),
// buffer is captured via reference copy for undo; we store a clone for redo
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 === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); handleRedoRef.current(); return; }
if (ctrl && !alt && e.key === 'o') { e.preventDefault(); showToast('Open Project dialog','info'); return; }
if (ctrl && !alt && e.key === 'n') { e.preventDefault(); setTracks([{ id:'1', name:'Track 01', buffer:null, 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') || (ctrl && e.shiftKey && e.key === 's')) { e.preventDefault(); showToast('Save As dialog','info'); return; }
if (ctrl && !alt && e.key === 'i') { e.preventDefault(); addNewTrack(); return; }
@@ -648,8 +667,11 @@
return;
}
const sr = t.buffer.sampleRate;
const startSample = Math.max(0, Math.floor(selLeft * sr));
const endSample = Math.min(t.buffer.length, Math.floor(selRight * sr));
const trackStart = t.startTime || 0;
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;
if (len < 100) {
showToast('Khoảng chọn quá ngắn.', 'warning');
@@ -723,8 +745,9 @@
// Crossfade merge into original track (§2.2)
const sr = track.buffer.sampleRate;
const origData = track.buffer.getChannelData(0);
const startSample = Math.floor(subTab.startTime * sr);
const endSample = Math.floor(subTab.endTime * sr);
const trackStart = track.startTime || 0;
const startSample = Math.floor((subTab.startTime - trackStart) * sr);
const endSample = Math.floor((subTab.endTime - trackStart) * sr);
const crossfadeLen = Math.min(100, Math.floor(0.01 * sr)); // 10ms
const mergedBuffer = ctx.createBuffer(1, track.buffer.length, sr);
@@ -831,8 +854,11 @@
const sr = t.buffer.sampleRate;
const data = t.buffer.getChannelData(0);
if (selLeft !== null && selRight !== null && selRight > selLeft) {
const startSample = Math.floor(selLeft * sr);
const endSample = Math.min(data.length, Math.floor(selRight * sr));
const trackStart = t.startTime || 0;
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;
if (len > 0) {
const ctx = getAudioContext();
@@ -857,7 +883,7 @@
contextMenuDelete();
};
const doPaste = (targetTrackId, pasteTime) => {
const doPaste = (targetTrackId, pasteTime) => {
if (!clipboardRef.current || !clipboardRef.current.buffer) {
showToast('Clipboard trống.', 'warning');
return null;
@@ -894,15 +920,32 @@
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 newLen = Math.max(origLen, insertSample + pasteSamples);
const combined = ctx.createBuffer(1, newLen, targetSr);
const combinedData = combined.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];
setTracks(p => p.map(t => t.id === targetTrackId ? { ...t, buffer: combined } : t));
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;
@@ -911,8 +954,8 @@
// 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, name: `Pasted_${name || t.name}`, volume: volume || t.volume, color: color || t.color } : t));
setCurrentTime(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));
setCurrentTime(pasteTime);
showToast('Đã dán vào track.', 'success');
return targetTrackId;
}
@@ -923,12 +966,13 @@
newClipBuffer.copyToChannel(clipBuffer.getChannelData(0), 0);
setTracks(prev => [...prev, {
id: newId, name: `Pasted_${name || 'track'}`, buffer: newClipBuffer,
startTime: pasteTime,
volume: volume || 0.8, muted: false, solo: false,
color: color || colors[prev.length % colors.length],
markers: [], serverFileId: null,
}]);
setSelectedTrackId(newId);
setCurrentTime(0);
setCurrentTime(pasteTime);
showToast('Đã dán track mới từ clipboard.', 'success');
return newId;
};
@@ -948,14 +992,17 @@
return;
}
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 merged = ctx.createBuffer(1, Math.ceil(maxDur * sr), sr);
const mergedData = merged.getChannelData(0);
activeTracks.forEach(t => {
const data = t.buffer.getChannelData(0);
const startSample = Math.floor((t.startTime || 0) * sr);
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;
@@ -970,7 +1017,7 @@
const newId = 'track_merged_' + Date.now();
const names = activeTracks.map(t => t.name).join('+').slice(0, 30);
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,
color: colors[prev.length % colors.length], markers: [], serverFileId: null,
}]);
@@ -984,14 +1031,22 @@
const at = tracks.filter(t => t.buffer && !t.muted);
if (at.length < 2) { showToast('Cần 2+ tracks để merge.','warning'); return; }
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 mb = actx.createBuffer(1, Math.ceil(maxDur * sr), sr);
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; }
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');
};
const handleCopyTrack = () => {
@@ -1001,8 +1056,11 @@
const data = t.buffer.getChannelData(0);
// If selection exists, copy only the selected region
if (selLeft !== null && selRight !== null && selRight > selLeft) {
const startSample = Math.floor(selLeft * sr);
const endSample = Math.min(data.length, Math.floor(selRight * sr));
const trackStart = t.startTime || 0;
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;
if (len > 0) {
const ctx = getAudioContext();
@@ -1025,8 +1083,11 @@
const data = t.buffer.getChannelData(0);
// If selection exists, cut only the selected region
if (selLeft !== null && selRight !== null && selRight > selLeft) {
const startSample = Math.floor(selLeft * sr);
const endSample = Math.min(data.length, Math.floor(selRight * sr));
const trackStart = t.startTime || 0;
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;
if (len > 0) {
// Copy selection to clipboard
@@ -1060,6 +1121,20 @@
};
// ── 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(() => {
fetch(API_BASE_URL)
.then(r => { if (r.ok) setServerStatus('connected'); else setServerStatus('error'); })
@@ -1070,7 +1145,10 @@
const maxDuration = useMemo(() => {
let max = 10;
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;
}, [tracks]);
@@ -1078,9 +1156,19 @@
const maxDurationRef = useRef(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]);
@@ -1268,8 +1356,17 @@
source.connect(gainNode);
gainNode.connect(context.destination);
if (offsetTime < track.buffer.duration) {
source.start(0, offsetTime);
const trackStart = track.startTime || 0;
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);
}
});
@@ -1290,8 +1387,17 @@
source.connect(gainNode);
gainNode.connect(context.destination);
if (offsetTime < track.buffer.duration) {
source.start(0, offsetTime);
const trackStart = track.startTime || 0;
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);
}
};
@@ -1424,6 +1530,85 @@
};
}, [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 maxLen = maxDuration;
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');
};
// ── Add Track ──
const addNewTrack = () => {
const newId = (tracks.length + 1).toString();
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
const selectColor = colors[tracks.length % colors.length];
// ── Add Track ──
const addNewTrack = () => {
const newId = (tracks.length + 1).toString();
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
const selectColor = colors[tracks.length % colors.length];
setTracks(prev => [...prev, {
id: newId, name: `Track ${newId}`, buffer: null,
volume: 0.8, muted: false, solo: false,
color: selectColor, markers: [], serverFileId: null
}]);
showToast(`Đã thêm Track ${newId}.`, 'info');
setTimeout(() => lucide.createIcons(), 200);
};
setTracks(prev => [...prev, {
id: newId, name: `Track ${newId}`, buffer: null, startTime: 0,
volume: 0.8, muted: false, solo: false,
color: selectColor, markers: [], serverFileId: null
}]);
showToast(`Đã thêm Track ${newId}.`, 'info');
setTimeout(() => lucide.createIcons(), 200);
};
// ── Server-side Export ──
const triggerWavExport = async () => {
@@ -1705,8 +1890,8 @@
muted: false,
clips: [{
clip_id: `clip_${t.id}`,
start_time_seconds: 0,
end_time_seconds: t.buffer.duration,
start_time_seconds: t.startTime || 0,
end_time_seconds: (t.startTime || 0) + t.buffer.duration,
loop_count: 1,
apply_zero_crossing: true,
fade_in_ms: 0,
@@ -1770,9 +1955,9 @@
try {
const targetRate = parseInt(exportSettings.sampleRate);
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 => {
const source = offlineCtx.createBufferSource();
@@ -1781,7 +1966,8 @@
gain.gain.setValueAtTime(t.volume, 0);
source.connect(gain);
gain.connect(offlineCtx.destination);
source.start(0);
const trackStart = t.startTime || 0;
source.start(trackStart);
});
const renderedBuffer = await offlineCtx.startRendering();
@@ -2044,7 +2230,9 @@
const track = tracks.find(t => t.id === trackId);
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 cutSample = Math.floor(cutTime * sr);
const originalData = track.buffer.getChannelData(0);
@@ -2071,6 +2259,7 @@
id: 'track_split_' + Date.now(),
name: `${track.name} (Part 2)`,
buffer: b2,
startTime: trackStart + (cutSample / sr),
markers: [],
serverFileId: null,
};
@@ -2405,12 +2594,14 @@
loadFileOnTrack(track.id, e.dataTransfer.files[0]);
}
}}
onMouseEnter={() => setHoveredTrackId(track.id)}
>
<WaveformLane track={track} zoom={zoom} timelineWidth={timelineWidth}
onSelectRange={handleSelectRange} onPlayheadSet={setCurrentTime}
isSelected={isSelected} onSelectTrack={setSelectedTrackId} markers={track.markers}
onTrackLaneMouseDown={handleTrackLaneMouseDown}
onContextMenu={handleContextMenu}
onClipDragStart={handleClipDragStart}
selectionMode={selectionMode}
localSelectionTrackId={localSelectionTrackId}
localSelLeft={localSelectionStart !== null && localSelectionEnd !== null ? Math.min(localSelectionStart, localSelectionEnd) : null}