fix: chỉnh sửa giao diện của main session

This commit is contained in:
2026-07-19 16:25:30 +07:00
parent 4744b2c485
commit 2894fae19b
2 changed files with 708 additions and 217 deletions
+322
View File
@@ -0,0 +1,322 @@
# Technical Specification: Implementing Volume, Fades & Panning Envelope Arrays on Audio Signals
This document defines the mathematical models, data flow diagrams (Audio Node Graph), and execution source code required to apply interactive graphical curves (Volume Automation, Fades, and Panning Automation) into the real-time digital signal processing pipeline on the Frontend and offline file export rendering on the Dockerized Python Backend.
---
## 1. Multi-stage Audio Node Graph
To simultaneously compute all three graphical configurations over the audio stream without precipitating phase cancellation or signal latency anomalies, the environment builds an explicit downstream node connection graph:
```text
┌─────────────────────────┐
│ AudioBufferSourceNode │ --> Streams the native original raw buffer array
└────────────┬────────────┘
┌─────────────────────────┐
│ GainNode (Automation) │ --> Modulates Volume dynamically via multi-point automation arrays
└────────────┬────────────┘
┌─────────────────────────┐
│ StereoPannerNode │ --> Transposes the Stereo Image (L/R Balance Automation trajectory)
└────────────┬────────────┘
┌─────────────────────────┐
│ GainNode (Fades) │ --> Multiplies bounding Fade-In and Fade-Out curves
└────────────┬────────────┘
┌─────────────────────────┐
│ AudioContext.destination│ --> Routes processed signal to hardware device outputs (Speakers/Headphones)
└─────────────────────────┘
```
---
## 2. Mathematical Formulations for Modulators
### 2.1. Multi-Point Volume Automation Curves
The vertical coordinate axis $Y$ of the volume points plots decibel thresholds bounded from $-30\text{ dB}$ to $+3\text{ dB}$. Prior to applying multipliers onto the signal, the logarithmic values must be translated into a standard linear scalar gain coefficient $G_{\text{linear}}$:
$$G_{\text{linear}}(t) = 10^{\frac{V_{\text{dB}}(t)}{20}}$$
At an arbitrary timeline timestamp $t$ residing between two chronologically adjacent control nodes $P_1(t_1, V_1)$ and $P_2(t_2, V_2)$, the target volume attenuation value is computed via standard linear interpolation:
$$V_{\text{dB}}(t) = V_1 + (t - t_1) \cdot \frac{V_2 - V_1}{t_2 - t_1}$$
### 2.2. Constant-Power Stereo Panning
To ensure that when a user shifts the audio image toward the Left ($L$) or Right ($R$) perimeter channels, the cumulative output sound energy emitted by the drivers does not collapse (avoiding a volume drop at the absolute horizontal center axis—known as the *Center Dip* anomaly), the system implements the **Constant-Power Panning Law**.
Let $p(t) \in [-1.0, 1.0]$ map to the explicit panning index at timestamp $t$ (where $-1.0$ represents a hard-left channel displacement, $0.0$ marks absolute center, and $+1.0$ dictates a hard-right channel boundary).
Convert the raw linear panning factor $p(t)$ into a circular panning sweep angle coordinate $\theta(t) \in [0, \pi/2]$:
$$\theta(t) = \frac{p(t) + 1}{2} \cdot \frac{\pi}{2}$$
Calculate the independent amplitude scalar gains for the Left channel ($g_L$) and the Right channel ($g_R$) elements:
$$g_L(t) = \cos(\theta(t)), \quad g_R(t) = \sin(\theta(t))$$
*Mathematical Proof:* The total sound field energy remains perfectly preserved under all operational transformations because:
$$g_L(t)^2 + g_R(t)^2 = \cos^2(\theta(t)) + \sin^2(\theta(t)) = 1.0$$
### 2.3. Fade Curves (Fade-In & Fade-Out)
Fading shapes are driven by a trigonometric Cosine equation framework to build organic, smooth amplitude transitions at the structural boundary zones of the audio asset:
* **Fade-In Curve** (Across an introductory duration window of $L_{\text{fade}}$ seconds):
$$f_{\text{in}}(t) = \frac{1 - \cos\left( \pi \cdot \frac{t}{L_{\text{fade}}} \right)}{2} \quad \text{for } 0 \le t < L_{\text{fade}}$$
* **Fade-Out Curve** (Across a trailing termination window of $L_{\text{fade}}$ seconds):
$$f_{\text{out}}(t) = \frac{1 + \cos\left( \pi \cdot \frac{t - (T_{\text{max}} - L_{\text{fade}})}{L_{\text{fade}}} \right)}{2} \quad \text{for } T_{\text{max}} - L_{\text{fade}} \le t \le T_{\text{max}}$$
---
## 3. Client-Side Runtime Integration (Web Audio API - Live Playback Modulator)
This JavaScript module sets up the physical Web Audio node graphs and automates parameters directly matching the real-time audio thread clocks:
```javascript
/**
* Configures a real-time audio node processing graph with parameter automation.
* @param {AudioContext} audioCtx - The active Web Audio runtime context instance.
* @param {AudioBuffer} audioBuffer - Decoded original raw target audio source asset.
* @param {number} startTime - Global time position index marking where playback initiates (seconds).
* @param {Array} volumeNodes - Automation point layout maps: [{time: 0.5, db: -3.0}, ...].
* @param {Array} panningNodes - Panning position layout maps: [{time: 1.2, pan: -0.5}, ...].
* @param {object} fadeConfig - Bounding fade time constants: {fadeInLen: 0.5, fadeOutLen: 0.8}.
*/
function playTrackWithAutomation(audioCtx, audioBuffer, startTime, volumeNodes, panningNodes, fadeConfig) {
// 1. Instantiate the Global Audio Source Buffer Node
const sourceNode = audioCtx.createBufferSource();
sourceNode.buffer = audioBuffer;
// 2. Instantiate the Gain Node managing Volume Automation tracking loops
const volumeGainNode = audioCtx.createGain();
// Establish baseline default state variables at Unity Gain (0 dB)
volumeGainNode.gain.setValueAtTime(1.0, audioCtx.currentTime);
// Map timeline automations for custom Volume Node trajectories
if (volumeNodes && volumeNodes.length > 0) {
// Purge legacy scheduled values to safely overwrite parameters
volumeGainNode.gain.cancelScheduledValues(audioCtx.currentTime);
volumeNodes.forEach(node => {
const timeOffset = startTime + node.time;
const linearGain = Math.pow(10, node.db / 20); // Map logarithmic dB thresholds to linear multipliers
volumeGainNode.gain.linearRampToValueAtTime(linearGain, audioCtx.currentTime + node.time);
});
}
// 3. Instantiate the StereoPannerNode for Panning Automation structures
const pannerNode = audioCtx.createStereoPanner();
pannerNode.pan.setValueAtTime(0.0, audioCtx.currentTime); // Standard initialization locked at Center
// Map timeline automations for Panning Node trajectories
if (panningNodes && panningNodes.length > 0) {
pannerNode.pan.cancelScheduledValues(audioCtx.currentTime);
panningNodes.forEach(node => {
// Enforce rigid clipping bounds to keep panning factors inside [-1.0, 1.0]
const clampedPan = Math.max(-1.0, Math.min(1.0, node.pan));
pannerNode.pan.linearRampToValueAtTime(clampedPan, audioCtx.currentTime + node.time);
});
}
// 4. Instantiate the Gain Node dedicated to boundary Fades
const fadeGainNode = audioCtx.createGain();
fadeGainNode.gain.setValueAtTime(1.0, audioCtx.currentTime);
const duration = audioBuffer.duration;
// Calculate and schedule introductory Fade-In values
if (fadeConfig.fadeInLen > 0) {
fadeGainNode.gain.setValueAtTime(0.0, audioCtx.currentTime);
fadeGainNode.gain.linearRampToValueAtTime(1.0, audioCtx.currentTime + fadeConfig.fadeInLen);
}
// Calculate and schedule terminating Fade-Out values
if (fadeConfig.fadeOutLen > 0) {
const fadeOutStart = duration - fadeConfig.fadeOutLen;
fadeGainNode.gain.setValueAtTime(1.0, audioCtx.currentTime + fadeOutStart);
fadeGainNode.gain.linearRampToValueAtTime(0.0, audioCtx.currentTime + duration);
}
// 5. Connect the physical downstream structural audio pipeline
sourceNode.connect(volumeGainNode);
volumeGainNode.connect(pannerNode);
pannerNode.connect(fadeGainNode);
fadeGainNode.connect(audioCtx.destination);
// 6. Drive hardware execution loops
sourceNode.start(0);
return { sourceNode, volumeGainNode, pannerNode, fadeGainNode };
}
```
---
## 4. Server-Side Execution Engine (Dockerized Python Engine - NumPy Processing)
When a user triggers an *Apply* action or an offline *Export* script, the frontend dispatches serialized JSON configuration models down to the Python backend framework. The signal processing architecture uses high-efficiency vectorized loops inside NumPy to multiply envelope modulators straight onto raw multi-channel float data:
```python
import numpy as np
class DSPAudioModulator:
@staticmethod
def apply_automation_and_panning(
y_raw: np.ndarray,
sr: int,
volume_points: list, # [{"time": 0.5, "db": -6.0}, ...]
panning_points: list, # [{"time": 1.0, "pan": -0.7}, ...]
fade_in_sec: float = 0.0,
fade_out_sec: float = 0.0
) -> np.ndarray:
"""
Applies multi-point volume envelopes, constant-power panning, and trigonometric fades
directly onto a 1D (Mono) or 2D (Stereo) acoustic NumPy signal array.
Input: y_raw maps to the raw sound array (Mono/Stereo matrix bounded inside [-1.0, 1.0]).
Output: y_processed yields a 2D interleaved Stereo NumPy array (2, N) with baked modulations.
"""
total_samples = y_raw.shape[-1] if len(y_raw.shape) > 1 else len(y_raw)
duration_sec = total_samples / sr
# 1. Guarantee Stereo geometry dimensions (2 discrete channels) for Panning operations
if len(y_raw.shape) == 1:
# For Mono arrays, clone sample metrics symmetrically to Left/Right matrices
y_stereo = np.vstack((y_raw, y_raw))
else:
y_stereo = np.copy(y_raw)
# 2. Allocate Envelope Mask arrays matching total track samples limits
volume_envelope = np.ones(total_samples, dtype=np.float32)
pan_envelope = np.zeros(total_samples, dtype=np.float32) # Default initialization: Center (0.0)
# 3. Compile the Volume Envelope using linear interpolation bounds across nodes
if volume_points and len(volume_points) > 0:
# Enforce strict chronological sorting down the timeline axis
points = sorted(volume_points, key=lambda x: x["time"])
# Pad introductory bounds if the initial point coordinate sits past t = 0.0s
if points[0]["time"] > 0:
first_gain = 10.0 ** (points[0]["db"] / 20.0)
idx_end = int(points[0]["time"] * sr)
volume_envelope[:idx_end] = first_gain
for i in range(len(points) - 1):
p1, p2 = points[i], points[i+1]
idx_start = int(p1["time"] * sr)
idx_end = int(p2["time"] * sr)
gain_start = 10.0 ** (p1["db"] / 20.0)
gain_end = 10.0 ** (p2["db"] / 20.0)
# Linearly interpolate vector increments between adjacent anchor positions
volume_envelope[idx_start:idx_end] = np.linspace(gain_start, gain_end, idx_end - idx_start)
# Pad trailing bounds from the final milestone extending through end-of-file
if points[-1]["time"] < duration_sec:
last_gain = 10.0 ** (points[-1]["db"] / 20.0)
idx_start = int(points[-1]["time"] * sr)
volume_envelope[idx_start:] = last_gain
# 4. Compile the Panning Envelope using linear interpolation bounds across nodes
if panning_points and len(panning_points) > 0:
points = sorted(panning_points, key=lambda x: x["time"])
if points[0]["time"] > 0:
pan_envelope[:int(points[0]["time"] * sr)] = points[0]["pan"]
for i in range(len(points) - 1):
p1, p2 = points[i], points[i+1]
idx_start = int(p1["time"] * sr)
idx_end = int(p2["time"] * sr)
pan_envelope[idx_start:idx_end] = np.linspace(p1["pan"], p2["pan"], idx_end - idx_start)
if points[-1]["time"] < duration_sec:
pan_envelope[int(points[-1]["time"] * sr):] = points[-1]["pan"]
# 5. Apply Trigonometric Cosine Fade-In / Fade-Out functions onto the Volume Envelope mask
if fade_in_sec > 0:
fade_in_samples = min(total_samples, int(fade_in_sec * sr))
x_fade = np.linspace(0.0, np.pi, fade_in_samples)
cosine_ramp = (1.0 - np.cos(x_fade)) / 2.0
volume_envelope[:fade_in_samples] *= cosine_ramp
if fade_out_sec > 0:
fade_out_samples = min(total_samples, int(fade_out_sec * sr))
x_fade = np.linspace(0.0, np.pi, fade_out_samples)
cosine_ramp = (1.0 + np.cos(x_fade)) / 2.0
volume_envelope[-fade_out_samples:] *= cosine_ramp
# 6. Bake Volume Envelope matrices onto the Left and Right discrete audio paths
y_stereo[0, :] *= volume_envelope
y_stereo[1, :] *= volume_envelope
# 7. Apply Constant-Power Stereo Panning allocations
# Map panning metrics range [-1.0, 1.0] onto angular radians field array [0, pi/2]
theta_envelope = ((pan_envelope + 1.0) / 2.0) * (np.pi / 2.0)
# Evaluate localized amplitude coefficients for physical channels split
gain_left = np.cos(theta_envelope)
gain_right = np.sin(theta_envelope)
# Multiply scaling factors directly across corresponding discrete matrices
y_stereo[0, :] *= gain_left
y_stereo[1, :] *= gain_right
return y_stereo
```
---
## 5. Viewport Coordinate Mapping & Data Serialization Protocols
As users drag and adjust coordinate anchors over the visual drawing Canvas, mouse-event pixel coordinates are continuously calculated and mapped into absolute physical values to preserve data matching between frontend layouts and backend signal arrays:
```text
[ GRAPH CANVAS VIEWPORT COORDINATES ] [ REAL-WORLD SYSTEM PHENOMENA VALUES ]
x (pixel) ─────────────────────────────────────► Timeline position t (seconds) = x / zoom_level
y (pixel) ─ (Volume: Center axis maps to 0dB) ─► db = ( (h - y) / h_half ) * range_db
y (pixel) ─ (Panning: Center axis maps to 0) ──► pan = ( (h_half - y) / h_half ) -> Bounded [-1.0, 1.0]
```
### Serialized API Data Transfer Model (Standard JSON Package Syntax)
```json
{
"track_id": "1",
"fades": {
"fade_in_sec": 0.500,
"fade_out_sec": 1.200
},
"volume_automation": [
{ "time": 0.000, "db": 0.0 },
{ "time": 1.450, "db": 3.0 },
{ "time": 3.820, "db": -12.5 },
{ "time": 6.000, "db": 0.0 }
],
"panning_automation": [
{ "time": 0.000, "pan": 0.0 },
{ "time": 2.100, "pan": -0.8 },
{ "time": 4.500, "pan": 0.8 },
{ "time": 6.000, "pan": 0.0 }
]
}
```
+386 -217
View File
@@ -668,7 +668,7 @@
}; };
// ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2 & SUB_EDITOR.md) ── // ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2 & SUB_EDITOR.md) ──
const SubTabWaveform = ({ buffer, subTabId, activeTab, currentTime, selectionStart, selectionEnd, onSelectRange, onPlayheadSet, onContextMenu, activeTool }) => { const SubTabWaveform = ({ buffer, subTabId, activeTab, currentTime, selectionStart, selectionEnd, onSelectRange, onPlayheadSet, onContextMenu, activeTool, zoom, timelineWidth }) => {
const canvasRef = useRef(null); const canvasRef = useRef(null);
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
@@ -676,12 +676,12 @@
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr; const w = timelineWidth;
canvas.height = rect.height * dpr; const h = rect.height;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr); ctx.scale(dpr, dpr);
const w = rect.width;
const h = rect.height;
ctx.fillStyle = '#181818'; ctx.fillStyle = '#181818';
ctx.fillRect(0, 0, w, h); ctx.fillRect(0, 0, w, h);
@@ -689,30 +689,46 @@
const len = data.length; const len = data.length;
if (len === 0) return; if (len === 0) return;
// Draw waveform // Draw waveform only in viewport range for performance & zero-line accuracy
ctx.fillStyle = '#6ee7b7'; const xStart = 0;
const wClip = buffer.duration * zoom;
const xEnd = xStart + wClip;
const drawXStart = Math.max(0, Math.floor(xStart));
const drawXEnd = Math.min(w, Math.ceil(xEnd));
const samplesPerPixel = buffer.sampleRate / zoom;
ctx.strokeStyle = '#6ee7b7';
ctx.lineWidth = 1;
const mid = h / 2; const mid = h / 2;
for (let px = 0; px < w; px++) {
const start = Math.floor((px / w) * len); for (let px = drawXStart; px < drawXEnd; px++) {
const end = Math.floor(((px + 1) / w) * len); const time = px / zoom;
const sampleIdx = Math.floor(time * buffer.sampleRate);
const chunkSize = Math.max(1, Math.floor(samplesPerPixel));
const chunkStart = Math.max(0, sampleIdx - Math.floor(chunkSize / 2));
const chunkEnd = Math.min(len, chunkStart + chunkSize);
let maxVal = 0; let maxVal = 0;
let minVal = 0; let minVal = 0;
for (let i = start; i < end && i < len; i++) { for (let i = chunkStart; i < chunkEnd; i++) {
const val = data[i]; const val = data[i];
if (val > maxVal) maxVal = val; if (val > maxVal) maxVal = val;
if (val < minVal) minVal = val; if (val < minVal) minVal = val;
} }
const yTop = mid + (minVal * (h * 0.45)); const yTop = mid + (minVal * (h * 0.45));
const yBottom = mid + (maxVal * (h * 0.45)); const yBottom = mid + (maxVal * (h * 0.45));
ctx.fillRect(px, yTop, 1, Math.max(1, yBottom - yTop)); ctx.beginPath();
ctx.moveTo(px, yTop);
ctx.lineTo(px, yBottom);
ctx.stroke();
} }
// Highlight selection if active // Highlight selection if active
if (selectionStart !== null && selectionEnd !== null && selectionStart !== selectionEnd) { if (selectionStart !== null && selectionEnd !== null && selectionStart !== selectionEnd) {
const left = Math.min(selectionStart, selectionEnd); const left = Math.min(selectionStart, selectionEnd);
const right = Math.max(selectionStart, selectionEnd); const right = Math.max(selectionStart, selectionEnd);
const leftPx = (left / buffer.duration) * w; const leftPx = left * zoom;
const rightPx = (right / buffer.duration) * w; const rightPx = right * zoom;
ctx.fillStyle = 'rgba(245, 158, 11, 0.15)'; ctx.fillStyle = 'rgba(245, 158, 11, 0.15)';
ctx.fillRect(leftPx, 0, rightPx - leftPx, h); ctx.fillRect(leftPx, 0, rightPx - leftPx, h);
ctx.strokeStyle = '#f59e0b'; ctx.strokeStyle = '#f59e0b';
@@ -721,20 +737,20 @@
ctx.moveTo(leftPx, 0); ctx.lineTo(leftPx, h); ctx.moveTo(leftPx, 0); ctx.lineTo(leftPx, h);
ctx.moveTo(rightPx, 0); ctx.lineTo(rightPx, h); ctx.moveTo(rightPx, 0); ctx.lineTo(rightPx, h);
ctx.stroke(); ctx.stroke();
} }
// Draw playhead // Draw playhead
if (currentTime !== null && currentTime >= 0 && currentTime <= buffer.duration) { if (currentTime !== null && currentTime >= 0 && currentTime <= buffer.duration) {
const playheadPx = (currentTime / buffer.duration) * w; const playheadPx = currentTime * zoom;
ctx.strokeStyle = '#ef4444'; ctx.strokeStyle = '#ef4444';
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(playheadPx, 0); ctx.moveTo(playheadPx, 0);
ctx.lineTo(playheadPx, h); ctx.lineTo(playheadPx, h);
ctx.stroke(); ctx.stroke();
} }
}, [buffer, currentTime, selectionStart, selectionEnd]); }, [buffer, currentTime, selectionStart, selectionEnd, zoom, timelineWidth]);
const handleMouseDown = (e) => { const handleMouseDown = (e) => {
if (e.button === 2) return; // ignore right click if (e.button === 2) return; // ignore right click
@@ -972,8 +988,8 @@
const App = () => { const App = () => {
// ── State Definitions ── // ── State Definitions ──
const [tracks, setTracks] = useState([ const [tracks, setTracks] = useState([
{ id: '1', name: 'Track 01', buffer: null, startTime: 0, height: 96, volume: 0.8, muted: false, solo: false, color: '#0f766e', markers: [], serverFileId: null }, { id: '1', name: 'Track 01', buffer: null, startTime: 0, height: 96, volumeDb: 0, pan: 0, muted: false, solo: false, color: '#0f766e', markers: [], serverFileId: null },
{ id: '2', name: 'Track 02', buffer: null, startTime: 0, height: 96, volume: 0.8, muted: false, solo: false, color: '#1d4ed8', markers: [], serverFileId: null }, { id: '2', name: 'Track 02', buffer: null, startTime: 0, height: 96, volumeDb: 0, pan: 0, muted: false, solo: false, color: '#1d4ed8', markers: [], serverFileId: null },
]); ]);
const [bpm, setBpm] = useState(localStorage.getItem('studio_bpm') || '120'); const [bpm, setBpm] = useState(localStorage.getItem('studio_bpm') || '120');
const [draggedClip, setDraggedClip] = useState(null); // { trackId, clickOffset, buffer, name, volume, color } const [draggedClip, setDraggedClip] = useState(null); // { trackId, clickOffset, buffer, name, volume, color }
@@ -1012,6 +1028,9 @@
const [localSelectionEnd, setLocalSelectionEnd] = useState(null); const [localSelectionEnd, setLocalSelectionEnd] = useState(null);
const [zoom, setZoom] = useState(100); const [zoom, setZoom] = useState(100);
const [isLoopingSelection, setIsLoopingSelection] = useState(false); const [isLoopingSelection, setIsLoopingSelection] = useState(false);
const [beginBar, setBeginBar] = useState(1);
const [endBar, setEndBar] = useState(1);
const [numberBar, setNumberBar] = useState(1);
const [isExporting, setIsExporting] = useState(false); const [isExporting, setIsExporting] = useState(false);
const [soloedTrackId, setSoloedTrackId] = useState(null); const [soloedTrackId, setSoloedTrackId] = useState(null);
const [toastMessage, setToastMessage] = useState(null); const [toastMessage, setToastMessage] = useState(null);
@@ -1019,7 +1038,7 @@
const [showExportPanel, setShowExportPanel] = useState(true); const [showExportPanel, setShowExportPanel] = useState(true);
const [showAIPanel, setShowAIPanel] = useState(true); const [showAIPanel, setShowAIPanel] = useState(true);
const [showSelectionPanel, setShowSelectionPanel] = useState(true); const [showSelectionPanel, setShowSelectionPanel] = useState(true);
const [panelPositions, setPanelPositions] = useState({ export: 'right', ai: 'right', selection: 'right' }); const [panelPositions, setPanelPositions] = useState({ export: 'bottom', ai: 'bottom', selection: 'right' });
const [panelDropZone, setPanelDropZone] = useState(null); const [panelDropZone, setPanelDropZone] = useState(null);
const [dragGhostPos, setDragGhostPos] = useState(null); const [dragGhostPos, setDragGhostPos] = useState(null);
const [dragGhostPanel, setDragGhostPanel] = useState(null); const [dragGhostPanel, setDragGhostPanel] = useState(null);
@@ -1044,6 +1063,8 @@
const [menuOpen, setMenuOpen] = useState(null); const [menuOpen, setMenuOpen] = useState(null);
const [selectedClipId, setSelectedClipId] = useState(null); // { trackId, clipId } const [selectedClipId, setSelectedClipId] = useState(null); // { trackId, clipId }
const [stretchedClip, setStretchedClip] = useState(null); // { trackId, clipId, originalDuration, startTime, originalSpeed, beforeSnap } const [stretchedClip, setStretchedClip] = useState(null); // { trackId, clipId, originalDuration, startTime, originalSpeed, beforeSnap }
const [editingTrackName, setEditingTrackName] = useState(null); // trackId being edited
const [editNameInput, setEditNameInput] = useState('');
// ── Context Menu & Clipboard ── // ── Context Menu & Clipboard ──
const [contextMenu, setContextMenu] = useState(null); // { x, y, trackId } const [contextMenu, setContextMenu] = useState(null); // { x, y, trackId }
@@ -1099,7 +1120,8 @@
const track = tracks.find(t => t.id === trackId); const track = tracks.find(t => t.id === trackId);
if (!track) return null; if (!track) return null;
return { return {
volume: track.volume, volumeDb: track.volumeDb,
pan: track.pan,
muted: track.muted, muted: track.muted,
name: track.name, name: track.name,
markers: JSON.parse(JSON.stringify(track.markers || [])), markers: JSON.parse(JSON.stringify(track.markers || [])),
@@ -1202,6 +1224,7 @@
}; };
const rulerRef = useRef(null); const rulerRef = useRef(null);
const activeSourcesRef = useRef([]); const activeSourcesRef = useRef([]);
const activeTrackNodesRef = useRef({}); // { [trackId]: { gainNode, pannerNode } }
const startOffsetTimeRef = useRef(0); const startOffsetTimeRef = useRef(0);
const startAudioTimeRef = useRef(0); const startAudioTimeRef = useRef(0);
const animationFrameIdRef = useRef(null); const animationFrameIdRef = useRef(null);
@@ -1468,15 +1491,21 @@
showToast(`Đã lặp vùng chọn ${loopCount} lần.`, 'success'); showToast(`Đã lặp vùng chọn ${loopCount} lần.`, 'success');
}; };
useEffect(() => { useEffect(() => {
const handler = (e) => { const handler = (e) => {
const ctrl = e.ctrlKey || e.metaKey; const ctrl = e.ctrlKey || e.metaKey;
const alt = e.altKey; const alt = e.altKey;
// Global space play/pause shortcut for transport
if (e.key === ' ' || e.code === 'Space') {
e.preventDefault();
if (handlePlayPauseRef.current) handlePlayPauseRef.current();
return;
}
if (activeTabRef.current !== 'main') { if (activeTabRef.current !== 'main') {
// Sub-Tab keyboard shortcuts mapping // Sub-Tab keyboard shortcuts mapping
const curTabId = activeTabRef.current; const curTabId = activeTabRef.current;
if (e.key === ' ' || e.code === 'Space') { e.preventDefault(); if (handlePlayPauseRef.current) handlePlayPauseRef.current(); return; }
if (ctrl && alt && e.key === 'n') { e.preventDefault(); handleSubTabNormalize(curTabId); return; } if (ctrl && alt && e.key === 'n') { e.preventDefault(); handleSubTabNormalize(curTabId); return; }
if (e.key === 'f' || e.key === 'F') { e.preventDefault(); handleSubTabFade(curTabId, 'in'); return; } if (e.key === 'f' || e.key === 'F') { e.preventDefault(); handleSubTabFade(curTabId, 'in'); return; }
if (e.key === 'g' || e.key === 'G') { e.preventDefault(); handleSubTabFade(curTabId, 'out'); return; } if (e.key === 'g' || e.key === 'G') { e.preventDefault(); handleSubTabFade(curTabId, 'out'); return; }
@@ -1491,7 +1520,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, startTime:0, height:96, volume:0.8, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, startTime:0, height:96, volume:0.8, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','info'); return; } if (ctrl && !alt && e.key === 'n') { e.preventDefault(); setTracks([{ id:'1', name:'Track 01', buffer:null, startTime:0, height:96, volumeDb:0, pan:0, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, startTime:0, height:96, volumeDb:0, pan:0, 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; }
@@ -1943,7 +1972,7 @@
const ctx = getAudioContext(); const ctx = getAudioContext();
const clipBuffer = ctx.createBuffer(1, len, sr); const clipBuffer = ctx.createBuffer(1, len, sr);
clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0);
clipboardRef.current = { buffer: clipBuffer, name: track.name, volume: track.volume, color: track.color }; clipboardRef.current = { buffer: clipBuffer, name: track.name, volumeDb: track.volumeDb, pan: track.pan, color: track.color };
closeContextMenu(); closeContextMenu();
showToast('Đã sao chép vùng chọn.', 'info'); showToast('Đã sao chép vùng chọn.', 'info');
return; return;
@@ -1954,7 +1983,8 @@
clipboardRef.current = { clipboardRef.current = {
buffer: track.buffer, buffer: track.buffer,
name: track.name, name: track.name,
volume: track.volume, volumeDb: track.volumeDb,
pan: track.pan,
color: track.color, color: track.color,
}; };
closeContextMenu(); closeContextMenu();
@@ -1978,7 +2008,7 @@
const ctx = getAudioContext(); const ctx = getAudioContext();
const clipBuffer = ctx.createBuffer(1, len, sr); const clipBuffer = ctx.createBuffer(1, len, sr);
clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0);
clipboardRef.current = { buffer: clipBuffer, name: t.name, volume: t.volume, color: t.color }; clipboardRef.current = { buffer: clipBuffer, name: t.name, volumeDb: t.volumeDb, color: t.color };
const newLen = data.length - len; const newLen = data.length - len;
const newBuffer = ctx.createBuffer(1, newLen, sr); const newBuffer = ctx.createBuffer(1, newLen, sr);
const newData = newBuffer.getChannelData(0); const newData = newBuffer.getChannelData(0);
@@ -2002,7 +2032,7 @@
showToast('Clipboard trống.', 'warning'); showToast('Clipboard trống.', 'warning');
return null; return null;
} }
const { buffer: clipBuffer, name, volume, color } = clipboardRef.current; const { buffer: clipBuffer, name, volumeDb, pan, color } = clipboardRef.current;
const ctx = getAudioContext(); const ctx = getAudioContext();
const targetTrack = tracks.find(t => t.id === targetTrackId); const targetTrack = tracks.find(t => t.id === targetTrackId);
@@ -2031,7 +2061,8 @@
buffer: updatedClips[0].buffer, buffer: updatedClips[0].buffer,
startTime: updatedClips[0].startTime, startTime: updatedClips[0].startTime,
name: name || t.name, name: name || t.name,
volume: volume || t.volume, volumeDb: volumeDb ?? t.volumeDb,
pan: pan ?? t.pan,
color: color || t.color color: color || t.color
}; };
} }
@@ -2051,7 +2082,7 @@
buffer: clipBuffer, buffer: clipBuffer,
startTime: pasteTime, startTime: pasteTime,
clips: [newClip], clips: [newClip],
volume: volume || 0.8, muted: false, solo: false, volumeDb: volumeDb ?? 0, pan: pan ?? 0, muted: false, solo: false,
color: color || colors[prev.length % colors.length], color: color || colors[prev.length % colors.length],
markers: [], serverFileId: null, markers: [], serverFileId: null,
}]); }]);
@@ -2083,9 +2114,10 @@
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); const startSample = Math.floor((t.startTime || 0) * sr);
const volLinear = (t.volumeDb ?? 0) <= -50 ? 0 : Math.pow(10, (t.volumeDb ?? 0) / 20);
for (let i = 0; i < data.length; i++) { for (let i = 0; i < data.length; i++) {
if (startSample + i < mergedData.length) { if (startSample + i < mergedData.length) {
mergedData[startSample + i] += data[i] * t.volume; mergedData[startSample + i] += data[i] * volLinear;
} }
} }
}); });
@@ -2102,7 +2134,7 @@
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, startTime: 0, id: newId, name: `Merged_${names}.wav`, buffer: merged, startTime: 0,
volume: 0.8, muted: false, solo: false, volumeDb: 0, pan: 0, muted: false, solo: false,
color: colors[prev.length % colors.length], markers: [], serverFileId: null, color: colors[prev.length % colors.length], markers: [], serverFileId: null,
}]); }]);
setSelectedTrackId(newId); setSelectedTrackId(newId);
@@ -2122,15 +2154,16 @@
at.forEach(t => { at.forEach(t => {
const d = t.buffer.getChannelData(0); const d = t.buffer.getChannelData(0);
const startSample = Math.floor((t.startTime || 0) * sr); const startSample = Math.floor((t.startTime || 0) * sr);
const volLinear = (t.volumeDb ?? 0) <= -50 ? 0 : Math.pow(10, (t.volumeDb ?? 0) / 20);
for (let i = 0; i < d.length; i++) { for (let i = 0; i < d.length; i++) {
if (startSample + i < mdata.length) { if (startSample + i < mdata.length) {
mdata[startSample + i] += d[i] * t.volume; mdata[startSample + i] += d[i] * volLinear;
} }
} }
}); });
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, startTime: 0, height: 96, 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, height: 96, volumeDb:0, pan:0, 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 = () => {
@@ -2150,13 +2183,13 @@
const ctx = getAudioContext(); const ctx = getAudioContext();
const clipBuffer = ctx.createBuffer(1, len, sr); const clipBuffer = ctx.createBuffer(1, len, sr);
clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0);
clipboardRef.current = { buffer: clipBuffer, name: t.name, volume: t.volume, color: t.color }; clipboardRef.current = { buffer: clipBuffer, name: t.name, volumeDb: t.volumeDb, color: t.color };
showToast('Copied selection to clipboard.', 'info'); showToast('Copied selection to clipboard.', 'info');
return; return;
} }
} }
// No selection: copy entire track // No selection: copy entire track
clipboardRef.current = { buffer: t.buffer, name: t.name, volume: t.volume, color: t.color }; clipboardRef.current = { buffer: t.buffer, name: t.name, volumeDb: t.volumeDb, color: t.color };
showToast('Copied track to clipboard.', 'info'); showToast('Copied track to clipboard.', 'info');
}; };
const handleCutTrack = () => { const handleCutTrack = () => {
@@ -2178,7 +2211,7 @@
const ctx = getAudioContext(); const ctx = getAudioContext();
const clipBuffer = ctx.createBuffer(1, len, sr); const clipBuffer = ctx.createBuffer(1, len, sr);
clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0);
clipboardRef.current = { buffer: clipBuffer, name: t.name, volume: t.volume, color: t.color }; clipboardRef.current = { buffer: clipBuffer, name: t.name, volumeDb: t.volumeDb, color: t.color };
// Remove selection from track, glue the two remaining parts // Remove selection from track, glue the two remaining parts
const newLen = data.length - len; const newLen = data.length - len;
const newBuffer = ctx.createBuffer(1, newLen, sr); const newBuffer = ctx.createBuffer(1, newLen, sr);
@@ -2374,6 +2407,51 @@
}, [zoom, minZoom, maxDuration, activeTab]); }, [zoom, minZoom, maxDuration, activeTab]);
// ── Update Playhead ── // ── Update Playhead ──
const startSubTabPlayback = (st, offsetTime) => {
const context = getAudioContext();
const eff = st.buffer.getChannelData(0);
const edBuffer = context.createBuffer(1, eff.length, st.buffer.sampleRate);
const edData = edBuffer.getChannelData(0);
edData.set(eff);
const fx = st.effects || {};
if (fx.reverse) {
const reversed = new Float32Array(edData);
for (let i = 0; i < edData.length; i++) reversed[i] = edData[edData.length - 1 - i];
edBuffer.copyToChannel(reversed, 0);
}
if (fx.gainDb !== 0) {
const gain = Math.pow(10, fx.gainDb / 20);
for (let i = 0; i < edData.length; i++) edData[i] = Math.max(-1, Math.min(1, edData[i] * gain));
}
if (fx.fadeInMs > 0) {
const sr = edBuffer.sampleRate;
const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeInMs / 1000 * sr));
for (let i = 0; i < fadeSamples; i++) edData[i] = edData[i] * (i / fadeSamples);
}
if (fx.fadeOutMs > 0) {
const sr = edBuffer.sampleRate;
const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeOutMs / 1000 * sr));
for (let i = edData.length - fadeSamples; i < edData.length; i++) {
edData[i] = edData[i] * ((edData.length - 1 - i) / fadeSamples);
}
}
const source = context.createBufferSource();
source.buffer = edBuffer;
const gainNode = context.createGain();
gainNode.gain.setValueAtTime(1.0, context.currentTime);
source.connect(gainNode);
gainNode.connect(context.destination);
source.start(context.currentTime, offsetTime);
activeSourcesRef.current = [source];
startOffsetTimeRef.current = offsetTime;
startAudioTimeRef.current = context.currentTime;
};
const updatePlayhead = () => { const updatePlayhead = () => {
if (activeTab !== 'main') { if (activeTab !== 'main') {
const st = subTabs.find(s => s.id === activeTab); const st = subTabs.find(s => s.id === activeTab);
@@ -2393,40 +2471,7 @@
currentTime: start, currentTime: start,
isPlaying: true isPlaying: true
} : s)); } : s));
// Restart subtab buffer segment startSubTabPlayback(st, start);
const eff = st.buffer.getChannelData(0);
const edBuffer = context.createBuffer(1, eff.length, st.buffer.sampleRate);
const edData = edBuffer.getChannelData(0);
edData.set(eff);
const fx = st.effects || {};
if (fx.reverse) {
const reversed = new Float32Array(edData);
for (let i = 0; i < edData.length; i++) reversed[i] = edData[edData.length - 1 - i];
edBuffer.copyToChannel(reversed, 0);
}
if (fx.gainDb !== 0) {
const gain = Math.pow(10, fx.gainDb / 20);
for (let i = 0; i < edData.length; i++) edData[i] = Math.max(-1, Math.min(1, edData[i] * gain));
}
if (fx.fadeInMs > 0) {
const sr = edBuffer.sampleRate;
const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeInMs / 1000 * sr));
for (let i = 0; i < fadeSamples; i++) edData[i] = edData[i] * (i / fadeSamples);
}
if (fx.fadeOutMs > 0) {
const sr = edBuffer.sampleRate;
const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeOutMs / 1000 * sr));
for (let i = edData.length - fadeSamples; i < edData.length; i++) {
edData[i] = edData[i] * ((edData.length - 1 - i) / fadeSamples);
}
}
const source = context.createBufferSource();
source.buffer = edBuffer;
source.connect(context.destination);
source.start(context.currentTime, start);
activeSourcesRef.current = [source];
startOffsetTimeRef.current = start;
startAudioTimeRef.current = context.currentTime;
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
return; return;
} }
@@ -2434,7 +2479,17 @@
if (updatedTime >= st.buffer.duration) { if (updatedTime >= st.buffer.duration) {
stopAllPlayback(); stopAllPlayback();
setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, currentTime: 0, isPlaying: false } : s)); if (isLoopingSelection) {
setSubTabs(prev => prev.map(s => s.id === activeTab ? {
...s,
currentTime: 0,
isPlaying: true
} : s));
startSubTabPlayback(st, 0);
animationFrameIdRef.current = requestAnimationFrame(updatePlayhead);
} else {
setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, currentTime: 0, isPlaying: false } : s));
}
return; return;
} }
@@ -2504,6 +2559,18 @@
if (!isPlayable) return; if (!isPlayable) return;
// Create persistent gain & panner per track for real-time control
const gainNode = context.createGain();
const volDb = track.volumeDb ?? 0;
const volLinear = volDb <= -50 ? 0 : Math.pow(10, volDb / 20);
gainNode.gain.setValueAtTime(volLinear, context.currentTime);
const pannerNode = context.createStereoPanner();
pannerNode.pan.setValueAtTime((track.pan ?? 0) / 100, context.currentTime);
pannerNode.connect(context.destination);
activeTrackNodesRef.current[track.id] = { gainNode, pannerNode };
const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{
id: 'default', id: 'default',
buffer: track.buffer, buffer: track.buffer,
@@ -2519,11 +2586,8 @@
source.buffer = clip.buffer; source.buffer = clip.buffer;
source.playbackRate.value = clip.speed || 1.0; source.playbackRate.value = clip.speed || 1.0;
const gainNode = context.createGain();
gainNode.gain.setValueAtTime(track.volume, context.currentTime);
source.connect(gainNode); source.connect(gainNode);
gainNode.connect(context.destination); gainNode.connect(pannerNode);
const clipStart = clip.startTime || 0; const clipStart = clip.startTime || 0;
const clipDuration = clip.buffer.duration / (clip.speed || 1.0); const clipDuration = clip.buffer.duration / (clip.speed || 1.0);
@@ -2556,6 +2620,18 @@
speed: track.speed || 1.0 speed: track.speed || 1.0
}] : []); }] : []);
// Create persistent gain & panner for real-time control
const gainNode = context.createGain();
const volDb = track.volumeDb ?? 0;
const volLinear = volDb <= -50 ? 0 : Math.pow(10, volDb / 20);
gainNode.gain.setValueAtTime(volLinear, context.currentTime);
const pannerNode = context.createStereoPanner();
pannerNode.pan.setValueAtTime((track.pan ?? 0) / 100, context.currentTime);
pannerNode.connect(context.destination);
activeTrackNodesRef.current[trackId] = { gainNode, pannerNode };
clips.forEach(clip => { clips.forEach(clip => {
if (!clip.buffer) return; if (!clip.buffer) return;
@@ -2563,11 +2639,8 @@
source.buffer = clip.buffer; source.buffer = clip.buffer;
source.playbackRate.value = clip.speed || 1.0; source.playbackRate.value = clip.speed || 1.0;
const gainNode = context.createGain();
gainNode.gain.setValueAtTime(track.volume, context.currentTime);
source.connect(gainNode); source.connect(gainNode);
gainNode.connect(context.destination); gainNode.connect(pannerNode);
const clipStart = clip.startTime || 0; const clipStart = clip.startTime || 0;
const clipDuration = clip.buffer.duration / (clip.speed || 1.0); const clipDuration = clip.buffer.duration / (clip.speed || 1.0);
@@ -2590,58 +2663,14 @@
// Sub-tab playback transport // Sub-tab playback transport
const st = subTabs.find(s => s.id === activeTab); const st = subTabs.find(s => s.id === activeTab);
if (!st || !st.buffer) return; if (!st || !st.buffer) return;
const context = getAudioContext();
if (st.isPlaying) { if (st.isPlaying) {
stopAllPlayback(); stopAllPlayback();
setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, isPlaying: false } : s)); setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, isPlaying: false } : s));
} else { } else {
stopAllPlayback(); stopAllPlayback();
// Clone buffer and apply effects for listening
const eff = st.buffer.getChannelData(0);
const edBuffer = context.createBuffer(1, eff.length, st.buffer.sampleRate);
const edData = edBuffer.getChannelData(0);
edData.set(eff);
const fx = st.effects || {};
if (fx.reverse) {
const reversed = new Float32Array(edData);
for (let i = 0; i < edData.length; i++) reversed[i] = edData[edData.length - 1 - i];
edBuffer.copyToChannel(reversed, 0);
}
if (fx.gainDb !== 0) {
const gain = Math.pow(10, fx.gainDb / 20);
for (let i = 0; i < edData.length; i++) edData[i] = Math.max(-1, Math.min(1, edData[i] * gain));
}
if (fx.fadeInMs > 0) {
const sr = edBuffer.sampleRate;
const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeInMs / 1000 * sr));
for (let i = 0; i < fadeSamples; i++) edData[i] = edData[i] * (i / fadeSamples);
}
if (fx.fadeOutMs > 0) {
const sr = edBuffer.sampleRate;
const fadeSamples = Math.min(edData.length, Math.floor(fx.fadeOutMs / 1000 * sr));
for (let i = edData.length - fadeSamples; i < edData.length; i++) {
edData[i] = edData[i] * ((edData.length - 1 - i) / fadeSamples);
}
}
const source = context.createBufferSource();
source.buffer = edBuffer;
const gainNode = context.createGain();
gainNode.gain.setValueAtTime(1.0, context.currentTime);
source.connect(gainNode);
gainNode.connect(context.destination);
const startOffset = st.currentTime || 0; const startOffset = st.currentTime || 0;
source.start(context.currentTime, startOffset); startSubTabPlayback(st, startOffset);
activeSourcesRef.current = [source];
startOffsetTimeRef.current = startOffset;
startAudioTimeRef.current = context.currentTime;
setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, isPlaying: true } : s)); setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, isPlaying: true } : s));
} }
return; return;
@@ -2667,6 +2696,7 @@
try { src.stop(); } catch(e) {} try { src.stop(); } catch(e) {}
}); });
activeSourcesRef.current = []; activeSourcesRef.current = [];
activeTrackNodesRef.current = {};
setIsPlaying(false); setIsPlaying(false);
setSubTabs(prev => prev.map(s => ({ ...s, isPlaying: false }))); setSubTabs(prev => prev.map(s => ({ ...s, isPlaying: false })));
}; };
@@ -3249,9 +3279,9 @@
}); });
}; };
const updateTrackVolume = (trackId, val) => { const updateTrackVolumeDb = (trackId, val) => {
const beforeSnap = captureTrackSnapshot(trackId); const beforeSnap = captureTrackSnapshot(trackId);
setTracks(prev => prev.map(t => t.id === trackId ? { ...t, volume: val } : t)); setTracks(prev => prev.map(t => t.id === trackId ? { ...t, volumeDb: val } : t));
setUndoStack(prev => { setUndoStack(prev => {
const next = [...prev, { const next = [...prev, {
action_type: 'VOLUME_CHANGE', action_type: 'VOLUME_CHANGE',
@@ -3263,6 +3293,65 @@
if (next.length > MAX_UNDO) next.shift(); if (next.length > MAX_UNDO) next.shift();
return next; return next;
}); });
// Real-time update during playback
const nodes = activeTrackNodesRef.current[trackId];
if (nodes) {
const volLinear = val <= -50 ? 0 : Math.pow(10, val / 20);
nodes.gainNode.gain.setValueAtTime(volLinear, getAudioContext().currentTime);
}
};
const updateTrackPan = (trackId, val) => {
const beforeSnap = captureTrackSnapshot(trackId);
setTracks(prev => prev.map(t => t.id === trackId ? { ...t, pan: val } : t));
setUndoStack(prev => {
const next = [...prev, {
action_type: 'PAN_CHANGE',
track_id: trackId,
timestamp: Date.now(),
before_state: beforeSnap,
after_state: captureTrackSnapshot(trackId),
}];
if (next.length > MAX_UNDO) next.shift();
return next;
});
// Real-time update during playback
const nodes = activeTrackNodesRef.current[trackId];
if (nodes) {
nodes.pannerNode.pan.setValueAtTime(val / 100, getAudioContext().currentTime);
}
};
const updateTrackName = (trackId, name) => {
const beforeSnap = captureTrackSnapshot(trackId);
setTracks(prev => prev.map(t => t.id === trackId ? { ...t, name } : t));
setUndoStack(prev => {
const next = [...prev, {
action_type: 'RENAME',
track_id: trackId,
timestamp: Date.now(),
before_state: beforeSnap,
after_state: captureTrackSnapshot(trackId),
}];
if (next.length > MAX_UNDO) next.shift();
return next;
});
};
const updateTrackColor = (trackId, color) => {
const beforeSnap = captureTrackSnapshot(trackId);
setTracks(prev => prev.map(t => t.id === trackId ? { ...t, color } : t));
setUndoStack(prev => {
const next = [...prev, {
action_type: 'RECOLOR',
track_id: trackId,
timestamp: Date.now(),
before_state: beforeSnap,
after_state: captureTrackSnapshot(trackId),
}];
if (next.length > MAX_UNDO) next.shift();
return next;
});
}; };
// ── Load File on Track (with server upload) ── // ── Load File on Track (with server upload) ──
@@ -3334,11 +3423,11 @@
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, startTime: 0, height: 96, id: newId, name: `Track ${newId}`, buffer: null, startTime: 0, height: 96,
volume: 0.8, muted: false, solo: false, volumeDb: 0, pan: 0, 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);
return newId; return newId;
@@ -3365,7 +3454,7 @@
const tracksMeta = activeTracks.map(t => ({ const tracksMeta = activeTracks.map(t => ({
track_id: t.id, track_id: t.id,
file_id: serverFileIdMap[t.id], file_id: serverFileIdMap[t.id],
volume: t.volume, volume_db: t.volumeDb,
muted: false, muted: false,
clips: [{ clips: [{
clip_id: `clip_${t.id}`, clip_id: `clip_${t.id}`,
@@ -3465,10 +3554,16 @@
source.playbackRate.value = clip.speed || 1.0; source.playbackRate.value = clip.speed || 1.0;
const gain = offlineCtx.createGain(); const gain = offlineCtx.createGain();
gain.gain.setValueAtTime(t.volume, 0); const volDb = t.volumeDb ?? 0;
const volLinear = volDb <= -50 ? 0 : Math.pow(10, volDb / 20);
gain.gain.setValueAtTime(volLinear, 0);
const panner = offlineCtx.createStereoPanner();
panner.pan.setValueAtTime((t.pan ?? 0) / 100, 0);
source.connect(gain); source.connect(gain);
gain.connect(offlineCtx.destination); gain.connect(panner);
panner.connect(offlineCtx.destination);
const clipStart = clip.startTime || 0; const clipStart = clip.startTime || 0;
source.start(clipStart); source.start(clipStart);
@@ -3731,7 +3826,8 @@
startTime: snapStart, startTime: snapStart,
name: `AI_Cut_${activeTrack.name.replace('.wav', '')}_${snapStart.toFixed(1)}s` name: `AI_Cut_${activeTrack.name.replace('.wav', '')}_${snapStart.toFixed(1)}s`
}], }],
volume: 0.8, volumeDb: 0,
pan: 0,
muted: false, muted: false,
solo: false, solo: false,
color: selectColor, color: selectColor,
@@ -3945,7 +4041,7 @@
<header className="h-9 bg-[#2e2e2e] border-b border-[#181818] flex items-center px-1 shrink-0 select-none"> <header className="h-9 bg-[#2e2e2e] border-b border-[#181818] flex items-center px-1 shrink-0 select-none">
{[ {[
{ label: 'File', items: [ { label: 'File', items: [
{ label: 'New Project', icon: 'file-plus', shortcut: 'Ctrl+N', action: () => { setTracks([{ id:'1', name:'Track 01', buffer:null, startTime:0, height:96, volume:0.8, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, startTime:0, height:96, volume:0.8, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','info'); } }, { label: 'New Project', icon: 'file-plus', shortcut: 'Ctrl+N', action: () => { setTracks([{ id:'1', name:'Track 01', buffer:null, startTime:0, height:96, volumeDb:0, pan:0, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, startTime:0, height:96, volumeDb:0, pan:0, muted:false, solo:false, color:'#1d4ed8', markers:[], serverFileId:null }]); setSelectedTrackId('1'); showToast('New project created','info'); } },
{ label: 'Open Project...', icon: 'folder-open', shortcut: 'Ctrl+O', action: () => showToast('Open project dialog','info') }, { label: 'Open Project...', icon: 'folder-open', shortcut: 'Ctrl+O', action: () => showToast('Open project dialog','info') },
{ label: 'Save Project', icon: 'save', shortcut: 'Ctrl+S', action: () => showToast('Project saved','success') }, { label: 'Save Project', icon: 'save', shortcut: 'Ctrl+S', action: () => showToast('Project saved','success') },
{ label: 'Save As...', icon: 'save', shortcut: 'Ctrl+Alt+S', action: () => showToast('Save as dialog','info') }, { label: 'Save As...', icon: 'save', shortcut: 'Ctrl+Alt+S', action: () => showToast('Save as dialog','info') },
@@ -4105,65 +4201,67 @@
{/* ── Transport Bar + Toolbar Overlay ── */} {/* ── Transport Bar + Toolbar Overlay ── */}
<div className="h-9 bg-[#222] border-b border-zinc-800 flex items-center px-3 gap-2 shrink-0 select-none"> <div className="h-9 bg-[#222] border-b border-zinc-800 flex items-center px-3 gap-2 shrink-0 select-none">
{/* Toolbar Overlay Panel — có thể kéo thả hoặc ghim sau này */} {/* Toolbar Overlay Panel */}
<div className="flex items-center gap-1 bg-zinc-900 border border-zinc-700 rounded px-1.5 py-1 shadow-lg h-7 cursor-grab active:cursor-grabbing" <div className="flex items-center gap-1 bg-zinc-900 border border-zinc-700 rounded px-1.5 py-0.5 shadow-lg"
title="Kéo để di chuyển toolbar" style={{ cursor: 'grab' }}> title="Kéo để di chuyển toolbar" style={{ cursor: 'grab' }}>
<div className="flex items-center gap-0.5 mr-1 text-zinc-600"> <div className="flex items-center gap-0.5 mr-1 text-zinc-600">
<i data-lucide="grip-vertical" className="w-3 h-3"></i> <i data-lucide="grip-vertical" className="w-3 h-3"></i>
</div> </div>
<button onClick={() => { setActiveTool('select'); showToast('Select Tool', 'info'); }} <button onClick={() => { setActiveTool('select'); showToast('Select Tool', 'info'); }}
className={`p-0.5 rounded ${activeTool === 'select' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`} className={`w-7 h-7 flex items-center justify-center rounded ${activeTool === 'select' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`}
title="Select Tool"> title="Select Tool (V)">
<i data-lucide="mouse-pointer" className="w-3.5 h-3.5"></i> <i data-lucide="mouse-pointer" className="w-3.5 h-3.5"></i>
</button> </button>
<button onClick={() => { setActiveTool('grab'); showToast('Grab Tool', 'info'); }} <button onClick={() => { setActiveTool('grab'); showToast('Grab Tool', 'info'); }}
className={`p-0.5 rounded ${activeTool === 'grab' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`} className={`w-7 h-7 flex items-center justify-center rounded ${activeTool === 'grab' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`}
title="Grab Tool"> title="Grab Tool (H)">
<i data-lucide="hand" className="w-3.5 h-3.5"></i> <i data-lucide="hand" className="w-3.5 h-3.5"></i>
</button> </button>
<button onClick={() => { setActiveTool('razor'); showToast('Razor Tool', 'info'); }} <div className="flex items-center gap-0.5 border border-zinc-700 rounded bg-zinc-850 px-0.5">
className={`p-0.5 rounded ${activeTool === 'razor' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`} <button onClick={() => { setActiveTool('razor'); showToast('Razor Tool', 'info'); }}
title="Razor Tool"> className={`w-7 h-7 flex items-center justify-center rounded ${activeTool === 'razor' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`}
<svg className="w-3.5 h-3.5 text-orange-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> title="Razor Tool (C)">
<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"/> <svg className="w-3.5 h-3.5 text-orange-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 9h16l-3 9H7z"/> <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"/>
<circle cx="12" cy="6" r="1"/> <path d="M4 9h16l-3 9H7z"/>
</svg> <circle cx="12" cy="6" r="1"/>
</button> </svg>
</button>
<button onClick={handleGlueTracks}
className="w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-purple-400 hover:bg-zinc-800"
title="Glue Clips">
<i data-lucide="link" className="w-3.5 h-3.5"></i>
</button>
</div>
<button onClick={() => { setActiveTool('pen'); showToast('Pen Tool', 'info'); }} <button onClick={() => { setActiveTool('pen'); showToast('Pen Tool', 'info'); }}
className={`p-0.5 rounded ${activeTool === 'pen' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`} className={`w-7 h-7 flex items-center justify-center rounded ${activeTool === 'pen' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`}
title="Pen Tool"> title="Pen Tool (P)">
<i data-lucide="pen-tool" className="w-3.5 h-3.5"></i> <i data-lucide="pen-tool" className="w-3.5 h-3.5"></i>
</button> </button>
<div className="w-[1px] h-4 bg-zinc-700 mx-0.5"></div> <div className="w-[1px] h-5 bg-zinc-700 mx-0.5"></div>
<button onClick={handleGlueTracks}
className="p-0.5 rounded text-zinc-400 hover:text-purple-400 hover:bg-zinc-800"
title="Glue Clips">
<i data-lucide="link" className="w-3.5 h-3.5"></i>
</button>
<button onClick={handleCutTrack} <button onClick={handleCutTrack}
className="p-0.5 rounded text-zinc-400 hover:text-red-400 hover:bg-zinc-800" className="w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-red-400 hover:bg-zinc-800"
title="Cut (Ctrl+X)"> title="Cut (Ctrl+X)">
<i data-lucide="scissors" className="w-3.5 h-3.5"></i> <i data-lucide="scissors" className="w-3.5 h-3.5"></i>
</button> </button>
<button onClick={handleCopyTrack} <button onClick={handleCopyTrack}
className="p-0.5 rounded text-zinc-400 hover:text-blue-400 hover:bg-zinc-800" className="w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-blue-400 hover:bg-zinc-800"
title="Copy (Ctrl+C)"> title="Copy (Ctrl+C)">
<i data-lucide="copy" className="w-3.5 h-3.5"></i> <i data-lucide="copy" className="w-3.5 h-3.5"></i>
</button> </button>
<button onClick={handlePasteTrack} <button onClick={handlePasteTrack}
className="p-0.5 rounded text-zinc-400 hover:text-emerald-400 hover:bg-zinc-800" className="w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-emerald-400 hover:bg-zinc-800"
title="Paste (Ctrl+V)"> title="Paste (Ctrl+V)">
<i data-lucide="clipboard" className="w-3.5 h-3.5"></i> <i data-lucide="clipboard" className="w-3.5 h-3.5"></i>
</button> </button>
<div className="w-[1px] h-4 bg-zinc-700 mx-0.5"></div> <div className="w-[1px] h-5 bg-zinc-700 mx-0.5"></div>
<button onClick={handleUndo} disabled={undoStack.length === 0} <button onClick={handleUndo} disabled={undoStack.length === 0}
className="p-0.5 rounded text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 disabled:opacity-30" className="w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 disabled:opacity-30"
title="Undo (Ctrl+Z)"> title="Undo (Ctrl+Z)">
<i data-lucide="undo" className="w-3.5 h-3.5"></i> <i data-lucide="undo" className="w-3.5 h-3.5"></i>
</button> </button>
<button onClick={handleRedo} disabled={redoStack.length === 0} <button onClick={handleRedo} disabled={redoStack.length === 0}
className="p-0.5 rounded text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 disabled:opacity-30" className="w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 disabled:opacity-30"
title="Redo (Ctrl+Y)"> title="Redo (Ctrl+Y)">
<i data-lucide="redo" className="w-3.5 h-3.5"></i> <i data-lucide="redo" className="w-3.5 h-3.5"></i>
</button> </button>
@@ -4204,6 +4302,41 @@
}`} title="Bật/Tắt Lặp vùng chọn"> }`} title="Bật/Tắt Lặp vùng chọn">
<i data-lucide="repeat" className="w-3.5 h-3.5"></i> <i data-lucide="repeat" className="w-3.5 h-3.5"></i>
</button> </button>
<span className="text-[9px] text-zinc-500 font-bold uppercase ml-2">Snap</span>
<select value={snapValue} onChange={e => setSnapValue(e.target.value)}
className="bg-black text-white text-[9px] px-1 py-0.5 rounded border border-zinc-700 focus:outline-none focus:border-cyan-500 font-mono cursor-pointer">
<option value="free">Free</option><option value="1">1</option>
<option value="1/2">1/2</option><option value="1/4">1/4</option>
<option value="1/8">1/8</option><option value="1/16">1/16</option><option value="1/32">1/32</option>
</select>
<div className="w-[1px] h-5 bg-zinc-800 mx-1"></div>
<span className="text-[9px] text-zinc-500 font-bold">Bars:</span>
<input type="number" min="1" value={beginBar} onChange={e => {
const b = parseInt(e.target.value) || 1;
setBeginBar(b);
const beatDuration = 60 / parseInt(bpm || 120);
const t = (b - 1) * beatDuration * 4;
setSelectionStart(t); setSelectionEnd(Math.max(t, selRight || t));
}} className="w-10 bg-black text-white text-[9px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" />
<span className="text-[9px] text-zinc-500">-</span>
<input type="number" min="1" value={endBar} onChange={e => {
const b = parseInt(e.target.value) || 1;
setEndBar(b);
const beatDuration = 60 / parseInt(bpm || 120);
const t = (b - 1) * beatDuration * 4;
setSelectionEnd(t);
setNumberBar(b - beginBar + 1);
}} className="w-10 bg-black text-white text-[9px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" />
<span className="text-[9px] text-zinc-500">#</span>
<input type="number" min="1" value={numberBar} readOnly
className="w-10 bg-black text-zinc-400 text-[9px] px-1 py-0.5 rounded border border-zinc-800 text-center font-mono" />
<div className="w-[1px] h-5 bg-zinc-800 mx-1"></div>
<span className="text-[9px] text-zinc-500">Start:</span>
<span className="text-[9px] font-mono text-zinc-200 w-14">{selLeft !== null ? formatTime(selLeft) : '--:--'}</span>
<span className="text-[9px] text-zinc-500">End:</span>
<span className="text-[9px] font-mono text-zinc-200 w-14">{selRight !== null ? formatTime(selRight) : '--:--'}</span>
<span className="text-[9px] text-zinc-500">Len:</span>
<span className="text-[9px] font-mono text-amber-400 w-14">{selLeft !== null && selRight !== null ? formatTime(Math.abs(selRight - selLeft)) : '--:--'}</span>
<div className="flex-1"></div> <div className="flex-1"></div>
<div className="text-xs font-bold font-mono text-zinc-100">{formatTime(currentTime)}</div> <div className="text-xs font-bold font-mono text-zinc-100">{formatTime(currentTime)}</div>
</div> </div>
@@ -4370,17 +4503,7 @@
onScroll={handleTCPScroll} onScroll={handleTCPScroll}
className="w-[300px] shrink-0 z-30 bg-[#262626] overflow-y-scroll no-scrollbar flex flex-col border-r border-zinc-900" className="w-[300px] shrink-0 z-30 bg-[#262626] overflow-y-scroll no-scrollbar flex flex-col border-r border-zinc-900"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}> style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
<div className="sticky top-0 z-50 flex items-center h-10 border-b border-zinc-900 bg-[#242424] px-2 justify-between shrink-0"> <div className="sticky top-0 z-50 flex items-center h-10 border-b border-zinc-900 bg-[#242424] px-2 shrink-0">
<span className="text-[10px] font-bold text-zinc-500 uppercase">Kênh</span>
<div className="flex items-center gap-1">
<span className="text-[9px] text-zinc-500 font-bold uppercase">Snap</span>
<select value={snapValue} onChange={e => setSnapValue(e.target.value)}
className="bg-zinc-850 text-zinc-300 text-[10px] px-1 py-0.5 rounded border border-zinc-800 focus:outline-none focus:border-cyan-550 font-mono">
<option value="free">Free</option><option value="1">1</option>
<option value="1/2">1/2</option><option value="1/4">1/4</option>
<option value="1/8">1/8</option><option value="1/16">1/16</option><option value="1/32">1/32</option>
</select>
</div>
</div> </div>
<div className="sticky top-10 z-40 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] p-2 border-l-4 border-purple-500 shrink-0"> <div className="sticky top-10 z-40 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] p-2 border-l-4 border-purple-500 shrink-0">
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
@@ -4406,8 +4529,24 @@
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-zinc-500 font-mono">{(idx+1).toString().padStart(2, '0')}</span> <span className="text-[10px] font-bold text-zinc-500 font-mono">{(idx+1).toString().padStart(2, '0')}</span>
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: track.color }} /> <label onClick={e => { e.stopPropagation(); const el = e.currentTarget.querySelector('input'); if (el) el.click(); }} className="cursor-pointer">
<span className="text-xs font-semibold text-zinc-300 truncate max-w-[100px]" title={track.name}>{track.name}</span> <input type="color" value={track.color || '#0f766e'} onChange={e => { e.stopPropagation(); updateTrackColor(track.id, e.target.value); }} className="w-0 h-0 opacity-0 absolute pointer-events-none" />
<div className="w-3 h-3 rounded-full border border-zinc-600 hover:scale-110 transition-transform" style={{ backgroundColor: track.color }} />
</label>
{editingTrackName === track.id ? (
<input type="text" value={editNameInput} autoFocus
onChange={e => setEditNameInput(e.target.value)}
onBlur={() => { updateTrackName(track.id, editNameInput || track.name); setEditingTrackName(null); }}
onKeyDown={e => { if (e.key === 'Enter') { updateTrackName(track.id, editNameInput || track.name); setEditingTrackName(null); } if (e.key === 'Escape') setEditingTrackName(null); }}
onClick={e => e.stopPropagation()}
className="text-xs font-semibold bg-black text-zinc-200 border border-cyan-500 rounded px-1 py-0 w-24 outline-none" />
) : (
<span className="text-xs font-semibold text-zinc-300 truncate max-w-[80px] cursor-text hover:text-cyan-300 transition-colors"
title="Click to rename"
onClick={e => { e.stopPropagation(); setEditingTrackName(track.id); setEditNameInput(track.name); }}>
{track.name}
</span>
)}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button onClick={e => { e.stopPropagation(); toggleTrackMute(track.id); }} <button onClick={e => { e.stopPropagation(); toggleTrackMute(track.id); }}
@@ -4418,20 +4557,23 @@
className="p-0.5 rounded text-red-500 hover:bg-red-950/40 hover:text-red-400 transition"><i data-lucide="trash-2" className="w-3.5 h-3.5"></i></button> className="p-0.5 rounded text-red-500 hover:bg-red-950/40 hover:text-red-400 transition"><i data-lucide="trash-2" className="w-3.5 h-3.5"></i></button>
</div> </div>
</div> </div>
<div className="flex items-center gap-1 text-[9px] text-zinc-400" onClick={e => e.stopPropagation()}> <div className="flex flex-col gap-0.5 text-[10px]" onClick={e => e.stopPropagation()}>
<span className="font-semibold uppercase text-[8px] text-zinc-500">Synth:</span> <div className="flex items-center gap-1">
<button onClick={() => generateSynthToTrack(track.id, 'kick')} className="px-1 bg-zinc-800 hover:bg-zinc-700 rounded border border-zinc-700">Kick</button> <span className="w-8 text-right text-zinc-500 text-[9px]">Vol:</span>
<button onClick={() => generateSynthToTrack(track.id, 'synth')} className="px-1 bg-zinc-800 hover:bg-zinc-700 rounded border border-zinc-700">Synth</button> <input type="range" min="-50" max="7" step="0.5" value={track.volumeDb ?? 0} onChange={e => updateTrackVolumeDb(track.id, parseFloat(e.target.value))} className="flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-cyan-500" style={{ height: '4px' }} />
<span className="w-12 text-right font-mono text-zinc-300 text-[9px]">{track.volumeDb ?? 0}dB</span>
</div>
<div className="flex items-center gap-1">
<span className="w-8 text-right text-zinc-500 text-[9px]">Pan:</span>
<input type="range" min="-100" max="100" step="1" value={track.pan ?? 0} onChange={e => updateTrackPan(track.id, parseInt(e.target.value))} className="flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-purple-500" style={{ height: '4px' }} />
<span className="w-12 text-right font-mono text-zinc-300 text-[9px]">{track.pan > 0 ? 'R' + track.pan : track.pan < 0 ? 'L' + Math.abs(track.pan) : 'C'}</span>
</div>
</div> </div>
<div className="flex items-center justify-between gap-2" onClick={e => e.stopPropagation()}> <div className="flex items-center gap-1.5 mt-1" onClick={e => e.stopPropagation()}>
<div className="flex items-center gap-1.5"> <input type="file" id={`upload-${track.id}`} accept="audio/*" className="hidden" onChange={e => loadFileOnTrack(track.id, e.target.files[0])} />
<VolumeKnob value={track.volume} onChange={v => updateTrackVolume(track.id, v)} /> <label htmlFor={`upload-${track.id}`} className="px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded text-[10px] flex items-center gap-1 cursor-pointer border border-zinc-700 font-medium"><i data-lucide="upload" className="w-3 h-3"></i> File</label>
<span className="text-[10px] font-mono text-zinc-500">Gain: {Math.round(track.volume * 100)}%</span> <button onClick={e => { e.stopPropagation(); showToast('FX panel for track ' + track.id, 'info'); }} className="px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-purple-400 border border-purple-900 rounded text-[10px] font-bold flex items-center gap-1"><i data-lucide="wand-2" className="w-3 h-3"></i> FX: <span className="text-zinc-500 font-normal">None</span></button>
</div> <button onClick={() => generateSynthToTrack(track.id, 'synth')} className="px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-amber-400 border border-amber-800 rounded text-[10px] font-bold flex items-center gap-1"><i data-lucide="music" className="w-3 h-3"></i> Synth: <span className="text-zinc-500 font-normal">None</span></button>
<div>
<input type="file" id={`upload-${track.id}`} accept="audio/*" className="hidden" onChange={e => loadFileOnTrack(track.id, e.target.files[0])} />
<label htmlFor={`upload-${track.id}`} className="px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded text-[10px] flex items-center gap-1 cursor-pointer border border-zinc-700"><i data-lucide="upload" className="w-3 h-3"></i> File</label>
</div>
</div> </div>
<div onMouseDown={e => handleTrackResizeMouseDown(e, track.id)} <div onMouseDown={e => handleTrackResizeMouseDown(e, track.id)}
className="absolute bottom-0 left-0 right-0 h-1 cursor-ns-resize z-30 hover:bg-cyan-500/50 transition-colors" onClick={e => e.stopPropagation()} /> className="absolute bottom-0 left-0 right-0 h-1 cursor-ns-resize z-30 hover:bg-cyan-500/50 transition-colors" onClick={e => e.stopPropagation()} />
@@ -4518,14 +4660,15 @@
</div> </div>
</div> </div>
</> </>
) : (() => { ) : (() => {
const st = subTabs.find(s => s.id === activeTab); const st = subTabs.find(s => s.id === activeTab);
if (!st) return null; if (!st) return null;
const subTrack = tracks.find(t => t.id === st.trackId); const subTrack = tracks.find(t => t.id === st.trackId);
const vTrack = subTrack ? { ...subTrack, buffer: st.buffer, isSubTab: true } : null; const vTrack = subTrack ? { ...subTrack, buffer: st.buffer, isSubTab: true } : null;
return ( const subTabTimelineWidth = Math.max(zoom * (st.buffer ? st.buffer.duration : 0), viewportWidth);
<> return (
{/* ══ TCP PANEL (sub-tab, single track) ══ */} <>
{/* ══ TCP PANEL (sub-tab, single track) ══ */}
<div className="w-[300px] shrink-0 z-30 bg-[#262626] overflow-y-scroll no-scrollbar flex flex-col border-r border-zinc-900" <div className="w-[300px] shrink-0 z-30 bg-[#262626] overflow-y-scroll no-scrollbar flex flex-col border-r border-zinc-900"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}> style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
<div className="sticky top-0 z-50 flex items-center h-10 border-b border-zinc-900 bg-[#242424] px-2 justify-between shrink-0"> <div className="sticky top-0 z-50 flex items-center h-10 border-b border-zinc-900 bg-[#242424] px-2 justify-between shrink-0">
@@ -4539,8 +4682,24 @@
<div key={vTrack.id} className="flex-1 flex flex-col p-2.5 bg-[#1e1e1e] border-r border-zinc-900 border-l-4 border-l-cyan-500"> <div key={vTrack.id} className="flex-1 flex flex-col p-2.5 bg-[#1e1e1e] border-r border-zinc-900 border-l-4 border-l-cyan-500">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: vTrack.color }} /> <label onClick={e => { e.stopPropagation(); const el = e.currentTarget.querySelector('input'); if (el) el.click(); }} className="cursor-pointer">
<span className="text-xs font-semibold text-zinc-300 truncate max-w-[100px]">{vTrack.name}</span> <input type="color" value={vTrack.color || '#0f766e'} onChange={e => { e.stopPropagation(); updateTrackColor(vTrack.id, e.target.value); }} className="w-0 h-0 opacity-0 absolute pointer-events-none" />
<div className="w-3 h-3 rounded-full border border-zinc-600 hover:scale-110 transition-transform" style={{ backgroundColor: vTrack.color }} />
</label>
{editingTrackName === vTrack.id ? (
<input type="text" value={editNameInput} autoFocus
onChange={e => setEditNameInput(e.target.value)}
onBlur={() => { updateTrackName(vTrack.id, editNameInput || vTrack.name); setEditingTrackName(null); }}
onKeyDown={e => { if (e.key === 'Enter') { updateTrackName(vTrack.id, editNameInput || vTrack.name); setEditingTrackName(null); } if (e.key === 'Escape') setEditingTrackName(null); }}
onClick={e => e.stopPropagation()}
className="text-xs font-semibold bg-black text-zinc-200 border border-cyan-500 rounded px-1 py-0 w-24 outline-none" />
) : (
<span className="text-xs font-semibold text-zinc-300 truncate max-w-[80px] cursor-text hover:text-cyan-300 transition-colors"
title="Click to rename"
onClick={e => { e.stopPropagation(); setEditingTrackName(vTrack.id); setEditNameInput(vTrack.name); }}>
{vTrack.name}
</span>
)}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button onClick={e => { e.stopPropagation(); toggleTrackMute(vTrack.id); }} <button onClick={e => { e.stopPropagation(); toggleTrackMute(vTrack.id); }}
@@ -4549,9 +4708,17 @@
className={`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border ${soloedTrackId === vTrack.id || vTrack.solo ? 'bg-amber-950 text-amber-400 border-amber-600' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`}>S</button> className={`px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border ${soloedTrackId === vTrack.id || vTrack.solo ? 'bg-amber-950 text-amber-400 border-amber-600' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}`}>S</button>
</div> </div>
</div> </div>
<div className="flex items-center gap-1.5 mb-2"> <div className="flex flex-col gap-0.5 text-[10px] mb-2">
<VolumeKnob value={vTrack.volume} onChange={v => updateTrackVolume(vTrack.id, v)} /> <div className="flex items-center gap-1">
<span className="text-[10px] font-mono text-zinc-500">Gain: {Math.round(vTrack.volume * 100)}%</span> <span className="w-8 text-right text-zinc-500 text-[9px]">Vol:</span>
<input type="range" min="-50" max="7" step="0.5" value={vTrack.volumeDb ?? 0} onChange={e => updateTrackVolumeDb(vTrack.id, parseFloat(e.target.value))} className="flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-cyan-500" style={{ height: '4px' }} />
<span className="w-12 text-right font-mono text-zinc-300 text-[9px]">{vTrack.volumeDb ?? 0}dB</span>
</div>
<div className="flex items-center gap-1">
<span className="w-8 text-right text-zinc-500 text-[9px]">Pan:</span>
<input type="range" min="-100" max="100" step="1" value={vTrack.pan ?? 0} onChange={e => updateTrackPan(vTrack.id, parseInt(e.target.value))} className="flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-purple-500" style={{ height: '4px' }} />
<span className="w-12 text-right font-mono text-zinc-300 text-[9px]">{vTrack.pan > 0 ? 'R' + vTrack.pan : vTrack.pan < 0 ? 'L' + Math.abs(vTrack.pan) : 'C'}</span>
</div>
</div> </div>
<div className="flex items-center gap-1 text-[9px] text-zinc-400"> <div className="flex items-center gap-1 text-[9px] text-zinc-400">
<span className="text-[8px] text-zinc-500 font-semibold uppercase">Duration:</span> <span className="text-[8px] text-zinc-500 font-semibold uppercase">Duration:</span>
@@ -4579,10 +4746,10 @@
</div> </div>
{/* ══ TIMELINE (sub-tab, single track) ══ */} {/* ══ TIMELINE (sub-tab, single track) ══ */}
<div ref={timelineWrapperRef} className="flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0"> <div ref={timelineWrapperRef} className="flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0">
<div style={{ width: `${timelineWidth}px` }} className="relative flex flex-col min-h-full"> <div style={{ width: `${subTabTimelineWidth}px` }} className="relative flex flex-col min-h-full">
<div className="sticky top-0 z-45 flex h-10 border-b border-zinc-900 bg-[#242424] shrink-0"> <div className="sticky top-0 z-45 flex h-10 border-b border-zinc-900 bg-[#242424] shrink-0">
<div className="flex-1 relative h-full flex items-center select-none cursor-ew-resize overflow-hidden"> <div className="flex-1 relative h-full flex items-center select-none cursor-ew-resize overflow-hidden">
{Array.from({ length: Math.ceil(maxDuration) }).map((_, i) => { {Array.from({ length: Math.ceil(st.buffer ? st.buffer.duration : 0) }).map((_, i) => {
const sec = i; const x = sec * zoom; const sec = i; const x = sec * zoom;
return (<div key={i} className="absolute h-full border-l border-zinc-800 pl-1 pt-1 text-[9px] font-mono text-zinc-500 pointer-events-none" style={{ left: `${x}px` }}>{formatTime(sec)}</div>); return (<div key={i} className="absolute h-full border-l border-zinc-800 pl-1 pt-1 text-[9px] font-mono text-zinc-500 pointer-events-none" style={{ left: `${x}px` }}>{formatTime(sec)}</div>);
})} })}
@@ -4590,7 +4757,7 @@
</div> </div>
<div className="sticky top-10 z-35 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] shrink-0"> <div className="sticky top-10 z-35 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] shrink-0">
<div className="flex-1 relative h-full overflow-hidden bg-[#1a1a2e]"> <div className="flex-1 relative h-full overflow-hidden bg-[#1a1a2e]">
<TempoTrackLane bpm={parseInt(bpm) || 120} zoom={zoom} timelineWidth={timelineWidth} <TempoTrackLane bpm={parseInt(bpm) || 120} zoom={zoom} timelineWidth={subTabTimelineWidth}
onPlayheadSet={setCurrentTime} snapValue={snapValue} /> onPlayheadSet={setCurrentTime} snapValue={snapValue} />
</div> </div>
</div> </div>
@@ -4608,6 +4775,8 @@
onSelectRange={(start, end) => setSubTabs(prev => prev.map(s => s.id === st.id ? {...s, selectionStart: start, selectionEnd: end} : s))} onSelectRange={(start, end) => setSubTabs(prev => prev.map(s => s.id === st.id ? {...s, selectionStart: start, selectionEnd: end} : s))}
onPlayheadSet={(time) => setSubTabs(prev => prev.map(s => s.id === st.id ? {...s, currentTime: time} : s))} onPlayheadSet={(time) => setSubTabs(prev => prev.map(s => s.id === st.id ? {...s, currentTime: time} : s))}
onContextMenu={(e, clickTime) => setContextMenu({x: e.clientX, y: e.clientY, isSubTab: true, subTabId: st.id, time: clickTime})} onContextMenu={(e, clickTime) => setContextMenu({x: e.clientX, y: e.clientY, isSubTab: true, subTabId: st.id, time: clickTime})}
zoom={zoom}
timelineWidth={subTabTimelineWidth}
/> />
</div> </div>
)} )}