diff --git a/15_GRAPH_EDIT.md b/15_GRAPH_EDIT.md new file mode 100644 index 0000000..b26b7be --- /dev/null +++ b/15_GRAPH_EDIT.md @@ -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 } + ] +} + +``` \ No newline at end of file diff --git a/app/templates/index.html b/app/templates/index.html index 490cb4a..c87b003 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -668,7 +668,7 @@ }; // ── 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); useEffect(() => { const canvas = canvasRef.current; @@ -676,12 +676,12 @@ const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); - canvas.width = rect.width * dpr; - canvas.height = rect.height * dpr; + const w = timelineWidth; + const h = rect.height; + canvas.width = w * dpr; + canvas.height = h * dpr; ctx.scale(dpr, dpr); - const w = rect.width; - const h = rect.height; ctx.fillStyle = '#181818'; ctx.fillRect(0, 0, w, h); @@ -689,30 +689,46 @@ const len = data.length; if (len === 0) return; - // Draw waveform - ctx.fillStyle = '#6ee7b7'; + // Draw waveform only in viewport range for performance & zero-line accuracy + 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; - for (let px = 0; px < w; px++) { - const start = Math.floor((px / w) * len); - const end = Math.floor(((px + 1) / w) * len); + + for (let px = drawXStart; px < drawXEnd; px++) { + 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 minVal = 0; - for (let i = start; i < end && i < len; i++) { + for (let i = chunkStart; i < chunkEnd; i++) { const val = data[i]; if (val > maxVal) maxVal = val; if (val < minVal) minVal = val; } const yTop = mid + (minVal * (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 if (selectionStart !== null && selectionEnd !== null && selectionStart !== selectionEnd) { const left = Math.min(selectionStart, selectionEnd); const right = Math.max(selectionStart, selectionEnd); - const leftPx = (left / buffer.duration) * w; - const rightPx = (right / buffer.duration) * w; + const leftPx = left * zoom; + const rightPx = right * zoom; ctx.fillStyle = 'rgba(245, 158, 11, 0.15)'; ctx.fillRect(leftPx, 0, rightPx - leftPx, h); ctx.strokeStyle = '#f59e0b'; @@ -721,20 +737,20 @@ ctx.moveTo(leftPx, 0); ctx.lineTo(leftPx, h); ctx.moveTo(rightPx, 0); ctx.lineTo(rightPx, h); ctx.stroke(); - } + } - // Draw playhead - if (currentTime !== null && currentTime >= 0 && currentTime <= buffer.duration) { - const playheadPx = (currentTime / buffer.duration) * w; - ctx.strokeStyle = '#ef4444'; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.moveTo(playheadPx, 0); - ctx.lineTo(playheadPx, h); - ctx.stroke(); - } + // Draw playhead + if (currentTime !== null && currentTime >= 0 && currentTime <= buffer.duration) { + const playheadPx = currentTime * zoom; + ctx.strokeStyle = '#ef4444'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(playheadPx, 0); + ctx.lineTo(playheadPx, h); + ctx.stroke(); + } - }, [buffer, currentTime, selectionStart, selectionEnd]); + }, [buffer, currentTime, selectionStart, selectionEnd, zoom, timelineWidth]); const handleMouseDown = (e) => { if (e.button === 2) return; // ignore right click @@ -972,8 +988,8 @@ const App = () => { // ── State Definitions ── 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: '2', name: 'Track 02', buffer: null, startTime: 0, height: 96, volume: 0.8, muted: false, solo: false, color: '#1d4ed8', 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, volumeDb: 0, pan: 0, muted: false, solo: false, color: '#1d4ed8', markers: [], serverFileId: null }, ]); const [bpm, setBpm] = useState(localStorage.getItem('studio_bpm') || '120'); const [draggedClip, setDraggedClip] = useState(null); // { trackId, clickOffset, buffer, name, volume, color } @@ -1012,6 +1028,9 @@ const [localSelectionEnd, setLocalSelectionEnd] = useState(null); const [zoom, setZoom] = useState(100); 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 [soloedTrackId, setSoloedTrackId] = useState(null); const [toastMessage, setToastMessage] = useState(null); @@ -1019,7 +1038,7 @@ const [showExportPanel, setShowExportPanel] = useState(true); const [showAIPanel, setShowAIPanel] = 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 [dragGhostPos, setDragGhostPos] = useState(null); const [dragGhostPanel, setDragGhostPanel] = useState(null); @@ -1044,6 +1063,8 @@ const [menuOpen, setMenuOpen] = useState(null); const [selectedClipId, setSelectedClipId] = useState(null); // { trackId, clipId } 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 ── const [contextMenu, setContextMenu] = useState(null); // { x, y, trackId } @@ -1099,7 +1120,8 @@ const track = tracks.find(t => t.id === trackId); if (!track) return null; return { - volume: track.volume, + volumeDb: track.volumeDb, + pan: track.pan, muted: track.muted, name: track.name, markers: JSON.parse(JSON.stringify(track.markers || [])), @@ -1202,6 +1224,7 @@ }; const rulerRef = useRef(null); const activeSourcesRef = useRef([]); + const activeTrackNodesRef = useRef({}); // { [trackId]: { gainNode, pannerNode } } const startOffsetTimeRef = useRef(0); const startAudioTimeRef = useRef(0); const animationFrameIdRef = useRef(null); @@ -1468,15 +1491,21 @@ showToast(`Đã lặp vùng chọn ${loopCount} lần.`, 'success'); }; - useEffect(() => { + useEffect(() => { const handler = (e) => { const ctrl = e.ctrlKey || e.metaKey; 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') { // Sub-Tab keyboard shortcuts mapping 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 (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; } @@ -1491,7 +1520,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, 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') || (ctrl && e.shiftKey && e.key === 's')) { e.preventDefault(); showToast('Save As dialog','info'); return; } if (ctrl && !alt && e.key === 'i') { e.preventDefault(); addNewTrack(); return; } @@ -1943,7 +1972,7 @@ const ctx = getAudioContext(); const clipBuffer = ctx.createBuffer(1, len, sr); 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(); showToast('Đã sao chép vùng chọn.', 'info'); return; @@ -1954,7 +1983,8 @@ clipboardRef.current = { buffer: track.buffer, name: track.name, - volume: track.volume, + volumeDb: track.volumeDb, + pan: track.pan, color: track.color, }; closeContextMenu(); @@ -1978,7 +2008,7 @@ const ctx = getAudioContext(); const clipBuffer = ctx.createBuffer(1, len, sr); 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 newBuffer = ctx.createBuffer(1, newLen, sr); const newData = newBuffer.getChannelData(0); @@ -2002,7 +2032,7 @@ showToast('Clipboard trống.', 'warning'); return null; } - const { buffer: clipBuffer, name, volume, color } = clipboardRef.current; + const { buffer: clipBuffer, name, volumeDb, pan, color } = clipboardRef.current; const ctx = getAudioContext(); const targetTrack = tracks.find(t => t.id === targetTrackId); @@ -2031,7 +2061,8 @@ buffer: updatedClips[0].buffer, startTime: updatedClips[0].startTime, name: name || t.name, - volume: volume || t.volume, + volumeDb: volumeDb ?? t.volumeDb, + pan: pan ?? t.pan, color: color || t.color }; } @@ -2051,7 +2082,7 @@ buffer: clipBuffer, startTime: pasteTime, 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], markers: [], serverFileId: null, }]); @@ -2083,9 +2114,10 @@ activeTracks.forEach(t => { const data = t.buffer.getChannelData(0); 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++) { 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); setTracks(prev => [...prev, { 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, }]); setSelectedTrackId(newId); @@ -2122,15 +2154,16 @@ at.forEach(t => { const d = t.buffer.getChannelData(0); 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++) { 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;imp) mp=a; } if (mp > 1.0) for (let i=0;i [...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'); }; const handleCopyTrack = () => { @@ -2150,13 +2183,13 @@ const ctx = getAudioContext(); const clipBuffer = ctx.createBuffer(1, len, sr); 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'); return; } } // 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'); }; const handleCutTrack = () => { @@ -2178,7 +2211,7 @@ const ctx = getAudioContext(); const clipBuffer = ctx.createBuffer(1, len, sr); 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 const newLen = data.length - len; const newBuffer = ctx.createBuffer(1, newLen, sr); @@ -2374,6 +2407,51 @@ }, [zoom, minZoom, maxDuration, activeTab]); // ── 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 = () => { if (activeTab !== 'main') { const st = subTabs.find(s => s.id === activeTab); @@ -2393,40 +2471,7 @@ currentTime: start, isPlaying: true } : s)); - // Restart subtab buffer segment - 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; + startSubTabPlayback(st, start); animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); return; } @@ -2434,7 +2479,17 @@ if (updatedTime >= st.buffer.duration) { 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; } @@ -2504,6 +2559,18 @@ 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 ? [{ id: 'default', buffer: track.buffer, @@ -2519,11 +2586,8 @@ source.buffer = clip.buffer; source.playbackRate.value = clip.speed || 1.0; - const gainNode = context.createGain(); - gainNode.gain.setValueAtTime(track.volume, context.currentTime); - source.connect(gainNode); - gainNode.connect(context.destination); + gainNode.connect(pannerNode); const clipStart = clip.startTime || 0; const clipDuration = clip.buffer.duration / (clip.speed || 1.0); @@ -2556,6 +2620,18 @@ 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 => { if (!clip.buffer) return; @@ -2563,11 +2639,8 @@ source.buffer = clip.buffer; source.playbackRate.value = clip.speed || 1.0; - const gainNode = context.createGain(); - gainNode.gain.setValueAtTime(track.volume, context.currentTime); - source.connect(gainNode); - gainNode.connect(context.destination); + gainNode.connect(pannerNode); const clipStart = clip.startTime || 0; const clipDuration = clip.buffer.duration / (clip.speed || 1.0); @@ -2590,58 +2663,14 @@ // Sub-tab playback transport const st = subTabs.find(s => s.id === activeTab); if (!st || !st.buffer) return; - const context = getAudioContext(); if (st.isPlaying) { stopAllPlayback(); setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, isPlaying: false } : s)); } else { 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; - source.start(context.currentTime, startOffset); - - activeSourcesRef.current = [source]; - startOffsetTimeRef.current = startOffset; - startAudioTimeRef.current = context.currentTime; - + startSubTabPlayback(st, startOffset); setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, isPlaying: true } : s)); } return; @@ -2667,6 +2696,7 @@ try { src.stop(); } catch(e) {} }); activeSourcesRef.current = []; + activeTrackNodesRef.current = {}; setIsPlaying(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); - 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 => { const next = [...prev, { action_type: 'VOLUME_CHANGE', @@ -3263,6 +3293,65 @@ if (next.length > MAX_UNDO) next.shift(); 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) ── @@ -3334,11 +3423,11 @@ const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; const selectColor = colors[tracks.length % colors.length]; - setTracks(prev => [...prev, { - id: newId, name: `Track ${newId}`, buffer: null, startTime: 0, height: 96, - volume: 0.8, muted: false, solo: false, - color: selectColor, markers: [], serverFileId: null - }]); + setTracks(prev => [...prev, { + id: newId, name: `Track ${newId}`, buffer: null, startTime: 0, height: 96, + volumeDb: 0, pan: 0, muted: false, solo: false, + color: selectColor, markers: [], serverFileId: null + }]); showToast(`Đã thêm Track ${newId}.`, 'info'); setTimeout(() => lucide.createIcons(), 200); return newId; @@ -3365,7 +3454,7 @@ const tracksMeta = activeTracks.map(t => ({ track_id: t.id, file_id: serverFileIdMap[t.id], - volume: t.volume, + volume_db: t.volumeDb, muted: false, clips: [{ clip_id: `clip_${t.id}`, @@ -3465,10 +3554,16 @@ source.playbackRate.value = clip.speed || 1.0; 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); - gain.connect(offlineCtx.destination); + gain.connect(panner); + panner.connect(offlineCtx.destination); const clipStart = clip.startTime || 0; source.start(clipStart); @@ -3731,7 +3826,8 @@ startTime: snapStart, name: `AI_Cut_${activeTrack.name.replace('.wav', '')}_${snapStart.toFixed(1)}s` }], - volume: 0.8, + volumeDb: 0, + pan: 0, muted: false, solo: false, color: selectColor, @@ -3945,7 +4041,7 @@
{[ { 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: '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') }, @@ -4105,65 +4201,67 @@ {/* ── Transport Bar + Toolbar Overlay ── */}
- {/* Toolbar Overlay Panel — có thể kéo thả hoặc ghim sau này */} -
- +
+ + +
-
- +
-
+
@@ -4204,6 +4302,41 @@ }`} title="Bật/Tắt Lặp vùng chọn"> + Snap + +
+ Bars: + { + 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" /> + - + { + 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" /> + # + +
+ Start: + {selLeft !== null ? formatTime(selLeft) : '--:--'} + End: + {selRight !== null ? formatTime(selRight) : '--:--'} + Len: + {selLeft !== null && selRight !== null ? formatTime(Math.abs(selRight - selLeft)) : '--:--'}
{formatTime(currentTime)}
@@ -4370,17 +4503,7 @@ onScroll={handleTCPScroll} 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' }}> -
- Kênh -
- Snap - -
+
@@ -4406,8 +4529,24 @@
{(idx+1).toString().padStart(2, '0')} -
- {track.name} +
-
e.stopPropagation()}> - Synth: - - +
e.stopPropagation()}> +
+ Vol: + 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' }} /> + {track.volumeDb ?? 0}dB +
+
+ Pan: + 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' }} /> + {track.pan > 0 ? 'R' + track.pan : track.pan < 0 ? 'L' + Math.abs(track.pan) : 'C'} +
-
e.stopPropagation()}> -
- updateTrackVolume(track.id, v)} /> - Gain: {Math.round(track.volume * 100)}% -
-
- loadFileOnTrack(track.id, e.target.files[0])} /> - -
+
e.stopPropagation()}> + loadFileOnTrack(track.id, e.target.files[0])} /> + + +
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()} /> @@ -4518,14 +4660,15 @@
- ) : (() => { - const st = subTabs.find(s => s.id === activeTab); - if (!st) return null; - const subTrack = tracks.find(t => t.id === st.trackId); - const vTrack = subTrack ? { ...subTrack, buffer: st.buffer, isSubTab: true } : null; - return ( - <> - {/* ══ TCP PANEL (sub-tab, single track) ══ */} + ) : (() => { + const st = subTabs.find(s => s.id === activeTab); + if (!st) return null; + const subTrack = tracks.find(t => t.id === st.trackId); + const vTrack = subTrack ? { ...subTrack, buffer: st.buffer, isSubTab: true } : null; + const subTabTimelineWidth = Math.max(zoom * (st.buffer ? st.buffer.duration : 0), viewportWidth); + return ( + <> + {/* ══ TCP PANEL (sub-tab, single track) ══ */}
@@ -4539,8 +4682,24 @@
-
- {vTrack.name} +
-
- updateTrackVolume(vTrack.id, v)} /> - Gain: {Math.round(vTrack.volume * 100)}% +
+
+ Vol: + 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' }} /> + {vTrack.volumeDb ?? 0}dB +
+
+ Pan: + 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' }} /> + {vTrack.pan > 0 ? 'R' + vTrack.pan : vTrack.pan < 0 ? 'L' + Math.abs(vTrack.pan) : 'C'} +
Duration: @@ -4579,10 +4746,10 @@
{/* ══ TIMELINE (sub-tab, single track) ══ */}
-
+
- {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; return (
{formatTime(sec)}
); })} @@ -4590,7 +4757,7 @@
-
@@ -4608,6 +4775,8 @@ 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))} onContextMenu={(e, clickTime) => setContextMenu({x: e.clientX, y: e.clientY, isSubTab: true, subTabId: st.id, time: clickTime})} + zoom={zoom} + timelineWidth={subTabTimelineWidth} />
)}