diff --git a/23_FIX_ZOOM.md b/23_FIX_ZOOM.md new file mode 100644 index 0000000..3e8401e --- /dev/null +++ b/23_FIX_ZOOM.md @@ -0,0 +1,87 @@ +# Technical Analysis: Canvas Dimension Overflow During Ultra-Zoom + +This document analyzes the root cause of the graphical failure that occurs when users perform an ultra-zoom operation on audio files of varying durations (1 second versus over 5 seconds). This anomaly leads to a rendering crash and turns the entire track lane completely blank white at maximum zoom levels. + +--- + +## 1. The Root Cause: Browser Canvas Dimension Limits + +This phenomenon is not a standard programming logic error, but rather a physical hardware limitation of modern web browsers (Chrome, Firefox, Safari) when interacting with the GPU (Graphics Card). + +### 1.1. Physical Canvas Width Calculation Formula + +In traditional DAW user interface architectures, the actual physical width of a waveform lane, $W_{\text{canvas}}$ (measured in pixels), is calculated dynamically based on the clip duration, $T_{\text{clip}}$ (seconds), and the zoom scale factor, $Z$ (pixels/second): + +$$W_{\text{canvas}} = T_{\text{clip}} \times Z$$ + +### 1.2. Browser Maximum Canvas Size Constraints ($W_{\text{limit}}$) + +To optimize performance, browsers leverage the GPU for hardware acceleration, managing the `` element as a specialized GPU Texture mapping block. Consequently, each browser and operating system sets an absolute maximum physical size boundary for the canvas element ($W_{\text{limit}}$). + +This maximum ceiling typically ranges within: + +* $16,384\text{ px}$ (on mobile devices or lower-end configurations). +* $32,768\text{ px}$ (on modern desktop browsers). + +If the calculated width of the canvas exceeds this physical hardware ceiling ($W_{\text{canvas}} > W_{\text{limit}}$): + +* The browser fails to allocate additional graphical memory or texture space. +* The underlying WebGL core or Canvas 2D Rendering Context suffers an immediate **Context Loss**. +* The entire display region of the canvas collapses and reverts to its default uninitialized hardware state: becoming completely blank white or entirely transparent. + +--- + +## 2. Analysis of the Variance Between 1-Second and 5-Second Files + +Assume an operator triggers an ultra-zoom action to an extreme deep magnification level of $Z = 10,000\text{ pixels/second}$ to monitor discrete sample node metrics: + +### 2.1. Ultra-Short Audio Files (1 Second) + +Applying the width calculation formula: + +$$W_{\text{canvas\_1s}} = 1.0\text{ s} \times 10,000\text{ px/s} = 10,000\text{ px}$$ + +* **Result:** Because $10,000\text{ px} < 32,768\text{ px}$ (safely below the maximum hardware threshold), the browser allocates the texture memory cache perfectly. Users can zoom in completely to view discrete green sample nodes cleanly rendered on top of smooth sinusoidal phases. + +### 2.2. Longer Audio Files (e.g., 5 Seconds or 10 Seconds) + +Applying the width calculation formula at the identical zoom factor of $Z = 10,000\text{ px/s}$: + +$$W_{\text{canvas\_5s}} = 5.0\text{ s} \times 10,000\text{ px/s} = 50,000\text{ px}$$ + +$$W_{\text{canvas\_10s}} = 10.0\text{ s} \times 10,000\text{ px/s} = 100,000\text{ px}$$ + +* **Result:** Both evaluated dimensions ($50,000\text{ px}$ and $100,000\text{ px}$) **drastically exceed the maximum boundary constraint** ($W_{\text{limit}} = 32,768\text{ px}$) enforced by the GPU. +* The instant the user pushes the magnification past this safety threshold, the browser overloads its hardware texture buffer, drops the canvas rendering context, and flashes the entire track lane into a **blank white void** (destroying waveform lines and grid displays entirely). + +--- + +## 3. The Fixed-Viewport Canvas Architecture Solution + +To eliminate this memory overflow anomaly permanently and allow users to zoom in infinitely across multi-hour audio files without encountering blank screen crashes, the layout engine must abandon the paradigm of scaling the physical canvas element width to match the audio clip length. + +### Professional DAW Solution: **Viewport-Only Canvas Architecture** + +```text + EDITOR VIEWPORT SCREEN (Fixed Width: 1200px) +|<────────────────────────── Physical Canvas Viewport ──────────────────────────>| ++────────────────────────────────────────────────────────────────────────────────+ +| Waveform is painted dynamically based on the scrollLeft offset | +| | +| [ Render localized sample slice from RAM ] | +| | ++────────────────────────────────────────────────────────────────────────────────+ + +``` + +1. **Rigid Canvas Sizing Constraints:** +The physical dimension width of the `` tag must never be allowed to stretch according to zoom ratios. It must remain strictly locked to match the exact visible horizontal window boundary of the user's viewport (e.g., $W_{\text{canvas}} = W_{\text{viewport}} \approx 1200\text{ px}$). +2. **Intelligent Slicing Redraw (Slicing Render):** +When a horizontal navigation event occurs (`scrollLeft`), the engine avoids shifting the physical canvas layout. Instead, it alters the offset index of the sample array queried for the drawing loop: +* **Visible Window Starting Index:** $T_{\text{start}} = \frac{\text{scrollLeft}}{Z}$ +* **Visible Window Terminating Index:** $T_{\text{end}} = \frac{\text{scrollLeft} + W_{\text{viewport}}}{Z}$ + + +The layout routine isolates only the localized sample chunk mapping within the interval $[T_{\text{start}}, T_{\text{end}}]$ straight from client-side RAM, rendering it directly over the fixed $1200\text{ px}$ canvas envelope. + +* **Absolute Advantages:** Because the canvas physical size is permanently pinned to a lightweight display footprint ($1200\text{ px}$), the system **consumes a minimal, static fraction of GPU memory**. It can never exceed hardware boundaries, permanently eradicating the blank track lane bug and enabling a fluid $120\text{ FPS}$ refresh cycle regardless of total audio track length. \ No newline at end of file diff --git a/app/static/css/tailwind.min.css b/app/static/css/tailwind.min.css new file mode 100644 index 0000000..e69de29 diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index b3f3f19..94213d8 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -1,7636 +1,9231 @@ +const { + useState, + useRef, + useEffect, + useMemo +} = React; - const { useState, useRef, useEffect, useMemo } = React; +// ── FastAPI Backend Configuration ── +const API_BASE_URL = window.location.origin; +const API_AUDIO = `${API_BASE_URL}/api/v1/audio`; +const API_MULTITRACK = `${API_BASE_URL}/api/v1/multitrack`; +const API_TASKS = `${API_BASE_URL}/api/v1/audio/tasks`; - // ── FastAPI Backend Configuration ── - const API_BASE_URL = window.location.origin; - const API_AUDIO = `${API_BASE_URL}/api/v1/audio`; - const API_MULTITRACK = `${API_BASE_URL}/api/v1/multitrack`; - const API_TASKS = `${API_BASE_URL}/api/v1/audio/tasks`; +// Handle ?sfs= from double-clicking a .sfs file (opens domain -> loads project) +(function handleSfsDeepLink() { + try { + const params = new URLSearchParams(window.location.search); + const sfsParam = params.get('sfs'); + if (!sfsParam) return; + const decoded = JSON.parse(decodeURIComponent(sfsParam)); + window.__pendingSfsProject = decoded; // consumed after auth in App + if (window.history.replaceState) { + window.history.replaceState({}, document.title, window.location.pathname); + } + } catch (e) { + window.__pendingSfsProject = null; + } +})(); - // Handle ?sfs= from double-clicking a .sfs file (opens domain -> loads project) - (function handleSfsDeepLink() { - try { - const params = new URLSearchParams(window.location.search); - const sfsParam = params.get('sfs'); - if (!sfsParam) return; - const decoded = JSON.parse(decodeURIComponent(sfsParam)); - window.__pendingSfsProject = decoded; // consumed after auth in App - if (window.history.replaceState) { - window.history.replaceState({}, document.title, window.location.pathname); - } - } catch (e) { window.__pendingSfsProject = null; } - })(); +// Storage for server-side file IDs mapped to track IDs +let serverFileIdMap = {}; +let audioCtx; +function getAudioContext() { + if (!audioCtx) { + audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + if (window.SonicAudio && window.SonicAudio.initAudioWorklet) { + window.SonicAudio.initAudioWorklet(); + } + } + if (audioCtx.state === 'suspended') { + audioCtx.resume(); + } + return audioCtx; +} +const formatTime = secs => { + if (isNaN(secs) || secs < 0) return "0:00.000"; + const m = Math.floor(secs / 60); + const s = Math.floor(secs % 60); + const ms = Math.floor(secs % 1 * 1000).toString().padStart(3, '0'); + return `${m}:${s.toString().padStart(2, '0')}.${ms}`; +}; +const formatTimeSimple = secs => { + if (isNaN(secs) || secs < 0) return "0.00s"; + return `${secs.toFixed(2)}s`; +}; +const formatBeat = (secs, bpmVal) => { + if (isNaN(secs) || secs < 0) return "1.1.1"; + const beatDuration = 60 / bpmVal; + const barDuration = beatDuration * 4; + const bar = Math.floor(secs / barDuration) + 1; + const beat = Math.floor(secs % barDuration / beatDuration) + 1; + const sub = Math.floor(secs % beatDuration / (beatDuration / 4)) + 1; + return `${bar}.${beat}.${sub}`; +}; +const getBeatMarkers = (maxDur, bpmVal) => { + const beatDuration = 60 / bpmVal; + const barDuration = beatDuration * 4; + const markers = []; + for (let t = 0; t <= maxDur; t += beatDuration) { + const isBar = Math.abs(t % barDuration) < 0.001 || Math.abs(t % barDuration - barDuration) < 0.001; + markers.push({ + time: t, + isBar, + beatNum: Math.floor(t / beatDuration) + 1 + }); + } + return markers; +}; +const findZeroCrossing = (buffer, targetTime) => { + if (!buffer) return targetTime; + const sampleRate = buffer.sampleRate; + const data = buffer.getChannelData(0); + const targetSample = Math.floor(targetTime * sampleRate); + const windowSize = Math.floor(0.04 * sampleRate); + const start = Math.max(0, targetSample - windowSize); + const end = Math.min(data.length - 2, targetSample + windowSize); + let bestSample = targetSample; + let minDistance = Infinity; + for (let i = start; i <= end; i++) { + if (data[i] >= 0 && data[i + 1] <= 0 || data[i] <= 0 && data[i + 1] >= 0) { + const dist = Math.abs(i - targetSample); + if (dist < minDistance) { + minDistance = dist; + bestSample = i; + } + } + } + return bestSample / sampleRate; +}; +const VolumeKnob = ({ + value, + onChange, + min = 0, + max = 1 +}) => { + const [isDragging, setIsDragging] = useState(false); + const startY = useRef(0); + const startValue = useRef(0); + const rotation = useMemo(() => { + const percent = (value - min) / (max - min); + return -135 + percent * 270; + }, [value, min, max]); + const handleMouseDown = e => { + setIsDragging(true); + startY.current = e.clientY; + startValue.current = value; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + const handleMouseMove = e => { + const deltaY = startY.current - e.clientY; + const sensitivity = 0.005; + const newValue = Math.max(min, Math.min(max, startValue.current + deltaY * sensitivity)); + onChange(parseFloat(newValue.toFixed(2))); + }; + const handleMouseUp = () => { + setIsDragging(false); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + return /*#__PURE__*/React.createElement("div", { + className: "knob-container cursor-ns-resize flex flex-col items-center", + onMouseDown: handleMouseDown, + title: `Volume: ${Math.round(value * 100)}%` + }, /*#__PURE__*/React.createElement("svg", { + className: "w-7 h-7", + viewBox: "0 0 40 40" + }, /*#__PURE__*/React.createElement("circle", { + cx: "20", + cy: "20", + r: "16", + fill: "#141414", + stroke: "#444", + strokeWidth: "2" + }), /*#__PURE__*/React.createElement("g", { + transform: `rotate(${rotation} 20 20)`, + className: "knob-dial" + }, /*#__PURE__*/React.createElement("line", { + x1: "20", + y1: "20", + x2: "20", + y2: "6", + stroke: "#ef4444", + strokeWidth: "3", + strokeLinecap: "round" + })))); +}; +const WaveformLane = ({ + track, + zoom, + timelineWidth, + viewportWidth, + onSelectRange, + onPlayheadSet, + isSelected, + onSelectTrack, + markers, + selectionMode, + localSelectionTrackId, + localSelectionStart, + currentTime, + getLocalAnchor, + onClearLocalSelection, + onSetSelectionMode, + onSetSelectionStart, + onSetSelectionEnd, + onSetCurrentTime, + onSetLocalSelectionTrackId, + onSetLocalSelectionStart, + onSetLocalSelectionEnd, + localSelLeft, + localSelRight, + onTrackLaneMouseDown, + onContextMenu, + onClipDragStart, + onClipStretchStart, + onSelectionEdgeDragStart, + setSelectedClipId, + selectedClipId, + activeTool, + onSplitTrackAtTime, + onEditClipInSubTab, + snapValue, + bpm +}) => { + const canvasRef = useRef(null); + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null; - // Storage for server-side file IDs mapped to track IDs - let serverFileIdMap = {}; + // Viewport Virtualization: canvas pixel width is strictly capped to visible window width (e.g. 1200px) + const scrollLeft = wrapper ? wrapper.scrollLeft : 0; + const vWidth = viewportWidth || (wrapper ? wrapper.clientWidth : 1200); + const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200)); + const height = canvas.parentElement ? canvas.parentElement.clientHeight : 96; + canvas.width = Math.round(drawWidth * dpr); + canvas.height = Math.round(height * dpr); + ctx.scale(dpr, dpr); + ctx.imageSmoothingEnabled = false; - let audioCtx; - function getAudioContext() { - if (!audioCtx) { - audioCtx = new (window.AudioContext || window.webkitAudioContext)(); - if (window.SonicAudio && window.SonicAudio.initAudioWorklet) { - window.SonicAudio.initAudioWorklet(); - } - } - if (audioCtx.state === 'suspended') { - audioCtx.resume(); - } - return audioCtx; + // Position canvas element at current scrollLeft inside track container + canvas.style.position = 'absolute'; + canvas.style.left = `${scrollLeft}px`; + canvas.style.width = `${drawWidth}px`; + canvas.style.height = `${height}px`; + ctx.fillStyle = isSelected ? '#2a2a2a' : track.id % 2 === 0 ? '#181818' : '#1d1d1d'; + ctx.fillRect(0, 0, drawWidth, height); + + // Grid lines based on Snap value + ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)'; + ctx.lineWidth = 1; + const tStart = scrollLeft / zoom; + const tEnd = (scrollLeft + drawWidth) / zoom; + let gridSpacing = 1.0; + if (snapValue && snapValue !== 'free') { + const beatDuration = 60 / parseFloat(bpm || 120); + let divisor = 1; + if (snapValue === '1') divisor = 1;else if (snapValue === '1/2') divisor = 0.5;else if (snapValue === '1/4') divisor = 0.25;else if (snapValue === '1/8') divisor = 0.125;else if (snapValue === '1/16') divisor = 0.0625;else if (snapValue === '1/32') divisor = 0.03125; + gridSpacing = beatDuration * divisor; + } else { + gridSpacing = 60 / parseFloat(bpm || 120); + } + let drawSpacing = gridSpacing; + while (drawSpacing * zoom < 10) { + drawSpacing *= 2; + } + const firstGridStep = Math.floor(tStart / drawSpacing) * drawSpacing; + for (let s = firstGridStep; s <= tEnd; s += drawSpacing) { + const localX = (s - tStart) * zoom; + ctx.beginPath(); + ctx.moveTo(localX, 0); + ctx.lineTo(localX, height); + ctx.stroke(); + } + + // Draw waveform lane + const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ + id: 'default', + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name, + speed: track.speed || 1.0 + }] : []; + if (clips.length > 0) { + clips.forEach(clip => { + const numChannels = clip.buffer.numberOfChannels || 1; + const dataL = clip.buffer.getChannelData(0); + const dataR = numChannels >= 2 ? clip.buffer.getChannelData(1) : dataL; + const sampleRate = clip.buffer.sampleRate; + const totalSamples = dataL.length; + const originalDuration = totalSamples / sampleRate; + const clipSpeed = clip.speed || 1.0; + const duration = originalDuration / clipSpeed; + const clipStartTime = clip.startTime || 0; + const clipEndTime = clipStartTime + duration; + + // Culling: Skip rendering if clip is outside visible viewport window + if (clipEndTime < tStart || clipStartTime > tEnd) return; + const xStartGlobal = clipStartTime * zoom; + const wClip = duration * zoom; + const xStartLocal = xStartGlobal - scrollLeft; + const xEndLocal = xStartLocal + wClip; + + // 1. Draw Clip Layer Background & Border + const clipIdentifier = clip.id === 'default' ? 'default_' + track.id : clip.id; + const isClipSelected = selectedClipId && selectedClipId.trackId === track.id && selectedClipId.clipId === clipIdentifier; + ctx.fillStyle = isClipSelected ? track.color ? track.color + '44' : 'rgba(6, 182, 212, 0.30)' : track.color ? track.color + '22' : 'rgba(6, 182, 212, 0.12)'; + ctx.strokeStyle = isClipSelected ? '#fbbf24' : track.color || '#06b6d4'; + ctx.lineWidth = isClipSelected ? 1 : 1.5; + const clipTop = 4; + const clipHeight = height - 8; + ctx.beginPath(); + if (ctx.roundRect) { + ctx.roundRect(xStartLocal, clipTop, wClip, clipHeight, 4); + } else { + ctx.rect(xStartLocal, clipTop, wClip, clipHeight); + } + ctx.fill(); + ctx.stroke(); + + // 2. Draw Clip Label & Speed Label + ctx.fillStyle = '#e4e4e7'; + ctx.font = 'bold 9px sans-serif'; + ctx.fillText(clip.name || 'Clip', Math.max(xStartLocal + 8, 8), clipTop + 12); + if (clipSpeed !== 1.0) { + ctx.fillStyle = '#fbbf24'; + ctx.font = 'bold 8px sans-serif'; + ctx.fillText(`Speed: ${(clipSpeed * 100).toFixed(1)}%`, Math.max(xStartLocal + 8, 8), clipTop + 22); } - const formatTime = (secs) => { - if (isNaN(secs) || secs < 0) return "0:00.000"; - const m = Math.floor(secs / 60); - const s = Math.floor(secs % 60); - const ms = Math.floor((secs % 1) * 1000).toString().padStart(3, '0'); - return `${m}:${s.toString().padStart(2, '0')}.${ms}`; - }; - - const formatTimeSimple = (secs) => { - if (isNaN(secs) || secs < 0) return "0.00s"; - return `${secs.toFixed(2)}s`; - }; - - const formatBeat = (secs, bpmVal) => { - if (isNaN(secs) || secs < 0) return "1.1.1"; - const beatDuration = 60 / bpmVal; - const barDuration = beatDuration * 4; - const bar = Math.floor(secs / barDuration) + 1; - const beat = Math.floor((secs % barDuration) / beatDuration) + 1; - const sub = Math.floor((secs % beatDuration) / (beatDuration / 4)) + 1; - return `${bar}.${beat}.${sub}`; - }; - - const getBeatMarkers = (maxDur, bpmVal) => { - const beatDuration = 60 / bpmVal; - const barDuration = beatDuration * 4; - const markers = []; - for (let t = 0; t <= maxDur; t += beatDuration) { - const isBar = Math.abs(t % barDuration) < 0.001 || Math.abs(t % barDuration - barDuration) < 0.001; - markers.push({ time: t, isBar, beatNum: Math.floor(t / beatDuration) + 1 }); + // Draw markers + if (markers && markers.length > 0) { + markers.forEach(m => { + const mxLocal = m.time * zoom - scrollLeft; + if (mxLocal >= 0 && mxLocal <= drawWidth) { + ctx.fillStyle = '#fbbf24'; + ctx.fillRect(mxLocal - 1, 0, 2, height); } - return markers; - }; + }); + } - const findZeroCrossing = (buffer, targetTime) => { - if (!buffer) return targetTime; - const sampleRate = buffer.sampleRate; - const data = buffer.getChannelData(0); - const targetSample = Math.floor(targetTime * sampleRate); - const windowSize = Math.floor(0.04 * sampleRate); - const start = Math.max(0, targetSample - windowSize); - const end = Math.min(data.length - 2, targetSample + windowSize); - - let bestSample = targetSample; - let minDistance = Infinity; - - for (let i = start; i <= end; i++) { - if ((data[i] >= 0 && data[i+1] <= 0) || (data[i] <= 0 && data[i+1] >= 0)) { - const dist = Math.abs(i - targetSample); - if (dist < minDistance) { - minDistance = dist; - bestSample = i; - } - } + // 3. Peak / Vector Waveform Drawing (Sound Forge Dual Stereo Channel Split Match) + const drawXStartLocal = Math.max(0, Math.floor(xStartLocal)); + const drawXEndLocal = Math.min(drawWidth, Math.ceil(xEndLocal)); + const samplesPerPixel = sampleRate / zoom * clipSpeed; + const isStereo = numChannels >= 2; + const channelConfigs = isStereo ? [{ + data: dataL, + mid: height / 4, + chHeight: height / 2 - 8, + label: '1' + }, { + data: dataR, + mid: 3 * height / 4, + chHeight: height / 2 - 8, + label: '2' + }] : [{ + data: dataL, + mid: height / 2, + chHeight: clipHeight - 12, + label: 'MONO' + }]; + if (isStereo) { + ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(xStartLocal, height / 2); + ctx.lineTo(xStartLocal + wClip, height / 2); + ctx.stroke(); + } + channelConfigs.forEach(ch => { + const data = ch.data; + const mid = ch.mid; + const peakRatio = ch.chHeight * 0.42; + + // Decibel Amplitude Grid Lines (+6.0dB, -Inf, -6.0dB) + const yPlus6 = mid - peakRatio * 0.8; + const yMinus6 = mid + peakRatio * 0.8; + ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(xStartLocal, yPlus6); + ctx.lineTo(xStartLocal + wClip, yPlus6); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(xStartLocal, mid); + ctx.lineTo(xStartLocal + wClip, mid); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(xStartLocal, yMinus6); + ctx.lineTo(xStartLocal + wClip, yMinus6); + ctx.stroke(); + + // Decibel Text Labels (Sound Forge Monospace) + ctx.fillStyle = '#71717a'; + ctx.font = '8px monospace'; + const labelX = Math.max(xStartLocal + 4, 4); + ctx.fillText('+6.0', labelX, yPlus6 - 2); + ctx.fillText('-Inf', labelX, mid - 2); + ctx.fillText('-6.0', labelX, yMinus6 + 8); + + // Sound Forge Channel ID Badge (1 or 2) + if (isStereo) { + ctx.fillStyle = 'rgba(6, 182, 212, 0.85)'; + ctx.font = 'bold 9px monospace'; + ctx.fillText(ch.label, Math.min(xStartLocal + wClip - 12, drawWidth - 16), mid - ch.chHeight * 0.35); + } + ctx.strokeStyle = '#5bc0be'; // Cornflower Blue + ctx.lineWidth = 1.2; + if (samplesPerPixel < 4) { + // High-zoom continuous vector line rendering (#5bc0be Cornflower Blue) + const visibleTStart = (drawXStartLocal - xStartLocal) / zoom; + const visibleTEnd = (drawXEndLocal - xStartLocal) / zoom; + const startSample = Math.max(0, Math.floor(visibleTStart * clipSpeed * sampleRate)); + const endSample = Math.min(totalSamples, Math.ceil(visibleTEnd * clipSpeed * sampleRate)); + ctx.beginPath(); + let first = true; + const maxSamples = 50000; + const vectorStep = Math.max(1, Math.floor((endSample - startSample) / maxSamples)); + for (let i = startSample; i < endSample; i += vectorStep) { + const sampleTime = i / sampleRate / clipSpeed; + const pxLocal = xStartLocal + sampleTime * zoom; + const y = mid - data[i] * peakRatio; + if (first) { + ctx.moveTo(pxLocal, y); + first = false; + } else { + ctx.lineTo(pxLocal, y); + } } - return bestSample / sampleRate; + ctx.stroke(); + + // Draw granular sample nodes only at extreme zoom (samplesPerPixel < 0.3) + if (samplesPerPixel < 0.3) { + const maxNodes = 5000; + const step = Math.max(1, Math.floor((endSample - startSample) / maxNodes)); + ctx.fillStyle = '#6ee7b7'; + let drawn = 0; + for (let i = startSample; i < endSample && drawn < maxNodes; i += step) { + const sampleTime = i / sampleRate / clipSpeed; + const pxLocal = xStartLocal + sampleTime * zoom; + const y = mid - data[i] * peakRatio; + ctx.fillRect(pxLocal - 1, y - 1, 2, 2); + drawn++; + } + } + } else { + // Coarse view vertical peak min/max bars per pixel + for (let pxLocal = drawXStartLocal; pxLocal < drawXEndLocal; pxLocal++) { + const timeInClip = (pxLocal - xStartLocal) / zoom * clipSpeed; + const sampleIdx = Math.floor(timeInClip * sampleRate); + const chunkSize = Math.max(1, Math.floor(samplesPerPixel)); + const chunkStart = Math.max(0, sampleIdx - Math.floor(chunkSize / 2)); + const chunkEnd = Math.min(totalSamples, chunkStart + chunkSize); + let maxVal = 0; + for (let i = chunkStart; i < chunkEnd; i++) { + const abs = Math.abs(data[i]); + if (abs > maxVal) maxVal = abs; + } + const peakHeight = maxVal * peakRatio; + ctx.beginPath(); + ctx.moveTo(pxLocal, mid - peakHeight); + ctx.lineTo(pxLocal, mid + peakHeight); + ctx.stroke(); + } + } + }); + }); + } else { + ctx.fillStyle = '#444'; + ctx.font = '12px Inter, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('Kéo thả hoặc tải file âm thanh vào Track này', drawWidth / 2, height / 2); + } + + // Selection highlight - local selection on this track + if (selectionMode === 'local' && localSelectionTrackId === track.id && localSelLeft !== null && localSelRight !== null && localSelRight > localSelLeft) { + const hlLeftLocal = localSelLeft * zoom - scrollLeft; + const hlWidth = (localSelRight - localSelLeft) * zoom; + ctx.fillStyle = 'rgba(245, 158, 11, 0.15)'; + ctx.fillRect(hlLeftLocal, 0, hlWidth, height); + ctx.strokeStyle = '#f59e0b'; + ctx.lineWidth = 1; + ctx.strokeRect(hlLeftLocal, 0, hlWidth, height); + } + }, [track, zoom, timelineWidth, viewportWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight, snapValue, bpm]); + return /*#__PURE__*/React.createElement("div", { + style: { + width: `${timelineWidth}px`, + height: '100%', + position: 'relative', + overflow: 'hidden' + } + }, /*#__PURE__*/React.createElement("canvas", { + ref: canvasRef, + style: { + position: 'absolute', + top: 0, + left: 0, + imageRendering: 'pixelated' + }, + className: "cursor-crosshair", + onMouseMove: e => { + if (!canvasRef.current) return; + const rect = canvasRef.current.getBoundingClientRect(); + const wrapper = canvasRef.current?.parentElement?.parentElement; + const scrollLeft = wrapper ? wrapper.scrollLeft : 0; + const x = e.clientX - rect.left + scrollLeft; + const time = x / zoom; + const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ + id: 'default', + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name, + speed: track.speed || 1.0 + }] : []; + + // 1. Shift+Drag (TOP PRIORITY): Range selection on track waveform + if (e.shiftKey && e.buttons > 0) { + e.preventDefault(); + e.stopPropagation(); + const currentAnchor = getLocalAnchor ? getLocalAnchor() : null; + const anchor = currentAnchor !== null && currentAnchor !== undefined ? currentAnchor : localSelectionStart !== null ? localSelectionStart : currentTime; + const selS = Math.min(anchor, time); + const selE = Math.max(anchor, time); + if (onSetSelectionMode) onSetSelectionMode('local'); + if (onSetLocalSelectionTrackId) onSetLocalSelectionTrackId(track.id); + if (onSetLocalSelectionStart) onSetLocalSelectionStart(selS); + if (onSetLocalSelectionEnd) onSetLocalSelectionEnd(selE); + if (onSetSelectionStart) onSetSelectionStart(selS); + if (onSetSelectionEnd) onSetSelectionEnd(selE); + if (onSetCurrentTime) onSetCurrentTime(time); + if (onSelectTrack) onSelectTrack(track.id); + return; + } + + // Check if hovering near local selection boundaries of this track + const isLocal = selectionMode === 'local' && localSelectionTrackId === track.id; + if (isLocal && localSelLeft !== null && localSelRight !== null) { + const leftPx = localSelLeft * zoom; + const rightPx = localSelRight * zoom; + const distToLeft = Math.abs(x - leftPx); + const distToRight = Math.abs(x - rightPx); + if (distToLeft <= 5 || distToRight <= 5) { + canvasRef.current.style.cursor = 'ew-resize'; + return; + } + } + + // Check if hovering near right edge of a clip for time-stretching (Alt key required) + const toleranceSec = 8 / zoom; + const rightEdgeClip = clips.find(c => { + const duration = c.buffer.duration / (c.speed || 1.0); + return Math.abs(time - (c.startTime + duration)) <= toleranceSec; + }); + if (rightEdgeClip && e.altKey) { + canvasRef.current.style.cursor = 'ew-resize'; + return; + } + const hoveredClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); + const isOverClip = !!hoveredClip; + if (activeTool === 'pen') { + canvasRef.current.style.cursor = isOverClip ? 'copy' : 'not-allowed'; + } else if (activeTool === 'grab') { + canvasRef.current.style.cursor = isOverClip ? 'grab' : 'default'; + } else if (activeTool === 'razor') { + canvasRef.current.style.cursor = isOverClip ? 'cell' : 'not-allowed'; + } else { + // select tool + canvasRef.current.style.cursor = isOverClip && (e.altKey || e.ctrlKey) ? 'grab' : 'crosshair'; + } + }, + onMouseDown: e => { + // Ignore right-click for local selection drag (context menu handles it) + if (e.button === 2) return; + const rect = canvasRef.current.getBoundingClientRect(); + const wrapper = canvasRef.current?.parentElement?.parentElement; + const scrollLeft = wrapper ? wrapper.scrollLeft : 0; + const x = e.clientX - rect.left + scrollLeft; + const time = Math.max(0, x / zoom); + onSelectTrack(track.id); + const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ + id: 'default', + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name, + speed: track.speed || 1.0 + }] : []; + + // 1. Shift+Click (TOP PRIORITY): Range selection on track waveform + if (e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + const currentAnchor = getLocalAnchor ? getLocalAnchor() : null; + const anchor = currentAnchor !== null && currentAnchor !== undefined ? currentAnchor : localSelectionStart !== null ? localSelectionStart : currentTime; + const selS = Math.min(anchor, time); + const selE = Math.max(anchor, time); + if (onSetSelectionMode) onSetSelectionMode('local'); + if (onSetLocalSelectionTrackId) onSetLocalSelectionTrackId(track.id); + if (onSetLocalSelectionStart) onSetLocalSelectionStart(selS); + if (onSetLocalSelectionEnd) onSetLocalSelectionEnd(selE); + if (onSetSelectionStart) onSetSelectionStart(selS); + if (onSetSelectionEnd) onSetSelectionEnd(selE); + if (onSetCurrentTime) onSetCurrentTime(time); + if (onSelectTrack) onSelectTrack(track.id); + return; + } + + // Check if dragging selection boundaries (local mode) + const isLocal = selectionMode === 'local' && localSelectionTrackId === track.id; + if (isLocal && localSelLeft !== null && localSelRight !== null) { + const leftPx = localSelLeft * zoom; + const rightPx = localSelRight * zoom; + const distToLeft = Math.abs(x - leftPx); + const distToRight = Math.abs(x - rightPx); + if (distToLeft <= 5) { + e.preventDefault(); + e.stopPropagation(); + if (onSelectionEdgeDragStart) onSelectionEdgeDragStart(e, track.id, 'left'); + return; + } else if (distToRight <= 5) { + e.preventDefault(); + e.stopPropagation(); + if (onSelectionEdgeDragStart) onSelectionEdgeDragStart(e, track.id, 'right'); + return; + } + } + + // Check if time-stretching (Alt + Right Edge) + const toleranceSec = 8 / zoom; + const rightEdgeClip = clips.find(c => { + const duration = c.buffer.duration / (c.speed || 1.0); + return Math.abs(time - (c.startTime + duration)) <= toleranceSec; + }); + if (rightEdgeClip && e.altKey) { + e.preventDefault(); + e.stopPropagation(); + if (onClipStretchStart) { + onClipStretchStart(track.id, rightEdgeClip.id, time); + } + return; + } + const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); + + // Set selected clip ID + if (clickedClip) { + setSelectedClipId({ + trackId: track.id, + clipId: clickedClip.id === 'default' ? 'default_' + track.id : clickedClip.id + }); + } else { + setSelectedClipId(null); + } + if (activeTool === 'pen' && !e.ctrlKey) { + if (clickedClip) { + e.preventDefault(); + e.stopPropagation(); + if (onEditClipInSubTab) { + onEditClipInSubTab(track.id, clickedClip.id); + } + } + return; + } + if (activeTool === 'razor' && !e.ctrlKey) { + if (clickedClip) { + e.preventDefault(); + e.stopPropagation(); + if (onSplitTrackAtTime) { + onSplitTrackAtTime(track.id, clickedClip.id, time); + } + } + return; + } + if (activeTool === 'grab') { + if (onTrackLaneMouseDown) { + onTrackLaneMouseDown(track.id, time, e); + } + if (clickedClip) { + e.preventDefault(); + e.stopPropagation(); + if (onClipDragStart) { + onClipDragStart(track.id, clickedClip.id, time - clickedClip.startTime, e.ctrlKey); + } + } else { + onPlayheadSet(time); + } + return; + } + + // Check for click drag clip (Alt to move, Ctrl to duplicate) + if (clickedClip && (e.altKey || e.ctrlKey)) { + e.preventDefault(); + e.stopPropagation(); + if (onClipDragStart) { + onClipDragStart(track.id, clickedClip.id, time - clickedClip.startTime, e.ctrlKey); + } + return; + } + + // Check if Ctrl+Click outside active local selection to clear + if (e.ctrlKey) { + if (onClearLocalSelection) onClearLocalSelection(); + if (onSetSelectionMode) onSetSelectionMode(null); + if (onSetSelectionStart) onSetSelectionStart(null); + if (onSetSelectionEnd) onSetSelectionEnd(null); + return; + } + onPlayheadSet(time); + if (onTrackLaneMouseDown) { + onTrackLaneMouseDown(track.id, time, e); + } + e.stopPropagation(); + }, + onDoubleClick: e => { + const rect = canvasRef.current.getBoundingClientRect(); + const wrapper = canvasRef.current?.parentElement?.parentElement; + const scrollLeft = wrapper ? wrapper.scrollLeft : 0; + const x = e.clientX - rect.left + scrollLeft; + const time = Math.max(0, x / zoom); + const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ + id: 'default', + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name, + speed: track.speed || 1.0 + }] : []; + const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); + if (clickedClip) { + e.preventDefault(); + e.stopPropagation(); + if (onEditClipInSubTab) { + onEditClipInSubTab(track.id, clickedClip.id); + } + } + }, + onContextMenu: e => { + e.preventDefault(); + e.stopPropagation(); + onSelectTrack(track.id); + const rect = canvasRef.current.getBoundingClientRect(); + const wrapper = canvasRef.current?.parentElement?.parentElement; + const scrollLeft = wrapper ? wrapper.scrollLeft : 0; + const x = e.clientX - rect.left + scrollLeft; + const time = Math.max(0, x / zoom); + if (onContextMenu) onContextMenu(e, track.id, time); + } + })); +}; +const TempoTrackLane = ({ + bpm, + zoom, + timelineWidth, + viewportWidth, + onPlayheadSet, + snapValue, + onRulerMouseDown +}) => { + const canvasRef = useRef(null); + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null; + const scrollLeft = wrapper ? wrapper.scrollLeft : 0; + const vWidth = viewportWidth || (wrapper ? wrapper.clientWidth : 1200); + const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200)); + const height = canvas.parentElement ? canvas.parentElement.clientHeight : 40; + canvas.width = Math.round(drawWidth * dpr); + canvas.height = Math.round(height * dpr); + ctx.scale(dpr, dpr); + ctx.imageSmoothingEnabled = false; + canvas.style.position = 'absolute'; + canvas.style.left = `${scrollLeft}px`; + canvas.style.width = `${drawWidth}px`; + canvas.style.height = `${height}px`; + ctx.fillStyle = '#1a1a2e'; + ctx.fillRect(0, 0, drawWidth, height); + const beatDuration = 60 / bpm; + const barDuration = beatDuration * 4; + const tStart = scrollLeft / zoom; + const tEnd = (scrollLeft + drawWidth) / zoom; + const firstBeat = Math.floor(tStart / beatDuration) * beatDuration; + for (let t = firstBeat; t <= tEnd; t += beatDuration) { + const beatNum = Math.floor(t / beatDuration) + 1; + const isBar = beatNum % 4 === 1; + const localX = (t - tStart) * zoom; + if (isBar) { + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(localX, 0); + ctx.lineTo(localX, height); + ctx.stroke(); + ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'; + ctx.font = 'bold 9px Inter, sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText(`${Math.ceil(beatNum / 4)}`, localX + 3, 11); + } else { + ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(localX, 0); + ctx.lineTo(localX, height); + ctx.stroke(); + } + } + + // Draw snap sub-ticks at the bottom + if (snapValue && snapValue !== 'free') { + ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)'; + ctx.lineWidth = 0.8; + let divisor = 1; + if (snapValue === '1') divisor = 1;else if (snapValue === '1/2') divisor = 0.5;else if (snapValue === '1/4') divisor = 0.25;else if (snapValue === '1/8') divisor = 0.125;else if (snapValue === '1/16') divisor = 0.0625;else if (snapValue === '1/32') divisor = 0.03125; + const snapInterval = beatDuration * divisor; + if (snapInterval * zoom >= 4) { + const firstSnap = Math.floor(tStart / snapInterval) * snapInterval; + for (let t = firstSnap; t <= tEnd; t += snapInterval) { + const onBeat = Math.abs(t / beatDuration - Math.round(t / beatDuration)) < 0.001; + if (!onBeat) { + const localX = (t - tStart) * zoom; + ctx.beginPath(); + ctx.moveTo(localX, height - 6); + ctx.lineTo(localX, height); + ctx.stroke(); + } + } + } + } + ctx.fillStyle = 'rgba(255, 255, 255, 0.35)'; + ctx.font = 'bold 10px Inter, sans-serif'; + ctx.textAlign = 'right'; + ctx.fillText(`${bpm} BPM`, drawWidth - 6, 12); + }, [bpm, zoom, timelineWidth, viewportWidth, snapValue]); + return /*#__PURE__*/React.createElement("div", { + style: { + width: `${timelineWidth}px`, + height: '100%', + position: 'relative', + overflow: 'hidden' + } + }, /*#__PURE__*/React.createElement("canvas", { + ref: canvasRef, + style: { + position: 'absolute', + top: 0, + left: 0, + imageRendering: 'pixelated' + }, + className: "cursor-crosshair", + onMouseDown: e => { + const wrapper = canvasRef.current?.parentElement?.parentElement; + const scrollLeft = wrapper ? wrapper.scrollLeft : 0; + const rect = canvasRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left + scrollLeft; + const time = Math.max(0, x / zoom); + if (e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + } + if (onRulerMouseDown) { + onRulerMouseDown(e); + } else { + onPlayheadSet(time, e.shiftKey); + } + } + })); +}; + +// ── 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, + zoom, + timelineWidth, + color, + name, + speed = 1.0, + onSpeedChange, + volumeNodes = [], + panningNodes = [], + fadeInLen = 0, + fadeOutLen = 0, + graphMode = null, + onUpdateNodes, + onUpdateFade, + onModeToggle, + selectedNodeTime, + setSelectedNodeTime, + channelInfo = null +}) => { + const canvasRef = useRef(null); + const isStretchingRef = useRef(false); + const stretchStartRef = useRef({ + mouseX: 0, + originalDuration: 0, + originalSpeed: 1.0 + }); + const subTabAnchorRef = useRef(null); + const isStereo = channelInfo ? channelInfo.isStereo : buffer && buffer.numberOfChannels >= 2; + const channelLabel = channelInfo ? channelInfo.label : isStereo ? 'STEREO' : 'MONO'; + // Mono: force volume mode (panning not applicable) + const effectiveGraphMode = !isStereo && graphMode === 'pan' ? null : graphMode; + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !buffer) return; + const ctx = canvas.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null; + const scrollLeft = wrapper ? wrapper.scrollLeft : 0; + const vWidth = wrapper ? wrapper.clientWidth : 1200; + const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200)); + const h = canvas.parentElement ? canvas.parentElement.clientHeight : 200; + canvas.width = Math.round(drawWidth * dpr); + canvas.height = Math.round(h * dpr); + ctx.scale(dpr, dpr); + ctx.imageSmoothingEnabled = false; + canvas.style.position = 'absolute'; + canvas.style.left = `${scrollLeft}px`; + canvas.style.width = `${drawWidth}px`; + canvas.style.height = `${h}px`; + ctx.fillStyle = '#181818'; + ctx.fillRect(0, 0, drawWidth, h); + const data = buffer.getChannelData(0); + const len = data.length; + if (len === 0) return; + + // Helper to compute volume gain at a specific time in clip using Monotone Cubic Hermite Spline + const computeHermiteTangents = pts => { + const n = pts.length; + if (n < 2) return []; + const m = new Array(n); + for (let i = 1; i < n - 1; i++) { + const hP = pts[i].time - pts[i - 1].time; + const hN = pts[i + 1].time - pts[i].time; + const sP = (pts[i].db - pts[i - 1].db) / hP; + const sN = (pts[i + 1].db - pts[i].db) / hN; + m[i] = (sP + sN) / 2; + } + m[0] = n > 1 ? (pts[1].db - pts[0].db) / (pts[1].time - pts[0].time) : 0; + m[n - 1] = n > 1 ? (pts[n - 1].db - pts[n - 2].db) / (pts[n - 1].time - pts[n - 2].time) : 0; + return m; + }; + const getVolumeDbAtTime = t => { + const volNodes = volumeNodes || []; + if (volNodes.length === 0) return 0.0; + const sortedNodes = [...volNodes].sort((a, b) => a.time - b.time); + if (sortedNodes.length === 1) return sortedNodes[0].db; + if (t <= sortedNodes[0].time) return sortedNodes[0].db; + if (t >= sortedNodes[sortedNodes.length - 1].time) return sortedNodes[sortedNodes.length - 1].db; + const tangents = computeHermiteTangents(sortedNodes); + for (let i = 0; i < sortedNodes.length - 1; i++) { + const n1 = sortedNodes[i]; + const n2 = sortedNodes[i + 1]; + if (t >= n1.time && t <= n2.time) { + const h = n2.time - n1.time; + if (h <= 0) return n1.db; + const frac = (t - n1.time) / h; + const frac2 = frac * frac, + frac3 = frac2 * frac; + return (2 * frac3 - 3 * frac2 + 1) * n1.db + (frac3 - 2 * frac2 + frac) * h * tangents[i] + (-2 * frac3 + 3 * frac2) * n2.db + (frac3 - frac2) * h * tangents[i + 1]; + } + } + return 0.0; + }; + const getVolumeGainAtTime = t => { + return Math.pow(10, getVolumeDbAtTime(t) / 20); + }; + const computeHermiteTangentsForPan = pts => { + const n = pts.length; + if (n < 2) return []; + const m = new Array(n); + for (let i = 1; i < n - 1; i++) { + const hP = pts[i].time - pts[i - 1].time; + const hN = pts[i + 1].time - pts[i].time; + const sP = (pts[i].pan - pts[i - 1].pan) / hP; + const sN = (pts[i + 1].pan - pts[i].pan) / hN; + m[i] = (sP + sN) / 2; + } + m[0] = n > 1 ? (pts[1].pan - pts[0].pan) / (pts[1].time - pts[0].time) : 0; + m[n - 1] = n > 1 ? (pts[n - 1].pan - pts[n - 2].pan) / (pts[n - 1].time - pts[n - 2].time) : 0; + return m; + }; + const getPanningValueAtTime = t => { + const panNodes = panningNodes || []; + if (panNodes.length === 0) return 0; + const sortedNodes = [...panNodes].sort((a, b) => a.time - b.time); + if (sortedNodes.length === 1) return Math.round(sortedNodes[0].pan * 100); + if (t <= sortedNodes[0].time) return Math.round(sortedNodes[0].pan * 100); + if (t >= sortedNodes[sortedNodes.length - 1].time) return Math.round(sortedNodes[sortedNodes.length - 1].pan * 100); + const tangents = computeHermiteTangentsForPan(sortedNodes); + for (let i = 0; i < sortedNodes.length - 1; i++) { + const n1 = sortedNodes[i]; + const n2 = sortedNodes[i + 1]; + if (t >= n1.time && t <= n2.time) { + const h = n2.time - n1.time; + if (h <= 0) return Math.round(n1.pan * 100); + const frac = (t - n1.time) / h; + const frac2 = frac * frac, + frac3 = frac2 * frac; + const val = (2 * frac3 - 3 * frac2 + 1) * n1.pan + (frac3 - 2 * frac2 + frac) * h * tangents[i] + (-2 * frac3 + 3 * frac2) * n2.pan + (frac3 - frac2) * h * tangents[i + 1]; + return Math.round(val * 100); + } + } + return 0; + }; + + // Draw clip container (like main session clips) + const xStart = 0; + const wClip = buffer.duration / speed * zoom; // speed-adjusted width + const xEnd = xStart + wClip; + const clipTop = 8; + const clipHeight = h - 16; + const clipColor = color || '#06b6d4'; + ctx.fillStyle = clipColor + '22'; + ctx.strokeStyle = clipColor; + ctx.lineWidth = 1.5; + if (ctx.roundRect) { + ctx.beginPath(); + ctx.roundRect(xStart, clipTop, wClip, clipHeight, 4); + ctx.fill(); + ctx.stroke(); + } else { + ctx.fillRect(xStart, clipTop, wClip, clipHeight); + ctx.strokeRect(xStart, clipTop, wClip, clipHeight); + } + + // Draw clip label with speed % + ctx.fillStyle = '#e4e4e7'; + ctx.font = 'bold 10px sans-serif'; + let displayName = name || 'Audio Clip'; + if (speed !== 1.0) { + displayName += ` (${Math.round(speed * 100)}%)`; + } + ctx.fillText(displayName, xStart + 8, clipTop + 14); + + // ── Graph Grid & Axes ── + const drawGrid = true; + if (drawGrid) { + // Background fill + ctx.fillStyle = '#181818'; + ctx.fillRect(xStart, clipTop, wClip, clipHeight); + + // Vertical grid lines (time markers) + ctx.strokeStyle = '#2a2a2a'; + ctx.lineWidth = 0.5; + ctx.setLineDash([]); + const timeStep = Math.max(0.1, Math.ceil(buffer.duration / 20 * 10) / 10); + for (let t = 0; t <= buffer.duration; t += timeStep) { + const px = t * zoom; + ctx.beginPath(); + ctx.moveTo(px, clipTop); + ctx.lineTo(px, clipTop + clipHeight); + ctx.stroke(); + } + + // Always draw the reference Volume 0dB Axis (White) and Panning Center Axis (Brown) + const volZeroY = clipTop + 1 / 3 * clipHeight; + const panZeroY = clipTop + 1 / 2 * clipHeight; + + // White line for volume 0dB + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.moveTo(xStart, volZeroY); + ctx.lineTo(xStart + wClip, volZeroY); + ctx.stroke(); + + // Brown line for panning center + ctx.strokeStyle = '#854d0e'; + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.moveTo(xStart, panZeroY); + ctx.lineTo(xStart + wClip, panZeroY); + ctx.stroke(); + + // Horizontal grid lines (other value markers) + const isPanMode = effectiveGraphMode === 'pan'; + if (isPanMode) { + for (let p = -100; p <= 100; p += 20) { + if (p === 0) continue; + const y = clipTop + clipHeight * (1 - (p / 100 + 1) / 2); + ctx.strokeStyle = '#2a2a2a'; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(xStart, y); + ctx.lineTo(xStart + wClip, y); + ctx.stroke(); + } + } else { + for (let db = -30; db <= 3; db += 3) { + if (db === 0) continue; + const y = db >= 0 ? volZeroY - db / 3 * (2 / 3 * clipHeight) : volZeroY + -db / 30 * (1 / 3 * clipHeight); + ctx.strokeStyle = '#2a2a2a'; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(xStart, y); + ctx.lineTo(xStart + wClip, y); + ctx.stroke(); + } + } + + // Y-axis labels (left side) + ctx.fillStyle = '#71717a'; + ctx.font = '7px monospace'; + ctx.textAlign = 'right'; + if (isPanMode) { + ctx.fillText('L100', xStart - 2, clipTop + 8); + ctx.fillText('R50', xStart - 2, clipTop + clipHeight * 0.25 + 2); + ctx.fillStyle = '#854d0e'; // Brown label for active Panning Center + ctx.fillText('C (Pan)', xStart - 2, clipTop + clipHeight * 0.5 + 2); + ctx.fillStyle = '#71717a'; + ctx.fillText('L50', xStart - 2, clipTop + clipHeight * 0.75 + 2); + ctx.fillText('R100', xStart - 2, clipTop + clipHeight - 2); + } else { + ctx.fillText('+3dB', xStart - 2, clipTop + 8); + ctx.fillStyle = '#ffffff'; // White label for active Volume 0dB + ctx.fillText('0dB (Vol)', xStart - 2, volZeroY + 2); + ctx.fillStyle = '#71717a'; + ctx.fillText('-15dB', xStart - 2, volZeroY + clipHeight / 6 + 2); + ctx.fillText('-30dB', xStart - 2, clipTop + clipHeight - 2); + } + ctx.textAlign = 'start'; + } + + // Drag hint labels for fade endpoints + ctx.fillStyle = '#71717a'; + ctx.font = '8px sans-serif'; + ctx.fillText('Kéo FI/FO trên đường cong để điều chỉnh', 8, clipTop + clipHeight + 12); + + // Display speed percentage in the bottom left corner of the waveform area + ctx.fillStyle = '#e4e4e7'; + ctx.font = 'bold 9px sans-serif'; + ctx.fillText(`Tốc độ: ${Math.round(speed * 100)}%`, xStart + 8, clipTop + clipHeight - 8); + + // Mode toggle button on waveform (bottom-right) + const modeBtnW = 28; + const modeBtnH = 14; + const modeBtnX = wClip - modeBtnW - 4; + const modeBtnY = clipTop + clipHeight - modeBtnH - 2; + const isPanMode = effectiveGraphMode === 'pan'; + ctx.fillStyle = isPanMode ? 'rgba(168, 85, 247, 0.5)' : 'rgba(6, 182, 212, 0.5)'; + ctx.beginPath(); + ctx.roundRect(modeBtnX, modeBtnY, modeBtnW, modeBtnH, 3); + ctx.fill(); + ctx.fillStyle = '#fff'; + ctx.font = 'bold 7px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(isPanMode ? 'PAN' : 'VOL', modeBtnX + modeBtnW / 2, modeBtnY + 10); + ctx.textAlign = 'start'; + + // Channel label (L / R for stereo, M for mono) + ctx.fillStyle = '#a1a1aa'; + ctx.font = 'bold 8px monospace'; + if (isStereo) { + ctx.fillText('L', xStart + 2, clipTop + clipHeight * 0.28); + ctx.fillText('R', xStart + 2, clipTop + clipHeight * 0.72); + } else { + ctx.fillText('M', xStart + 2, clipTop + clipHeight / 2); + } + + // Draw waveform inside clip (speed-adjusted) with fade envelope applied + const drawXStart = Math.max(0, Math.floor(xStart)); + const drawXEnd = Math.min(drawWidth, Math.ceil(xEnd)); + const samplesPerPixel = buffer.sampleRate / zoom * speed; + const bufDur = buffer.duration; + const mid = h / 2; + const peakRatio = clipHeight * 0.45; + const getFadeGainAtTime = t => { + if (fadeInLen > 0 && t < fadeInLen) { + return (1 - Math.cos(Math.PI * t / fadeInLen)) / 2; + } else if (fadeOutLen > 0 && t > bufDur - fadeOutLen) { + const ratio = (t - (bufDur - fadeOutLen)) / fadeOutLen; + return (1 + Math.cos(Math.PI * ratio)) / 2; + } + return 1; + }; + ctx.strokeStyle = '#5bc0be'; + ctx.lineWidth = 1.2; + if (samplesPerPixel < 4) { + // High-zoom continuous vector line rendering (#5bc0be Cornflower Blue) + const startSample = Math.max(0, Math.floor((drawXStart - xStart) / zoom * speed * buffer.sampleRate)); + const endSample = Math.min(len, Math.ceil((drawXEnd - xStart) / zoom * speed * buffer.sampleRate)); + ctx.beginPath(); + let first = true; + const maxSamples = 50000; + const vectorStep = Math.max(1, Math.floor((endSample - startSample) / maxSamples)); + for (let i = startSample; i < endSample; i += vectorStep) { + const timeInClip = i / buffer.sampleRate / speed; + const px = xStart + timeInClip * zoom; + const fadeGain = getFadeGainAtTime(timeInClip); + const volGain = getVolumeGainAtTime(px / zoom); + const totalGain = fadeGain * volGain; + const y = mid - data[i] * totalGain * peakRatio; + if (first) { + ctx.moveTo(px, y); + first = false; + } else { + ctx.lineTo(px, y); + } + } + ctx.stroke(); + + // Draw granular sample nodes only at extreme zoom (samplesPerPixel < 0.3) + if (samplesPerPixel < 0.3) { + const maxNodes = 5000; + const step = Math.max(1, Math.floor((endSample - startSample) / maxNodes)); + ctx.fillStyle = '#6ee7b7'; + let drawn = 0; + for (let i = startSample; i < endSample && drawn < maxNodes; i += step) { + const timeInClip = i / buffer.sampleRate / speed; + const px = xStart + timeInClip * zoom; + const fadeGain = getFadeGainAtTime(timeInClip); + const volGain = getVolumeGainAtTime(px / zoom); + const totalGain = fadeGain * volGain; + const y = mid - data[i] * totalGain * peakRatio; + ctx.fillRect(px - 1, y - 1, 2, 2); + drawn++; + } + } + } else { + // Coarse view vertical peak min/max bars + for (let px = drawXStart; px < drawXEnd; px++) { + const timeInClip = px / zoom * speed; + const sampleIdx = Math.floor(timeInClip * 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 fadeGain = getFadeGainAtTime(timeInClip); + const volGain = getVolumeGainAtTime(px / zoom); + const totalGain = fadeGain * volGain; + let maxVal = 0; + let minVal = 0; + for (let i = chunkStart; i < chunkEnd; i++) { + const val = data[i]; + if (val > maxVal) maxVal = val; + if (val < minVal) minVal = val; + } + maxVal *= totalGain; + minVal *= totalGain; + const yTop = mid + minVal * peakRatio; + const yBottom = mid + maxVal * peakRatio; + ctx.beginPath(); + ctx.moveTo(px, yTop); + ctx.lineTo(px, yBottom); + ctx.stroke(); + } + } + + // Volume: 0dB at 1/3 from top + const autoY = node => { + const db = typeof node === 'number' ? node : node.db; + const zeroY = clipTop + 1 / 3 * clipHeight; + return db >= 0 ? zeroY - db / 3 * (1 / 3 * clipHeight) : zeroY + -db / 30 * (2 / 3 * clipHeight); + }; + // Panning: 0 at center + const autoPanY = node => { + const pan = typeof node === 'number' ? node : node.pan; + return clipTop + (1 - (pan + 1) / 2) * clipHeight; + }; + + // Helper: compute tangents for monotone Hermite spline + const computeTangents = (pts, yFn) => { + const n = pts.length; + if (n < 2) return []; + const m = new Array(n); + for (let i = 1; i < n - 1; i++) { + const hP = pts[i].time - pts[i - 1].time; + const hN = pts[i + 1].time - pts[i].time; + const sP = (yFn(pts[i]) - yFn(pts[i - 1])) / hP; + const sN = (yFn(pts[i + 1]) - yFn(pts[i])) / hN; + m[i] = (sP + sN) / 2; + } + m[0] = n > 1 ? (yFn(pts[1]) - yFn(pts[0])) / (pts[1].time - pts[0].time) : 0; + m[n - 1] = n > 1 ? (yFn(pts[n - 1]) - yFn(pts[n - 2])) / (pts[n - 1].time - pts[n - 2].time) : 0; + return m; + }; + + // Helper: evaluate Hermite at pixel position px + const hermiteY = (px, x0, y0, m0, x1, y1, m1) => { + const h = x1 - x0; + if (h <= 0) return y0; + const t = (px - x0) / h; + const t2 = t * t, + t3 = t2 * t; + return (2 * t3 - 3 * t2 + 1) * y0 + (t3 - 2 * t2 + t) * h * m0 + (-2 * t3 + 3 * t2) * y1 + (t3 - t2) * h * m1; + }; + + // Draw automation curve with Hermite spline + const drawSpline = (nodes, yFn, color, lineDash) => { + if (nodes.length < 2) { + if (nodes.length === 1) { + const px = nodes[0].time * zoom; + const y = yFn(nodes[0]); + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(px, y, 4, 0, Math.PI * 2); + ctx.fill(); + } + return; + } + const tangents = computeTangents(nodes, yFn); + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.setLineDash(lineDash || []); + ctx.beginPath(); + for (let i = 0; i < nodes.length - 1; i++) { + const x0 = nodes[i].time * zoom, + y0 = yFn(nodes[i]); + const x1 = nodes[i + 1].time * zoom, + y1 = yFn(nodes[i + 1]); + const t0 = nodes[i].time, + t1 = nodes[i + 1].time; + const m0 = tangents[i], + m1 = tangents[i + 1]; + for (let px = Math.floor(x0); px < Math.ceil(x1); px++) { + const t = px / zoom; + const y = hermiteY(t, t0, y0, m0, t1, y1, m1); + if (px === Math.floor(x0) && i === 0) ctx.moveTo(px, y);else ctx.lineTo(px, y); + } + } + ctx.stroke(); + ctx.setLineDash([]); + + // Draw node handles + nodes.forEach(n => { + const px = n.time * zoom, + y = yFn(n); + const isSelected = n.time === selectedNodeTime; + ctx.fillStyle = isSelected ? '#ffebb3' : '#fff'; + ctx.beginPath(); + ctx.arc(px, y, isSelected ? 6 : 4, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = isSelected ? '#fbbf24' : color; + ctx.lineWidth = isSelected ? 2.5 : 1.5; + ctx.beginPath(); + ctx.arc(px, y, isSelected ? 6 : 4, 0, Math.PI * 2); + ctx.stroke(); + + // Display value at node + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 12px sans-serif'; + const labelText = isPanMode ? n.pan > 0 ? 'R' + Math.round(n.pan * 100) : n.pan < 0 ? 'L' + Math.round(Math.abs(n.pan) * 100) : 'C' : `${n.db >= 0 ? '+' : ''}${n.db.toFixed(1)}dB`; + ctx.fillText(labelText, px + 8, y + 4); + }); + }; + drawSpline(volumeNodes, autoY, '#f43f5e'); + drawSpline(panningNodes, autoPanY, '#a855f7', [4, 4]); + + // ── Fade Curves (transparent, only curve lines + endpoint handles) ── + const FADE_COLOR = '#b91c1c'; + const HANDLE_RADIUS = 5; + if (fadeInLen >= 0) { + const fiPx = fadeInLen * zoom; + if (fadeInLen > 0) { + ctx.strokeStyle = FADE_COLOR; + ctx.lineWidth = 1.8; + ctx.setLineDash([]); + ctx.beginPath(); + ctx.moveTo(0, clipTop + clipHeight); + for (let px = 0; px <= fiPx; px++) { + const ratio = px / fiPx; + const amp = (1 - Math.cos(Math.PI * ratio)) / 2; + const y = clipTop + clipHeight - amp * clipHeight; + ctx.lineTo(px, y); + } + ctx.stroke(); + } + + // Endpoint handle (draggable) + const endX = fiPx, + endY = clipTop; + ctx.fillStyle = '#fff'; + ctx.beginPath(); + ctx.arc(endX, endY, HANDLE_RADIUS, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = FADE_COLOR; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(endX, endY, HANDLE_RADIUS, 0, Math.PI * 2); + ctx.stroke(); + // Label + ctx.fillStyle = '#b91c1c'; + ctx.font = 'bold 7px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('FI', endX, endY - HANDLE_RADIUS - 3); + ctx.textAlign = 'start'; + } + if (fadeOutLen >= 0) { + const foPx = fadeOutLen * zoom; + const startX = wClip - foPx; + if (fadeOutLen > 0) { + ctx.strokeStyle = FADE_COLOR; + ctx.lineWidth = 1.8; + ctx.setLineDash([]); + ctx.beginPath(); + ctx.moveTo(startX, clipTop); + for (let px = 0; px <= foPx; px++) { + const ratio = px / foPx; + const amp = (1 + Math.cos(Math.PI * ratio)) / 2; + const y = clipTop + clipHeight - amp * clipHeight; + ctx.lineTo(startX + px, y); + } + ctx.stroke(); + } + + // Endpoint handle (draggable) + const handleX = startX, + handleY = clipTop; + ctx.fillStyle = '#fff'; + ctx.beginPath(); + ctx.arc(handleX, handleY, HANDLE_RADIUS, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = FADE_COLOR; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(handleX, handleY, HANDLE_RADIUS, 0, Math.PI * 2); + ctx.stroke(); + ctx.fillStyle = '#b91c1c'; + ctx.font = 'bold 7px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('FO', handleX, handleY - HANDLE_RADIUS - 3); + ctx.textAlign = 'start'; + } + + // Draw right-edge stretch handle indicator + if (wClip > 0 && wClip < drawWidth) { + ctx.strokeStyle = clipColor; + ctx.lineWidth = 1.5; + ctx.setLineDash([3, 3]); + ctx.beginPath(); + ctx.moveTo(wClip + xStart, clipTop); + ctx.lineTo(wClip + xStart, clipTop + clipHeight); + ctx.stroke(); + ctx.setLineDash([]); + } + + // 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 * zoom; + const rightPx = right * zoom; + ctx.fillStyle = 'rgba(245, 158, 11, 0.15)'; + ctx.fillRect(leftPx, 0, rightPx - leftPx, h); + ctx.strokeStyle = '#f59e0b'; + ctx.lineWidth = 1; + ctx.beginPath(); + 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 / speed) { + const playheadPx = currentTime * zoom; + ctx.strokeStyle = '#ef4444'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(playheadPx, 0); + ctx.lineTo(playheadPx, h); + ctx.stroke(); + } + + // Update TCP slider values in real-time to match the curve at currentTime + const volInput = document.getElementById(`tcp-vol-${subTabId}`); + const volLabel = document.getElementById(`tcp-vol-label-${subTabId}`); + if (volInput && volLabel) { + const dbVal = getVolumeDbAtTime(currentTime || 0); + volInput.value = dbVal.toFixed(1); + volLabel.textContent = `${dbVal.toFixed(1)}dB`; + } + const panInput = document.getElementById(`tcp-pan-${subTabId}`); + const panLabel = document.getElementById(`tcp-pan-label-${subTabId}`); + if (panInput && panLabel) { + const panVal = getPanningValueAtTime(currentTime || 0); + panInput.value = panVal; + panLabel.textContent = panVal > 0 ? 'R' : panVal < 0 ? 'L' : 'C'; + const panLabelDetailed = document.getElementById(`tcp-pan-label-detailed-${subTabId}`); + if (panLabelDetailed) { + panLabelDetailed.textContent = panVal > 0 ? 'R' + panVal : panVal < 0 ? 'L' + Math.abs(panVal) : 'C'; + } + } + }, [buffer, currentTime, selectionStart, selectionEnd, zoom, timelineWidth, color, name, speed, volumeNodes, panningNodes, fadeInLen, fadeOutLen, graphMode, selectedNodeTime, subTabId]); + const handleMouseDown = e => { + if (e.button === 2) return; + const canvas = canvasRef.current; + const rect = canvas.getBoundingClientRect(); + const parent = canvas.parentElement; + const scrollContainer = parent ? parent.parentElement : null; + const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; + const bufDuration = buffer.duration; + const wallDuration = bufDuration / (speed || 1.0); + const mouseX = e.clientX - rect.left + scrollLeft; + const startTime = Math.max(0, Math.min(wallDuration, mouseX / zoom)); + + // Shift + Click range selection in SubTab Waveform + if (e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + const anchor = subTabAnchorRef.current !== null && subTabAnchorRef.current !== undefined ? subTabAnchorRef.current : selectionStart !== null && selectionStart !== undefined ? selectionStart : currentTime; + const selS = Math.min(anchor, startTime); + const selE = Math.max(anchor, startTime); + onSelectRange(selS, selE); + onPlayheadSet(startTime); + return; + } + + // Alt+Click near right edge → speed stretch + if (e.altKey && onSpeedChange) { + const clipRightEdge = bufDuration / (speed || 1.0) * zoom; + const tolerance = 8; + if (Math.abs(mouseX - clipRightEdge) <= tolerance) { + isStretchingRef.current = true; + stretchStartRef.current = { + mouseX, + originalDuration: bufDuration, + originalSpeed: speed }; - - const VolumeKnob = ({ value, onChange, min = 0, max = 1 }) => { - const [isDragging, setIsDragging] = useState(false); - const startY = useRef(0); - const startValue = useRef(0); - - const rotation = useMemo(() => { - const percent = (value - min) / (max - min); - return -135 + percent * 270; - }, [value, min, max]); - - const handleMouseDown = (e) => { - setIsDragging(true); - startY.current = e.clientY; - startValue.current = value; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - }; - - const handleMouseMove = (e) => { - const deltaY = startY.current - e.clientY; - const sensitivity = 0.005; - const newValue = Math.max(min, Math.min(max, startValue.current + deltaY * sensitivity)); - onChange(parseFloat(newValue.toFixed(2))); - }; - - const handleMouseUp = () => { - setIsDragging(false); - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - - return ( -
- - - - - - -
- ); + canvas.style.cursor = 'ew-resize'; + const handleMouseMove = moveEvent => { + const currentX = moveEvent.clientX - rect.left + scrollLeft; + const wClipPx = stretchStartRef.current.originalDuration / stretchStartRef.current.originalSpeed * zoom; + const deltaX = currentX - stretchStartRef.current.mouseX; + const newWClip = Math.max(10, wClipPx + deltaX); + const newSpeed = stretchStartRef.current.originalDuration / (newWClip / zoom); + if (onSpeedChange) onSpeedChange(Math.max(0.05, Math.min(10, newSpeed))); }; - - const WaveformLane = ({ - track, - zoom, - timelineWidth, - viewportWidth, - onSelectRange, - onPlayheadSet, - isSelected, - onSelectTrack, - markers, - selectionMode, - localSelectionTrackId, - localSelectionStart, - currentTime, - getLocalAnchor, - onClearLocalSelection, - onSetSelectionMode, - onSetSelectionStart, - onSetSelectionEnd, - onSetCurrentTime, - onSetLocalSelectionTrackId, - onSetLocalSelectionStart, - onSetLocalSelectionEnd, - localSelLeft, - localSelRight, - onTrackLaneMouseDown, - onContextMenu, - onClipDragStart, - onClipStretchStart, - onSelectionEdgeDragStart, - setSelectedClipId, - selectedClipId, - activeTool, - onSplitTrackAtTime, - onEditClipInSubTab, - snapValue, - bpm, - }) => { - const canvasRef = useRef(null); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext('2d'); - const dpr = window.devicePixelRatio || 1; - const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null; - - // Viewport Virtualization: canvas pixel width is strictly capped to visible window width (e.g. 1200px) - const scrollLeft = wrapper ? wrapper.scrollLeft : 0; - const vWidth = viewportWidth || (wrapper ? wrapper.clientWidth : 1200); - const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200)); - const height = canvas.parentElement ? canvas.parentElement.clientHeight : 96; - - canvas.width = Math.round(drawWidth * dpr); - canvas.height = Math.round(height * dpr); - ctx.scale(dpr, dpr); - ctx.imageSmoothingEnabled = false; - - // Position canvas element at current scrollLeft inside track container - canvas.style.position = 'absolute'; - canvas.style.left = `${scrollLeft}px`; - canvas.style.width = `${drawWidth}px`; - canvas.style.height = `${height}px`; - - ctx.fillStyle = isSelected ? '#2a2a2a' : (track.id % 2 === 0 ? '#181818' : '#1d1d1d'); - ctx.fillRect(0, 0, drawWidth, height); - - // Grid lines based on Snap value - ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)'; - ctx.lineWidth = 1; - - const tStart = scrollLeft / zoom; - const tEnd = (scrollLeft + drawWidth) / zoom; - - let gridSpacing = 1.0; - if (snapValue && snapValue !== 'free') { - const beatDuration = 60 / parseFloat(bpm || 120); - let divisor = 1; - if (snapValue === '1') divisor = 1; - else if (snapValue === '1/2') divisor = 0.5; - else if (snapValue === '1/4') divisor = 0.25; - else if (snapValue === '1/8') divisor = 0.125; - else if (snapValue === '1/16') divisor = 0.0625; - else if (snapValue === '1/32') divisor = 0.03125; - - gridSpacing = beatDuration * divisor; - } else { - gridSpacing = 60 / parseFloat(bpm || 120); - } - - let drawSpacing = gridSpacing; - while (drawSpacing * zoom < 10) { - drawSpacing *= 2; - } - - const firstGridStep = Math.floor(tStart / drawSpacing) * drawSpacing; - for (let s = firstGridStep; s <= tEnd; s += drawSpacing) { - const localX = (s - tStart) * zoom; - ctx.beginPath(); - ctx.moveTo(localX, 0); - ctx.lineTo(localX, height); - ctx.stroke(); - } - - // Draw waveform lane - const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ - id: 'default', - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name, - speed: track.speed || 1.0 - }] : []); - - if (clips.length > 0) { - clips.forEach(clip => { - const numChannels = clip.buffer.numberOfChannels || 1; - const dataL = clip.buffer.getChannelData(0); - const dataR = numChannels >= 2 ? clip.buffer.getChannelData(1) : dataL; - const sampleRate = clip.buffer.sampleRate; - const totalSamples = dataL.length; - const originalDuration = totalSamples / sampleRate; - const clipSpeed = clip.speed || 1.0; - const duration = originalDuration / clipSpeed; - - const clipStartTime = clip.startTime || 0; - const clipEndTime = clipStartTime + duration; - - // Culling: Skip rendering if clip is outside visible viewport window - if (clipEndTime < tStart || clipStartTime > tEnd) return; - - const xStartGlobal = clipStartTime * zoom; - const wClip = duration * zoom; - - const xStartLocal = xStartGlobal - scrollLeft; - const xEndLocal = xStartLocal + wClip; - - // 1. Draw Clip Layer Background & Border - const clipIdentifier = clip.id === 'default' ? 'default_' + track.id : clip.id; - const isClipSelected = selectedClipId && selectedClipId.trackId === track.id && selectedClipId.clipId === clipIdentifier; - - ctx.fillStyle = isClipSelected ? (track.color ? track.color + '44' : 'rgba(6, 182, 212, 0.30)') : (track.color ? track.color + '22' : 'rgba(6, 182, 212, 0.12)'); - ctx.strokeStyle = isClipSelected ? '#fbbf24' : (track.color || '#06b6d4'); - ctx.lineWidth = isClipSelected ? 1 : 1.5; - - const clipTop = 4; - const clipHeight = height - 8; - - ctx.beginPath(); - if (ctx.roundRect) { - ctx.roundRect(xStartLocal, clipTop, wClip, clipHeight, 4); - } else { - ctx.rect(xStartLocal, clipTop, wClip, clipHeight); - } - ctx.fill(); - ctx.stroke(); - - // 2. Draw Clip Label & Speed Label - ctx.fillStyle = '#e4e4e7'; - ctx.font = 'bold 9px sans-serif'; - ctx.fillText(clip.name || 'Clip', Math.max(xStartLocal + 8, 8), clipTop + 12); - - if (clipSpeed !== 1.0) { - ctx.fillStyle = '#fbbf24'; - ctx.font = 'bold 8px sans-serif'; - ctx.fillText(`Speed: ${(clipSpeed * 100).toFixed(1)}%`, Math.max(xStartLocal + 8, 8), clipTop + 22); - } - - // Draw markers - if (markers && markers.length > 0) { - markers.forEach(m => { - const mxLocal = m.time * zoom - scrollLeft; - if (mxLocal >= 0 && mxLocal <= drawWidth) { - ctx.fillStyle = '#fbbf24'; - ctx.fillRect(mxLocal - 1, 0, 2, height); - } - }); - } - - // 3. Peak / Vector Waveform Drawing (Sound Forge Dual Stereo Channel Split Match) - const drawXStartLocal = Math.max(0, Math.floor(xStartLocal)); - const drawXEndLocal = Math.min(drawWidth, Math.ceil(xEndLocal)); - const samplesPerPixel = (sampleRate / zoom) * clipSpeed; - - const isStereo = numChannels >= 2; - const channelConfigs = isStereo ? [ - { data: dataL, mid: height / 4, chHeight: height / 2 - 8, label: '1' }, - { data: dataR, mid: (3 * height) / 4, chHeight: height / 2 - 8, label: '2' } - ] : [ - { data: dataL, mid: height / 2, chHeight: clipHeight - 12, label: 'MONO' } - ]; - - if (isStereo) { - ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(xStartLocal, height / 2); - ctx.lineTo(xStartLocal + wClip, height / 2); - ctx.stroke(); - } - - channelConfigs.forEach(ch => { - const data = ch.data; - const mid = ch.mid; - const peakRatio = ch.chHeight * 0.42; - - // Decibel Amplitude Grid Lines (+6.0dB, -Inf, -6.0dB) - const yPlus6 = mid - peakRatio * 0.8; - const yMinus6 = mid + peakRatio * 0.8; - - ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'; - ctx.lineWidth = 0.5; - - ctx.beginPath(); ctx.moveTo(xStartLocal, yPlus6); ctx.lineTo(xStartLocal + wClip, yPlus6); ctx.stroke(); - ctx.beginPath(); ctx.moveTo(xStartLocal, mid); ctx.lineTo(xStartLocal + wClip, mid); ctx.stroke(); - ctx.beginPath(); ctx.moveTo(xStartLocal, yMinus6); ctx.lineTo(xStartLocal + wClip, yMinus6); ctx.stroke(); - - // Decibel Text Labels (Sound Forge Monospace) - ctx.fillStyle = '#71717a'; - ctx.font = '8px monospace'; - const labelX = Math.max(xStartLocal + 4, 4); - ctx.fillText('+6.0', labelX, yPlus6 - 2); - ctx.fillText('-Inf', labelX, mid - 2); - ctx.fillText('-6.0', labelX, yMinus6 + 8); - - // Sound Forge Channel ID Badge (1 or 2) - if (isStereo) { - ctx.fillStyle = 'rgba(6, 182, 212, 0.85)'; - ctx.font = 'bold 9px monospace'; - ctx.fillText(ch.label, Math.min(xStartLocal + wClip - 12, drawWidth - 16), mid - ch.chHeight * 0.35); - } - - ctx.strokeStyle = '#5bc0be'; // Cornflower Blue - ctx.lineWidth = 1.2; - - if (samplesPerPixel < 4) { - // High-zoom continuous vector line rendering (#5bc0be Cornflower Blue) - const visibleTStart = (drawXStartLocal - xStartLocal) / zoom; - const visibleTEnd = (drawXEndLocal - xStartLocal) / zoom; - - const startSample = Math.max(0, Math.floor(visibleTStart * clipSpeed * sampleRate)); - const endSample = Math.min(totalSamples, Math.ceil(visibleTEnd * clipSpeed * sampleRate)); - - ctx.beginPath(); - let first = true; - const maxSamples = 50000; - const vectorStep = Math.max(1, Math.floor((endSample - startSample) / maxSamples)); - for (let i = startSample; i < endSample; i += vectorStep) { - const sampleTime = (i / sampleRate) / clipSpeed; - const pxLocal = xStartLocal + sampleTime * zoom; - const y = mid - data[i] * peakRatio; - if (first) { - ctx.moveTo(pxLocal, y); - first = false; - } else { - ctx.lineTo(pxLocal, y); - } - } - ctx.stroke(); - - // Draw granular sample nodes only at extreme zoom (samplesPerPixel < 0.3) - if (samplesPerPixel < 0.3) { - const maxNodes = 5000; - const step = Math.max(1, Math.floor((endSample - startSample) / maxNodes)); - ctx.fillStyle = '#6ee7b7'; - let drawn = 0; - for (let i = startSample; i < endSample && drawn < maxNodes; i += step) { - const sampleTime = (i / sampleRate) / clipSpeed; - const pxLocal = xStartLocal + sampleTime * zoom; - const y = mid - data[i] * peakRatio; - ctx.fillRect(pxLocal - 1, y - 1, 2, 2); - drawn++; - } - } - } else { - // Coarse view vertical peak min/max bars per pixel - for (let pxLocal = drawXStartLocal; pxLocal < drawXEndLocal; pxLocal++) { - const timeInClip = ((pxLocal - xStartLocal) / zoom) * clipSpeed; - const sampleIdx = Math.floor(timeInClip * sampleRate); - const chunkSize = Math.max(1, Math.floor(samplesPerPixel)); - const chunkStart = Math.max(0, sampleIdx - Math.floor(chunkSize / 2)); - const chunkEnd = Math.min(totalSamples, chunkStart + chunkSize); - - let maxVal = 0; - for (let i = chunkStart; i < chunkEnd; i++) { - const abs = Math.abs(data[i]); - if (abs > maxVal) maxVal = abs; - } - - const peakHeight = maxVal * peakRatio; - - ctx.beginPath(); - ctx.moveTo(pxLocal, mid - peakHeight); - ctx.lineTo(pxLocal, mid + peakHeight); - ctx.stroke(); - } - } - }); - }); - } else { - ctx.fillStyle = '#444'; - ctx.font = '12px Inter, sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText('Kéo thả hoặc tải file âm thanh vào Track này', drawWidth / 2, height / 2); - } - - // Selection highlight - local selection on this track - if (selectionMode === 'local' && localSelectionTrackId === track.id && - localSelLeft !== null && localSelRight !== null && localSelRight > localSelLeft) { - const hlLeftLocal = localSelLeft * zoom - scrollLeft; - const hlWidth = (localSelRight - localSelLeft) * zoom; - ctx.fillStyle = 'rgba(245, 158, 11, 0.15)'; - ctx.fillRect(hlLeftLocal, 0, hlWidth, height); - ctx.strokeStyle = '#f59e0b'; - ctx.lineWidth = 1; - ctx.strokeRect(hlLeftLocal, 0, hlWidth, height); - } - - }, [track, zoom, timelineWidth, viewportWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight, snapValue, bpm]); - - return ( -
- { - if (!canvasRef.current) return; - const rect = canvasRef.current.getBoundingClientRect(); - const wrapper = canvasRef.current?.parentElement?.parentElement; - const scrollLeft = wrapper ? wrapper.scrollLeft : 0; - const x = e.clientX - rect.left + scrollLeft; - const time = x / zoom; - - const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ - id: 'default', - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name, - speed: track.speed || 1.0 - }] : []); - - // 1. Shift+Drag (TOP PRIORITY): Range selection on track waveform - if (e.shiftKey && e.buttons > 0) { - e.preventDefault(); - e.stopPropagation(); - const currentAnchor = getLocalAnchor ? getLocalAnchor() : null; - const anchor = (currentAnchor !== null && currentAnchor !== undefined) ? currentAnchor : (localSelectionStart !== null ? localSelectionStart : currentTime); - const selS = Math.min(anchor, time); - const selE = Math.max(anchor, time); - if (onSetSelectionMode) onSetSelectionMode('local'); - if (onSetLocalSelectionTrackId) onSetLocalSelectionTrackId(track.id); - if (onSetLocalSelectionStart) onSetLocalSelectionStart(selS); - if (onSetLocalSelectionEnd) onSetLocalSelectionEnd(selE); - if (onSetSelectionStart) onSetSelectionStart(selS); - if (onSetSelectionEnd) onSetSelectionEnd(selE); - if (onSetCurrentTime) onSetCurrentTime(time); - if (onSelectTrack) onSelectTrack(track.id); - return; - } - - // Check if hovering near local selection boundaries of this track - const isLocal = selectionMode === 'local' && localSelectionTrackId === track.id; - if (isLocal && localSelLeft !== null && localSelRight !== null) { - const leftPx = localSelLeft * zoom; - const rightPx = localSelRight * zoom; - const distToLeft = Math.abs(x - leftPx); - const distToRight = Math.abs(x - rightPx); - if (distToLeft <= 5 || distToRight <= 5) { - canvasRef.current.style.cursor = 'ew-resize'; - return; - } - } - - // Check if hovering near right edge of a clip for time-stretching (Alt key required) - const toleranceSec = 8 / zoom; - const rightEdgeClip = clips.find(c => { - const duration = c.buffer.duration / (c.speed || 1.0); - return Math.abs(time - (c.startTime + duration)) <= toleranceSec; - }); - - if (rightEdgeClip && e.altKey) { - canvasRef.current.style.cursor = 'ew-resize'; - return; - } - - const hoveredClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); - const isOverClip = !!hoveredClip; - - if (activeTool === 'pen') { - canvasRef.current.style.cursor = isOverClip ? 'copy' : 'not-allowed'; - } else if (activeTool === 'grab') { - canvasRef.current.style.cursor = isOverClip ? 'grab' : 'default'; - } else if (activeTool === 'razor') { - canvasRef.current.style.cursor = isOverClip ? 'cell' : 'not-allowed'; - } else { - // select tool - canvasRef.current.style.cursor = (isOverClip && (e.altKey || e.ctrlKey)) ? 'grab' : 'crosshair'; - } - }} - onMouseDown={(e) => { - // Ignore right-click for local selection drag (context menu handles it) - if (e.button === 2) return; - const rect = canvasRef.current.getBoundingClientRect(); - const wrapper = canvasRef.current?.parentElement?.parentElement; - const scrollLeft = wrapper ? wrapper.scrollLeft : 0; - const x = e.clientX - rect.left + scrollLeft; - const time = Math.max(0, x / zoom); - onSelectTrack(track.id); - - const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ - id: 'default', - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name, - speed: track.speed || 1.0 - }] : []); - - // 1. Shift+Click (TOP PRIORITY): Range selection on track waveform - if (e.shiftKey) { - e.preventDefault(); - e.stopPropagation(); - const currentAnchor = getLocalAnchor ? getLocalAnchor() : null; - const anchor = (currentAnchor !== null && currentAnchor !== undefined) ? currentAnchor : (localSelectionStart !== null ? localSelectionStart : currentTime); - const selS = Math.min(anchor, time); - const selE = Math.max(anchor, time); - if (onSetSelectionMode) onSetSelectionMode('local'); - if (onSetLocalSelectionTrackId) onSetLocalSelectionTrackId(track.id); - if (onSetLocalSelectionStart) onSetLocalSelectionStart(selS); - if (onSetLocalSelectionEnd) onSetLocalSelectionEnd(selE); - if (onSetSelectionStart) onSetSelectionStart(selS); - if (onSetSelectionEnd) onSetSelectionEnd(selE); - if (onSetCurrentTime) onSetCurrentTime(time); - if (onSelectTrack) onSelectTrack(track.id); - return; - } - - // Check if dragging selection boundaries (local mode) - const isLocal = selectionMode === 'local' && localSelectionTrackId === track.id; - if (isLocal && localSelLeft !== null && localSelRight !== null) { - const leftPx = localSelLeft * zoom; - const rightPx = localSelRight * zoom; - const distToLeft = Math.abs(x - leftPx); - const distToRight = Math.abs(x - rightPx); - if (distToLeft <= 5) { - e.preventDefault(); - e.stopPropagation(); - if (onSelectionEdgeDragStart) onSelectionEdgeDragStart(e, track.id, 'left'); - return; - } else if (distToRight <= 5) { - e.preventDefault(); - e.stopPropagation(); - if (onSelectionEdgeDragStart) onSelectionEdgeDragStart(e, track.id, 'right'); - return; - } - } - - // Check if time-stretching (Alt + Right Edge) - const toleranceSec = 8 / zoom; - const rightEdgeClip = clips.find(c => { - const duration = c.buffer.duration / (c.speed || 1.0); - return Math.abs(time - (c.startTime + duration)) <= toleranceSec; - }); - - if (rightEdgeClip && e.altKey) { - e.preventDefault(); - e.stopPropagation(); - if (onClipStretchStart) { - onClipStretchStart(track.id, rightEdgeClip.id, time); - } - return; - } - - const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); - - // Set selected clip ID - if (clickedClip) { - setSelectedClipId({ trackId: track.id, clipId: clickedClip.id === 'default' ? 'default_' + track.id : clickedClip.id }); - } else { - setSelectedClipId(null); - } - - if (activeTool === 'pen' && !e.ctrlKey) { - if (clickedClip) { - e.preventDefault(); - e.stopPropagation(); - if (onEditClipInSubTab) { - onEditClipInSubTab(track.id, clickedClip.id); - } - } - return; - } - - if (activeTool === 'razor' && !e.ctrlKey) { - if (clickedClip) { - e.preventDefault(); - e.stopPropagation(); - if (onSplitTrackAtTime) { - onSplitTrackAtTime(track.id, clickedClip.id, time); - } - } - return; - } - - if (activeTool === 'grab') { - if (onTrackLaneMouseDown) { - onTrackLaneMouseDown(track.id, time, e); - } - if (clickedClip) { - e.preventDefault(); - e.stopPropagation(); - if (onClipDragStart) { - onClipDragStart(track.id, clickedClip.id, time - clickedClip.startTime, e.ctrlKey); - } - } else { - onPlayheadSet(time); - } - return; - } - - // Check for click drag clip (Alt to move, Ctrl to duplicate) - if (clickedClip && (e.altKey || e.ctrlKey)) { - e.preventDefault(); - e.stopPropagation(); - if (onClipDragStart) { - onClipDragStart(track.id, clickedClip.id, time - clickedClip.startTime, e.ctrlKey); - } - return; - } - - // Check if Ctrl+Click outside active local selection to clear - if (e.ctrlKey) { - if (onClearLocalSelection) onClearLocalSelection(); - if (onSetSelectionMode) onSetSelectionMode(null); - if (onSetSelectionStart) onSetSelectionStart(null); - if (onSetSelectionEnd) onSetSelectionEnd(null); - return; - } - - - onPlayheadSet(time); - if (onTrackLaneMouseDown) { - onTrackLaneMouseDown(track.id, time, e); - } - e.stopPropagation(); - }} - onDoubleClick={(e) => { - const rect = canvasRef.current.getBoundingClientRect(); - const wrapper = canvasRef.current?.parentElement?.parentElement; - const scrollLeft = wrapper ? wrapper.scrollLeft : 0; - const x = e.clientX - rect.left + scrollLeft; - const time = Math.max(0, x / zoom); - - const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ - id: 'default', - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name, - speed: track.speed || 1.0 - }] : []); - - const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); - if (clickedClip) { - e.preventDefault(); - e.stopPropagation(); - if (onEditClipInSubTab) { - onEditClipInSubTab(track.id, clickedClip.id); - } - } - }} - onContextMenu={(e) => { - e.preventDefault(); - e.stopPropagation(); - onSelectTrack(track.id); - const rect = canvasRef.current.getBoundingClientRect(); - const wrapper = canvasRef.current?.parentElement?.parentElement; - const scrollLeft = wrapper ? wrapper.scrollLeft : 0; - const x = e.clientX - rect.left + scrollLeft; - const time = Math.max(0, x / zoom); - if (onContextMenu) onContextMenu(e, track.id, time); - }} - /> -
- ); + const handleMouseUp = () => { + isStretchingRef.current = false; + canvas.style.cursor = 'default'; + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return; + } + } - const TempoTrackLane = ({ bpm, zoom, timelineWidth, viewportWidth, onPlayheadSet, snapValue, onRulerMouseDown }) => { - const canvasRef = useRef(null); + // Mode toggle button click (bottom-right VOL/PAN) + const wClipPx = bufDuration / (speed || 1.0) * zoom; + const cTop = 8; + const cSize = 16; + const cHeight = rect.height - 16; + const modeBtnX = wClipPx - 28 - 4; + const modeBtnY = cTop + cHeight - 14 - 2; + if (mouseX >= modeBtnX && mouseX <= modeBtnX + 28 && e.clientY - rect.top >= modeBtnY && e.clientY - rect.top <= modeBtnY + 14) { + if (onModeToggle) onModeToggle(); + return; + } - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext('2d'); - const dpr = window.devicePixelRatio || 1; - const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null; - const scrollLeft = wrapper ? wrapper.scrollLeft : 0; - const vWidth = viewportWidth || (wrapper ? wrapper.clientWidth : 1200); - const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200)); - const height = canvas.parentElement ? canvas.parentElement.clientHeight : 40; + // Fade endpoint handle drag (click on FI/FO handle circles) + const HANDLE_R = 5; + const fiEndPx = fadeInLen * zoom; + const foStartPx = wClipPx - fadeOutLen * zoom; + const distToFiHandle = Math.abs(mouseX - fiEndPx) + Math.abs(e.clientY - rect.top - cTop); + const distToFoHandle = Math.abs(mouseX - foStartPx) + Math.abs(e.clientY - rect.top - cTop); + if (distToFiHandle <= HANDLE_R + 6) { + const handleMouseMove = moveEvent => { + const x = moveEvent.clientX - rect.left + scrollLeft; + const t = Math.max(0, Math.min(wallDuration, x / zoom)); + if (onUpdateFade) onUpdateFade({ + fadeInLen: t + }); + }; + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return; + } + if (distToFoHandle <= HANDLE_R + 6) { + const handleMouseMove = moveEvent => { + const x = moveEvent.clientX - rect.left + scrollLeft; + const t = Math.max(0, Math.min(wallDuration, (wClipPx - x) / zoom)); + if (onUpdateFade) onUpdateFade({ + fadeOutLen: t + }); + }; + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return; + } - canvas.width = Math.round(drawWidth * dpr); - canvas.height = Math.round(height * dpr); - ctx.scale(dpr, dpr); - ctx.imageSmoothingEnabled = false; + // Tool-specific behavior + if (activeTool === 'grab') { + setSelectedNodeTime(null); + onPlayheadSet(startTime); + const handleMouseMove = moveEvent => { + const currentX = moveEvent.clientX - rect.left + scrollLeft; + const ct = Math.max(0, Math.min(wallDuration, currentX / zoom)); + onPlayheadSet(ct); + }; + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return; + } + if (activeTool === 'razor') { + setSelectedNodeTime(null); + onPlayheadSet(startTime); + showToast(`Cut point at ${formatTime(startTime)}`, 'info'); + return; + } + if (activeTool === 'pen') { + // Deduplicate by time: keep last occurrence per time key + const mergeNodes = (existing, incoming) => { + const map = new Map(); + existing.forEach(n => map.set(n.time, n)); + incoming.forEach(n => map.set(n.time, n)); + return Array.from(map.values()).sort((a, b) => a.time - b.time); + }; + onPlayheadSet(startTime); + canvas.style.cursor = 'crosshair'; + const isPan = (graphMode || 'volume') === 'pan'; + const isCtrl = e.ctrlKey || e.metaKey; + const cTop = 8; + const cHeight = rect.height - 16; + const curNodes = isPan ? panningNodes : volumeNodes; + const valFromY = y => { + if (isPan) return Math.max(-1, Math.min(1, -(y - cTop) / cHeight * 2 + 1)); + const yOff = (y - cTop) / cHeight; + return yOff <= 1 / 3 ? Math.max(0, Math.min(3, 3 * (1 - yOff * 3))) : Math.max(-30, Math.min(0, -30 * (yOff - 1 / 3) * (3 / 2))); + }; + const snapVal = v => isPan ? Math.round(v * 20) / 20 : Math.round(v * 2) / 2; + const snapTime = t => Math.round(t * 10) / 10; - canvas.style.position = 'absolute'; - canvas.style.left = `${scrollLeft}px`; - canvas.style.width = `${drawWidth}px`; - canvas.style.height = `${height}px`; - - ctx.fillStyle = '#1a1a2e'; - ctx.fillRect(0, 0, drawWidth, height); - - const beatDuration = 60 / bpm; - const barDuration = beatDuration * 4; - - const tStart = scrollLeft / zoom; - const tEnd = (scrollLeft + drawWidth) / zoom; - - const firstBeat = Math.floor(tStart / beatDuration) * beatDuration; - for (let t = firstBeat; t <= tEnd; t += beatDuration) { - const beatNum = Math.floor(t / beatDuration) + 1; - const isBar = beatNum % 4 === 1; - const localX = (t - tStart) * zoom; - - if (isBar) { - ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; - ctx.lineWidth = 1.5; - ctx.beginPath(); - ctx.moveTo(localX, 0); - ctx.lineTo(localX, height); - ctx.stroke(); - - ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'; - ctx.font = 'bold 9px Inter, sans-serif'; - ctx.textAlign = 'left'; - ctx.fillText(`${Math.ceil(beatNum / 4)}`, localX + 3, 11); - } else { - ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(localX, 0); - ctx.lineTo(localX, height); - ctx.stroke(); - } - } - - // Draw snap sub-ticks at the bottom - if (snapValue && snapValue !== 'free') { - ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)'; - ctx.lineWidth = 0.8; - let divisor = 1; - if (snapValue === '1') divisor = 1; - else if (snapValue === '1/2') divisor = 0.5; - else if (snapValue === '1/4') divisor = 0.25; - else if (snapValue === '1/8') divisor = 0.125; - else if (snapValue === '1/16') divisor = 0.0625; - else if (snapValue === '1/32') divisor = 0.03125; - - const snapInterval = beatDuration * divisor; - if (snapInterval * zoom >= 4) { - const firstSnap = Math.floor(tStart / snapInterval) * snapInterval; - for (let t = firstSnap; t <= tEnd; t += snapInterval) { - const onBeat = Math.abs((t / beatDuration) - Math.round(t / beatDuration)) < 0.001; - if (!onBeat) { - const localX = (t - tStart) * zoom; - ctx.beginPath(); - ctx.moveTo(localX, height - 6); - ctx.lineTo(localX, height); - ctx.stroke(); - } - } - } - } - - ctx.fillStyle = 'rgba(255, 255, 255, 0.35)'; - ctx.font = 'bold 10px Inter, sans-serif'; - ctx.textAlign = 'right'; - ctx.fillText(`${bpm} BPM`, drawWidth - 6, 12); - - }, [bpm, zoom, timelineWidth, viewportWidth, snapValue]); - - return ( -
- { - const wrapper = canvasRef.current?.parentElement?.parentElement; - const scrollLeft = wrapper ? wrapper.scrollLeft : 0; - const rect = canvasRef.current.getBoundingClientRect(); - const x = e.clientX - rect.left + scrollLeft; - const time = Math.max(0, x / zoom); - if (e.shiftKey) { - e.preventDefault(); - e.stopPropagation(); - } - if (onRulerMouseDown) { - onRulerMouseDown(e); - } else { - onPlayheadSet(time, e.shiftKey); - } - }} - /> -
- ); + // Check if clicking near existing node (any mode) + const volNodeY = v => { + const z = cTop + 1 / 3 * cHeight; + return v >= 0 ? z - v / 3 * (1 / 3 * cHeight) : z + -v / 30 * (2 / 3 * cHeight); + }; + const nearNode = curNodes.findIndex(n => { + const t = Math.abs(n.time - startTime); + const val = isPan ? n.pan : n.db; + const ny = isPan ? cTop + (1 - (val + 1) / 2) * cHeight : volNodeY(val); + const dy = Math.abs(e.clientY - rect.top - ny); + return t < 0.1 / (speed || 1) && dy < 10; + }); + if (nearNode >= 0) { + // Drag existing node + setSelectedNodeTime(curNodes[nearNode].time); + let working = [...curNodes]; + let dragIdx = nearNode; + const handleMouseMove = moveEvent => { + const currentX = moveEvent.clientX - rect.left + scrollLeft; + const ct = Math.max(0, Math.min(wallDuration, currentX / zoom)); + const val = snapVal(valFromY(moveEvent.clientY - rect.top)); + const updated = isPan ? { + time: Math.min(snapTime(ct), wallDuration), + pan: val + } : { + time: Math.min(snapTime(ct), wallDuration), + db: val + }; + const cleaned = mergeNodes(working.filter((_, i) => i !== dragIdx), [updated]); + if (onUpdateNodes) onUpdateNodes(cleaned); + working = [...cleaned]; + dragIdx = working.findIndex(n => n.time === updated.time && (isPan ? n.pan === updated.pan : n.db === updated.db)); + // Follow the selected node during dragging + setSelectedNodeTime(updated.time); }; - - // ── 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, zoom, timelineWidth, color, name, speed = 1.0, onSpeedChange, volumeNodes = [], panningNodes = [], fadeInLen = 0, fadeOutLen = 0, graphMode = null, onUpdateNodes, onUpdateFade, onModeToggle, selectedNodeTime, setSelectedNodeTime, channelInfo = null }) => { - const canvasRef = useRef(null); - const isStretchingRef = useRef(false); - const stretchStartRef = useRef({ mouseX: 0, originalDuration: 0, originalSpeed: 1.0 }); - const subTabAnchorRef = useRef(null); - - const isStereo = channelInfo ? channelInfo.isStereo : (buffer && buffer.numberOfChannels >= 2); - const channelLabel = channelInfo ? channelInfo.label : (isStereo ? 'STEREO' : 'MONO'); - // Mono: force volume mode (panning not applicable) - const effectiveGraphMode = (!isStereo && graphMode === 'pan') ? null : graphMode; - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas || !buffer) return; - const ctx = canvas.getContext('2d'); - const dpr = window.devicePixelRatio || 1; - const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null; - const scrollLeft = wrapper ? wrapper.scrollLeft : 0; - const vWidth = wrapper ? wrapper.clientWidth : 1200; - const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200)); - const h = canvas.parentElement ? canvas.parentElement.clientHeight : 200; - - canvas.width = Math.round(drawWidth * dpr); - canvas.height = Math.round(h * dpr); - ctx.scale(dpr, dpr); - ctx.imageSmoothingEnabled = false; - - canvas.style.position = 'absolute'; - canvas.style.left = `${scrollLeft}px`; - canvas.style.width = `${drawWidth}px`; - canvas.style.height = `${h}px`; - - ctx.fillStyle = '#181818'; - ctx.fillRect(0, 0, drawWidth, h); - - const data = buffer.getChannelData(0); - const len = data.length; - if (len === 0) return; - - // Helper to compute volume gain at a specific time in clip using Monotone Cubic Hermite Spline - const computeHermiteTangents = (pts) => { - const n = pts.length; - if (n < 2) return []; - const m = new Array(n); - for (let i = 1; i < n - 1; i++) { - const hP = pts[i].time - pts[i-1].time; - const hN = pts[i+1].time - pts[i].time; - const sP = (pts[i].db - pts[i-1].db) / hP; - const sN = (pts[i+1].db - pts[i].db) / hN; - m[i] = (sP + sN) / 2; - } - m[0] = n > 1 ? (pts[1].db - pts[0].db) / (pts[1].time - pts[0].time) : 0; - m[n-1] = n > 1 ? (pts[n-1].db - pts[n-2].db) / (pts[n-1].time - pts[n-2].time) : 0; - return m; - }; - - const getVolumeDbAtTime = (t) => { - const volNodes = volumeNodes || []; - if (volNodes.length === 0) return 0.0; - const sortedNodes = [...volNodes].sort((a, b) => a.time - b.time); - if (sortedNodes.length === 1) return sortedNodes[0].db; - if (t <= sortedNodes[0].time) return sortedNodes[0].db; - if (t >= sortedNodes[sortedNodes.length - 1].time) return sortedNodes[sortedNodes.length - 1].db; - const tangents = computeHermiteTangents(sortedNodes); - for (let i = 0; i < sortedNodes.length - 1; i++) { - const n1 = sortedNodes[i]; - const n2 = sortedNodes[i+1]; - if (t >= n1.time && t <= n2.time) { - const h = n2.time - n1.time; - if (h <= 0) return n1.db; - const frac = (t - n1.time) / h; - const frac2 = frac * frac, frac3 = frac2 * frac; - return (2*frac3 - 3*frac2 + 1) * n1.db + (frac3 - 2*frac2 + frac) * h * tangents[i] + (-2*frac3 + 3*frac2) * n2.db + (frac3 - frac2) * h * tangents[i+1]; - } - } - return 0.0; - }; - - const getVolumeGainAtTime = (t) => { - return Math.pow(10, getVolumeDbAtTime(t) / 20); - }; - - const computeHermiteTangentsForPan = (pts) => { - const n = pts.length; - if (n < 2) return []; - const m = new Array(n); - for (let i = 1; i < n - 1; i++) { - const hP = pts[i].time - pts[i-1].time; - const hN = pts[i+1].time - pts[i].time; - const sP = (pts[i].pan - pts[i-1].pan) / hP; - const sN = (pts[i+1].pan - pts[i].pan) / hN; - m[i] = (sP + sN) / 2; - } - m[0] = n > 1 ? (pts[1].pan - pts[0].pan) / (pts[1].time - pts[0].time) : 0; - m[n-1] = n > 1 ? (pts[n-1].pan - pts[n-2].pan) / (pts[n-1].time - pts[n-2].time) : 0; - return m; - }; - - const getPanningValueAtTime = (t) => { - const panNodes = panningNodes || []; - if (panNodes.length === 0) return 0; - const sortedNodes = [...panNodes].sort((a, b) => a.time - b.time); - if (sortedNodes.length === 1) return Math.round(sortedNodes[0].pan * 100); - if (t <= sortedNodes[0].time) return Math.round(sortedNodes[0].pan * 100); - if (t >= sortedNodes[sortedNodes.length - 1].time) return Math.round(sortedNodes[sortedNodes.length - 1].pan * 100); - const tangents = computeHermiteTangentsForPan(sortedNodes); - for (let i = 0; i < sortedNodes.length - 1; i++) { - const n1 = sortedNodes[i]; - const n2 = sortedNodes[i+1]; - if (t >= n1.time && t <= n2.time) { - const h = n2.time - n1.time; - if (h <= 0) return Math.round(n1.pan * 100); - const frac = (t - n1.time) / h; - const frac2 = frac * frac, frac3 = frac2 * frac; - const val = (2*frac3 - 3*frac2 + 1) * n1.pan + (frac3 - 2*frac2 + frac) * h * tangents[i] + (-2*frac3 + 3*frac2) * n2.pan + (frac3 - frac2) * h * tangents[i+1]; - return Math.round(val * 100); - } - } - return 0; - }; - - // Draw clip container (like main session clips) - const xStart = 0; - const wClip = (buffer.duration / speed) * zoom; // speed-adjusted width - const xEnd = xStart + wClip; - const clipTop = 8; - const clipHeight = h - 16; - const clipColor = color || '#06b6d4'; - - ctx.fillStyle = clipColor + '22'; - ctx.strokeStyle = clipColor; - ctx.lineWidth = 1.5; - if (ctx.roundRect) { - ctx.beginPath(); - ctx.roundRect(xStart, clipTop, wClip, clipHeight, 4); - ctx.fill(); - ctx.stroke(); - } else { - ctx.fillRect(xStart, clipTop, wClip, clipHeight); - ctx.strokeRect(xStart, clipTop, wClip, clipHeight); - } - - // Draw clip label with speed % - ctx.fillStyle = '#e4e4e7'; - ctx.font = 'bold 10px sans-serif'; - let displayName = name || 'Audio Clip'; - if (speed !== 1.0) { - displayName += ` (${Math.round(speed * 100)}%)`; - } - ctx.fillText(displayName, xStart + 8, clipTop + 14); - - // ── Graph Grid & Axes ── - const drawGrid = true; - if (drawGrid) { - // Background fill - ctx.fillStyle = '#181818'; - ctx.fillRect(xStart, clipTop, wClip, clipHeight); - - // Vertical grid lines (time markers) - ctx.strokeStyle = '#2a2a2a'; - ctx.lineWidth = 0.5; - ctx.setLineDash([]); - const timeStep = Math.max(0.1, Math.ceil((buffer.duration / 20) * 10) / 10); - for (let t = 0; t <= buffer.duration; t += timeStep) { - const px = t * zoom; - ctx.beginPath(); ctx.moveTo(px, clipTop); ctx.lineTo(px, clipTop + clipHeight); ctx.stroke(); - } - - // Always draw the reference Volume 0dB Axis (White) and Panning Center Axis (Brown) - const volZeroY = clipTop + (1/3) * clipHeight; - const panZeroY = clipTop + (1/2) * clipHeight; - - // White line for volume 0dB - ctx.strokeStyle = '#ffffff'; - ctx.lineWidth = 1.2; - ctx.beginPath(); ctx.moveTo(xStart, volZeroY); ctx.lineTo(xStart + wClip, volZeroY); ctx.stroke(); - - // Brown line for panning center - ctx.strokeStyle = '#854d0e'; - ctx.lineWidth = 1.2; - ctx.beginPath(); ctx.moveTo(xStart, panZeroY); ctx.lineTo(xStart + wClip, panZeroY); ctx.stroke(); - - // Horizontal grid lines (other value markers) - const isPanMode = effectiveGraphMode === 'pan'; - if (isPanMode) { - for (let p = -100; p <= 100; p += 20) { - if (p === 0) continue; - const y = clipTop + clipHeight * (1 - (p / 100 + 1) / 2); - ctx.strokeStyle = '#2a2a2a'; - ctx.lineWidth = 0.5; - ctx.beginPath(); ctx.moveTo(xStart, y); ctx.lineTo(xStart + wClip, y); ctx.stroke(); - } - } else { - for (let db = -30; db <= 3; db += 3) { - if (db === 0) continue; - const y = db >= 0 - ? volZeroY - (db / 3) * (2/3 * clipHeight) - : volZeroY + (-db / 30) * (1/3 * clipHeight); - ctx.strokeStyle = '#2a2a2a'; - ctx.lineWidth = 0.5; - ctx.beginPath(); ctx.moveTo(xStart, y); ctx.lineTo(xStart + wClip, y); ctx.stroke(); - } - } - - // Y-axis labels (left side) - ctx.fillStyle = '#71717a'; - ctx.font = '7px monospace'; - ctx.textAlign = 'right'; - if (isPanMode) { - ctx.fillText('L100', xStart - 2, clipTop + 8); - ctx.fillText('R50', xStart - 2, clipTop + clipHeight * 0.25 + 2); - ctx.fillStyle = '#854d0e'; // Brown label for active Panning Center - ctx.fillText('C (Pan)', xStart - 2, clipTop + clipHeight * 0.5 + 2); - ctx.fillStyle = '#71717a'; - ctx.fillText('L50', xStart - 2, clipTop + clipHeight * 0.75 + 2); - ctx.fillText('R100', xStart - 2, clipTop + clipHeight - 2); - } else { - ctx.fillText('+3dB', xStart - 2, clipTop + 8); - ctx.fillStyle = '#ffffff'; // White label for active Volume 0dB - ctx.fillText('0dB (Vol)', xStart - 2, volZeroY + 2); - ctx.fillStyle = '#71717a'; - ctx.fillText('-15dB', xStart - 2, volZeroY + clipHeight / 6 + 2); - ctx.fillText('-30dB', xStart - 2, clipTop + clipHeight - 2); - } - ctx.textAlign = 'start'; - } - - // Drag hint labels for fade endpoints - ctx.fillStyle = '#71717a'; - ctx.font = '8px sans-serif'; - ctx.fillText('Kéo FI/FO trên đường cong để điều chỉnh', 8, clipTop + clipHeight + 12); - - // Display speed percentage in the bottom left corner of the waveform area - ctx.fillStyle = '#e4e4e7'; - ctx.font = 'bold 9px sans-serif'; - ctx.fillText(`Tốc độ: ${Math.round(speed * 100)}%`, xStart + 8, clipTop + clipHeight - 8); - - // Mode toggle button on waveform (bottom-right) - const modeBtnW = 28; - const modeBtnH = 14; - const modeBtnX = wClip - modeBtnW - 4; - const modeBtnY = clipTop + clipHeight - modeBtnH - 2; - const isPanMode = effectiveGraphMode === 'pan'; - ctx.fillStyle = isPanMode ? 'rgba(168, 85, 247, 0.5)' : 'rgba(6, 182, 212, 0.5)'; - ctx.beginPath(); - ctx.roundRect(modeBtnX, modeBtnY, modeBtnW, modeBtnH, 3); - ctx.fill(); - ctx.fillStyle = '#fff'; - ctx.font = 'bold 7px sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText(isPanMode ? 'PAN' : 'VOL', modeBtnX + modeBtnW / 2, modeBtnY + 10); - ctx.textAlign = 'start'; - - // Channel label (L / R for stereo, M for mono) - ctx.fillStyle = '#a1a1aa'; - ctx.font = 'bold 8px monospace'; - if (isStereo) { - ctx.fillText('L', xStart + 2, clipTop + clipHeight * 0.28); - ctx.fillText('R', xStart + 2, clipTop + clipHeight * 0.72); - } else { - ctx.fillText('M', xStart + 2, clipTop + clipHeight / 2); - } - - // Draw waveform inside clip (speed-adjusted) with fade envelope applied - const drawXStart = Math.max(0, Math.floor(xStart)); - const drawXEnd = Math.min(drawWidth, Math.ceil(xEnd)); - const samplesPerPixel = (buffer.sampleRate / zoom) * speed; - const bufDur = buffer.duration; - const mid = h / 2; - const peakRatio = clipHeight * 0.45; - - const getFadeGainAtTime = (t) => { - if (fadeInLen > 0 && t < fadeInLen) { - return (1 - Math.cos(Math.PI * t / fadeInLen)) / 2; - } else if (fadeOutLen > 0 && t > bufDur - fadeOutLen) { - const ratio = (t - (bufDur - fadeOutLen)) / fadeOutLen; - return (1 + Math.cos(Math.PI * ratio)) / 2; - } - return 1; - }; - - ctx.strokeStyle = '#5bc0be'; - ctx.lineWidth = 1.2; - - if (samplesPerPixel < 4) { - // High-zoom continuous vector line rendering (#5bc0be Cornflower Blue) - const startSample = Math.max(0, Math.floor(((drawXStart - xStart) / zoom) * speed * buffer.sampleRate)); - const endSample = Math.min(len, Math.ceil(((drawXEnd - xStart) / zoom) * speed * buffer.sampleRate)); - - ctx.beginPath(); - let first = true; - for (let i = startSample; i < endSample; i++) { - const timeInClip = (i / buffer.sampleRate) / speed; - const px = xStart + (timeInClip / speed) * zoom; - const fadeGain = getFadeGainAtTime(timeInClip); - const volGain = getVolumeGainAtTime(px / zoom); - const totalGain = fadeGain * volGain; - const y = mid - (data[i] * totalGain) * peakRatio; - - if (first) { - ctx.moveTo(px, y); - first = false; - } else { - ctx.lineTo(px, y); - } - } - ctx.stroke(); - - // Draw granular sample nodes (#6ee7b7 Green Emerald, radius r = 2px) when samplesPerPixel < 1.5 - if (samplesPerPixel < 1.5) { - ctx.fillStyle = '#6ee7b7'; - for (let i = startSample; i < endSample; i++) { - const timeInClip = (i / buffer.sampleRate) / speed; - const px = xStart + (timeInClip / speed) * zoom; - const fadeGain = getFadeGainAtTime(timeInClip); - const volGain = getVolumeGainAtTime(px / zoom); - const totalGain = fadeGain * volGain; - const y = mid - (data[i] * totalGain) * peakRatio; - - ctx.beginPath(); - ctx.arc(px, y, 2, 0, Math.PI * 2); - ctx.fill(); - } - } - } else { - // Coarse view vertical peak min/max bars - for (let px = drawXStart; px < drawXEnd; px++) { - const timeInClip = (px / zoom) * speed; - const sampleIdx = Math.floor(timeInClip * 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 fadeGain = getFadeGainAtTime(timeInClip); - const volGain = getVolumeGainAtTime(px / zoom); - const totalGain = fadeGain * volGain; - - let maxVal = 0; - let minVal = 0; - for (let i = chunkStart; i < chunkEnd; i++) { - const val = data[i]; - if (val > maxVal) maxVal = val; - if (val < minVal) minVal = val; - } - maxVal *= totalGain; - minVal *= totalGain; - const yTop = mid + (minVal * peakRatio); - const yBottom = mid + (maxVal * peakRatio); - ctx.beginPath(); - ctx.moveTo(px, yTop); - ctx.lineTo(px, yBottom); - ctx.stroke(); - } - } - - // Volume: 0dB at 1/3 from top - const autoY = (node) => { - const db = typeof node === 'number' ? node : node.db; - const zeroY = clipTop + (1/3) * clipHeight; - return db >= 0 - ? zeroY - (db / 3) * (1/3 * clipHeight) - : zeroY + (-db / 30) * (2/3 * clipHeight); - }; - // Panning: 0 at center - const autoPanY = (node) => { - const pan = typeof node === 'number' ? node : node.pan; - return clipTop + ((1 - (pan + 1) / 2) * clipHeight); - }; - - // Helper: compute tangents for monotone Hermite spline - const computeTangents = (pts, yFn) => { - const n = pts.length; - if (n < 2) return []; - const m = new Array(n); - for (let i = 1; i < n - 1; i++) { - const hP = pts[i].time - pts[i-1].time; - const hN = pts[i+1].time - pts[i].time; - const sP = (yFn(pts[i]) - yFn(pts[i-1])) / hP; - const sN = (yFn(pts[i+1]) - yFn(pts[i])) / hN; - m[i] = (sP + sN) / 2; - } - m[0] = n > 1 ? (yFn(pts[1]) - yFn(pts[0])) / (pts[1].time - pts[0].time) : 0; - m[n-1] = n > 1 ? (yFn(pts[n-1]) - yFn(pts[n-2])) / (pts[n-1].time - pts[n-2].time) : 0; - return m; - }; - - // Helper: evaluate Hermite at pixel position px - const hermiteY = (px, x0, y0, m0, x1, y1, m1) => { - const h = x1 - x0; - if (h <= 0) return y0; - const t = (px - x0) / h; - const t2 = t * t, t3 = t2 * t; - return (2*t3 - 3*t2 + 1) * y0 + (t3 - 2*t2 + t) * h * m0 + (-2*t3 + 3*t2) * y1 + (t3 - t2) * h * m1; - }; - - // Draw automation curve with Hermite spline - const drawSpline = (nodes, yFn, color, lineDash) => { - if (nodes.length < 2) { - if (nodes.length === 1) { - const px = nodes[0].time * zoom; - const y = yFn(nodes[0]); - ctx.fillStyle = color; - ctx.beginPath(); ctx.arc(px, y, 4, 0, Math.PI * 2); ctx.fill(); - } - return; - } - const tangents = computeTangents(nodes, yFn); - ctx.strokeStyle = color; - ctx.lineWidth = 2; - ctx.setLineDash(lineDash || []); - ctx.beginPath(); - - for (let i = 0; i < nodes.length - 1; i++) { - const x0 = nodes[i].time * zoom, y0 = yFn(nodes[i]); - const x1 = nodes[i+1].time * zoom, y1 = yFn(nodes[i+1]); - const t0 = nodes[i].time, t1 = nodes[i+1].time; - const m0 = tangents[i], m1 = tangents[i+1]; - for (let px = Math.floor(x0); px < Math.ceil(x1); px++) { - const t = px / zoom; - const y = hermiteY(t, t0, y0, m0, t1, y1, m1); - if (px === Math.floor(x0) && i === 0) ctx.moveTo(px, y); else ctx.lineTo(px, y); - } - } - ctx.stroke(); - ctx.setLineDash([]); - - // Draw node handles - nodes.forEach(n => { - const px = n.time * zoom, y = yFn(n); - const isSelected = n.time === selectedNodeTime; - ctx.fillStyle = isSelected ? '#ffebb3' : '#fff'; - ctx.beginPath(); ctx.arc(px, y, isSelected ? 6 : 4, 0, Math.PI * 2); ctx.fill(); - ctx.strokeStyle = isSelected ? '#fbbf24' : color; - ctx.lineWidth = isSelected ? 2.5 : 1.5; - ctx.beginPath(); ctx.arc(px, y, isSelected ? 6 : 4, 0, Math.PI * 2); ctx.stroke(); - - // Display value at node - ctx.fillStyle = '#ffffff'; - ctx.font = 'bold 12px sans-serif'; - const labelText = isPanMode - ? (n.pan > 0 ? 'R' + Math.round(n.pan * 100) : n.pan < 0 ? 'L' + Math.round(Math.abs(n.pan) * 100) : 'C') - : `${n.db >= 0 ? '+' : ''}${n.db.toFixed(1)}dB`; - ctx.fillText(labelText, px + 8, y + 4); - }); - }; - - drawSpline(volumeNodes, autoY, '#f43f5e'); - drawSpline(panningNodes, autoPanY, '#a855f7', [4, 4]); - - // ── Fade Curves (transparent, only curve lines + endpoint handles) ── - const FADE_COLOR = '#b91c1c'; - const HANDLE_RADIUS = 5; - - if (fadeInLen >= 0) { - const fiPx = fadeInLen * zoom; - if (fadeInLen > 0) { - ctx.strokeStyle = FADE_COLOR; - ctx.lineWidth = 1.8; - ctx.setLineDash([]); - ctx.beginPath(); - ctx.moveTo(0, clipTop + clipHeight); - for (let px = 0; px <= fiPx; px++) { - const ratio = px / fiPx; - const amp = (1 - Math.cos(Math.PI * ratio)) / 2; - const y = clipTop + clipHeight - amp * clipHeight; - ctx.lineTo(px, y); - } - ctx.stroke(); - } - - // Endpoint handle (draggable) - const endX = fiPx, endY = clipTop; - ctx.fillStyle = '#fff'; - ctx.beginPath(); ctx.arc(endX, endY, HANDLE_RADIUS, 0, Math.PI * 2); ctx.fill(); - ctx.strokeStyle = FADE_COLOR; - ctx.lineWidth = 1.5; - ctx.beginPath(); ctx.arc(endX, endY, HANDLE_RADIUS, 0, Math.PI * 2); ctx.stroke(); - // Label - ctx.fillStyle = '#b91c1c'; - ctx.font = 'bold 7px sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText('FI', endX, endY - HANDLE_RADIUS - 3); - ctx.textAlign = 'start'; - } - - if (fadeOutLen >= 0) { - const foPx = fadeOutLen * zoom; - const startX = wClip - foPx; - if (fadeOutLen > 0) { - ctx.strokeStyle = FADE_COLOR; - ctx.lineWidth = 1.8; - ctx.setLineDash([]); - ctx.beginPath(); - ctx.moveTo(startX, clipTop); - for (let px = 0; px <= foPx; px++) { - const ratio = px / foPx; - const amp = (1 + Math.cos(Math.PI * ratio)) / 2; - const y = clipTop + clipHeight - amp * clipHeight; - ctx.lineTo(startX + px, y); - } - ctx.stroke(); - } - - // Endpoint handle (draggable) - const handleX = startX, handleY = clipTop; - ctx.fillStyle = '#fff'; - ctx.beginPath(); ctx.arc(handleX, handleY, HANDLE_RADIUS, 0, Math.PI * 2); ctx.fill(); - ctx.strokeStyle = FADE_COLOR; - ctx.lineWidth = 1.5; - ctx.beginPath(); ctx.arc(handleX, handleY, HANDLE_RADIUS, 0, Math.PI * 2); ctx.stroke(); - ctx.fillStyle = '#b91c1c'; - ctx.font = 'bold 7px sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText('FO', handleX, handleY - HANDLE_RADIUS - 3); - ctx.textAlign = 'start'; - } - - // Draw right-edge stretch handle indicator - if (wClip > 0 && wClip < drawWidth) { - ctx.strokeStyle = clipColor; - ctx.lineWidth = 1.5; - ctx.setLineDash([3, 3]); - ctx.beginPath(); - ctx.moveTo(wClip + xStart, clipTop); - ctx.lineTo(wClip + xStart, clipTop + clipHeight); - ctx.stroke(); - ctx.setLineDash([]); - } - - // 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 * zoom; - const rightPx = right * zoom; - ctx.fillStyle = 'rgba(245, 158, 11, 0.15)'; - ctx.fillRect(leftPx, 0, rightPx - leftPx, h); - ctx.strokeStyle = '#f59e0b'; - ctx.lineWidth = 1; - ctx.beginPath(); - 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 / speed) { - const playheadPx = currentTime * zoom; - ctx.strokeStyle = '#ef4444'; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.moveTo(playheadPx, 0); - ctx.lineTo(playheadPx, h); - ctx.stroke(); - } - - // Update TCP slider values in real-time to match the curve at currentTime - const volInput = document.getElementById(`tcp-vol-${subTabId}`); - const volLabel = document.getElementById(`tcp-vol-label-${subTabId}`); - if (volInput && volLabel) { - const dbVal = getVolumeDbAtTime(currentTime || 0); - volInput.value = dbVal.toFixed(1); - volLabel.textContent = `${dbVal.toFixed(1)}dB`; - } - const panInput = document.getElementById(`tcp-pan-${subTabId}`); - const panLabel = document.getElementById(`tcp-pan-label-${subTabId}`); - if (panInput && panLabel) { - const panVal = getPanningValueAtTime(currentTime || 0); - panInput.value = panVal; - panLabel.textContent = panVal > 0 ? 'R' : panVal < 0 ? 'L' : 'C'; - const panLabelDetailed = document.getElementById(`tcp-pan-label-detailed-${subTabId}`); - if (panLabelDetailed) { - panLabelDetailed.textContent = panVal > 0 ? 'R' + panVal : panVal < 0 ? 'L' + Math.abs(panVal) : 'C'; - } - } - - }, [buffer, currentTime, selectionStart, selectionEnd, zoom, timelineWidth, color, name, speed, volumeNodes, panningNodes, fadeInLen, fadeOutLen, graphMode, selectedNodeTime, subTabId]); - - const handleMouseDown = (e) => { - if (e.button === 2) return; - const canvas = canvasRef.current; - const rect = canvas.getBoundingClientRect(); - const parent = canvas.parentElement; - const scrollContainer = parent ? parent.parentElement : null; - const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; - const bufDuration = buffer.duration; - const wallDuration = bufDuration / (speed || 1.0); - const mouseX = e.clientX - rect.left + scrollLeft; - const startTime = Math.max(0, Math.min(wallDuration, mouseX / zoom)); - - // Shift + Click range selection in SubTab Waveform - if (e.shiftKey) { - e.preventDefault(); - e.stopPropagation(); - const anchor = subTabAnchorRef.current !== null && subTabAnchorRef.current !== undefined ? subTabAnchorRef.current : (selectionStart !== null && selectionStart !== undefined ? selectionStart : currentTime); - const selS = Math.min(anchor, startTime); - const selE = Math.max(anchor, startTime); - onSelectRange(selS, selE); - onPlayheadSet(startTime); - return; - } - - // Alt+Click near right edge → speed stretch - if (e.altKey && onSpeedChange) { - const clipRightEdge = (bufDuration / (speed || 1.0)) * zoom; - const tolerance = 8; - if (Math.abs(mouseX - clipRightEdge) <= tolerance) { - isStretchingRef.current = true; - stretchStartRef.current = { - mouseX, - originalDuration: bufDuration, - originalSpeed: speed - }; - canvas.style.cursor = 'ew-resize'; - - const handleMouseMove = (moveEvent) => { - const currentX = moveEvent.clientX - rect.left + scrollLeft; - const wClipPx = (stretchStartRef.current.originalDuration / stretchStartRef.current.originalSpeed) * zoom; - const deltaX = currentX - stretchStartRef.current.mouseX; - const newWClip = Math.max(10, wClipPx + deltaX); - const newSpeed = stretchStartRef.current.originalDuration / (newWClip / zoom); - if (onSpeedChange) onSpeedChange(Math.max(0.05, Math.min(10, newSpeed))); - }; - - const handleMouseUp = () => { - isStretchingRef.current = false; - canvas.style.cursor = 'default'; - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - return; - } - } - - // Mode toggle button click (bottom-right VOL/PAN) - const wClipPx = (bufDuration / (speed || 1.0)) * zoom; - const cTop = 8; - const cSize = 16; - const cHeight = rect.height - 16; - const modeBtnX = wClipPx - 28 - 4; - const modeBtnY = cTop + cHeight - 14 - 2; - if (mouseX >= modeBtnX && mouseX <= modeBtnX + 28 && (e.clientY - rect.top) >= modeBtnY && (e.clientY - rect.top) <= modeBtnY + 14) { - if (onModeToggle) onModeToggle(); - return; - } - - // Fade endpoint handle drag (click on FI/FO handle circles) - const HANDLE_R = 5; - const fiEndPx = fadeInLen * zoom; - const foStartPx = wClipPx - fadeOutLen * zoom; - const distToFiHandle = Math.abs(mouseX - fiEndPx) + Math.abs((e.clientY - rect.top) - cTop); - const distToFoHandle = Math.abs(mouseX - foStartPx) + Math.abs((e.clientY - rect.top) - cTop); - if (distToFiHandle <= HANDLE_R + 6) { - const handleMouseMove = (moveEvent) => { - const x = moveEvent.clientX - rect.left + scrollLeft; - const t = Math.max(0, Math.min(wallDuration, x / zoom)); - if (onUpdateFade) onUpdateFade({ fadeInLen: t }); - }; - const handleMouseUp = () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - return; - } - if (distToFoHandle <= HANDLE_R + 6) { - const handleMouseMove = (moveEvent) => { - const x = moveEvent.clientX - rect.left + scrollLeft; - const t = Math.max(0, Math.min(wallDuration, (wClipPx - x) / zoom)); - if (onUpdateFade) onUpdateFade({ fadeOutLen: t }); - }; - const handleMouseUp = () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - return; - } - - // Tool-specific behavior - if (activeTool === 'grab') { - setSelectedNodeTime(null); - onPlayheadSet(startTime); - - const handleMouseMove = (moveEvent) => { - const currentX = moveEvent.clientX - rect.left + scrollLeft; - const ct = Math.max(0, Math.min(wallDuration, currentX / zoom)); - onPlayheadSet(ct); - }; - - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - return; - } - - if (activeTool === 'razor') { - setSelectedNodeTime(null); - onPlayheadSet(startTime); - showToast(`Cut point at ${formatTime(startTime)}`, 'info'); - return; - } - - if (activeTool === 'pen') { - // Deduplicate by time: keep last occurrence per time key - const mergeNodes = (existing, incoming) => { - const map = new Map(); - existing.forEach(n => map.set(n.time, n)); - incoming.forEach(n => map.set(n.time, n)); - return Array.from(map.values()).sort((a, b) => a.time - b.time); - }; - onPlayheadSet(startTime); - canvas.style.cursor = 'crosshair'; - const isPan = (graphMode || 'volume') === 'pan'; - const isCtrl = e.ctrlKey || e.metaKey; - const cTop = 8; - const cHeight = rect.height - 16; - const curNodes = isPan ? panningNodes : volumeNodes; - const valFromY = (y) => { - if (isPan) return Math.max(-1, Math.min(1, -(y - cTop) / cHeight * 2 + 1)); - const yOff = (y - cTop) / cHeight; - return yOff <= 1/3 - ? Math.max(0, Math.min(3, 3 * (1 - yOff * 3))) - : Math.max(-30, Math.min(0, -30 * (yOff - 1/3) * (3/2))); - }; - - const snapVal = (v) => isPan ? Math.round(v * 20) / 20 : Math.round(v * 2) / 2; - const snapTime = (t) => Math.round(t * 10) / 10; - - // Check if clicking near existing node (any mode) - const volNodeY = (v) => { const z = cTop + (1/3) * cHeight; return v >= 0 ? z - (v / 3) * (1/3 * cHeight) : z + (-v / 30) * (2/3 * cHeight); }; - const nearNode = curNodes.findIndex(n => { - const t = Math.abs(n.time - startTime); - const val = isPan ? n.pan : n.db; - const ny = isPan ? cTop + (1 - (val + 1) / 2) * cHeight : volNodeY(val); - const dy = Math.abs((e.clientY - rect.top) - ny); - return t < 0.1 / (speed || 1) && dy < 10; - }); - - if (nearNode >= 0) { - // Drag existing node - setSelectedNodeTime(curNodes[nearNode].time); - let working = [...curNodes]; - let dragIdx = nearNode; - - const handleMouseMove = (moveEvent) => { - const currentX = moveEvent.clientX - rect.left + scrollLeft; - const ct = Math.max(0, Math.min(wallDuration, currentX / zoom)); - const val = snapVal(valFromY(moveEvent.clientY - rect.top)); - const updated = isPan - ? { time: Math.min(snapTime(ct), wallDuration), pan: val } - : { time: Math.min(snapTime(ct), wallDuration), db: val }; - const cleaned = mergeNodes(working.filter((_, i) => i !== dragIdx), [updated]); - if (onUpdateNodes) onUpdateNodes(cleaned); - working = [...cleaned]; - dragIdx = working.findIndex(n => n.time === updated.time && (isPan ? n.pan === updated.pan : n.db === updated.db)); - // Follow the selected node during dragging - setSelectedNodeTime(updated.time); - }; - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - canvas.style.cursor = 'crosshair'; - }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - return; - } - - if (isCtrl) { - setSelectedNodeTime(null); - const pts = []; - let lastKey = ''; - const handleMouseMove = (moveEvent) => { - const currentX = moveEvent.clientX - rect.left + scrollLeft; - const ct = Math.max(0, Math.min(wallDuration, currentX / zoom)); - const t = +snapTime(ct).toFixed(3); - const v = isPan - ? +snapVal(valFromY(moveEvent.clientY - rect.top)).toFixed(2) - : +snapVal(valFromY(moveEvent.clientY - rect.top)).toFixed(1); - const key = t + '|' + v; - if (key !== lastKey) { pts.push({ time: t, [isPan ? 'pan' : 'db']: v }); lastKey = key; } - }; - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - canvas.style.cursor = 'crosshair'; - const merged = mergeNodes(curNodes, pts); - if (onUpdateNodes) onUpdateNodes(merged); - }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - } else { - const newNode = isPan - ? { time: +snapTime(startTime).toFixed(3), pan: +snapVal(valFromY(e.clientY - rect.top)).toFixed(2) } - : { time: +snapTime(startTime).toFixed(3), db: +snapVal(valFromY(e.clientY - rect.top)).toFixed(1) }; - setSelectedNodeTime(newNode.time); - const merged = mergeNodes(curNodes, [newNode]); - if (onUpdateNodes) onUpdateNodes(merged); - - // Now drag this newly created node - let working = [...merged]; - let dragIdx = working.findIndex(n => n.time === newNode.time && (isPan ? n.pan === newNode.pan : n.db === newNode.db)); - - const handleMouseMove = (moveEvent) => { - const currentX = moveEvent.clientX - rect.left + scrollLeft; - const ct = Math.max(0, Math.min(wallDuration, currentX / zoom)); - const val = snapVal(valFromY(moveEvent.clientY - rect.top)); - const updated = isPan - ? { time: Math.min(snapTime(ct), wallDuration), pan: val } - : { time: Math.min(snapTime(ct), wallDuration), db: val }; - const cleaned = mergeNodes(working.filter((_, i) => i !== dragIdx), [updated]); - if (onUpdateNodes) onUpdateNodes(cleaned); - working = [...cleaned]; - dragIdx = working.findIndex(n => n.time === updated.time && (isPan ? n.pan === updated.pan : n.db === updated.db)); - setSelectedNodeTime(updated.time); - }; - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - canvas.style.cursor = 'crosshair'; - }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - } - return; - } - - // Select tool (default): drag to select range - subTabAnchorRef.current = startTime; - setSelectedNodeTime(null); - onSelectRange(startTime, startTime); - onPlayheadSet(startTime); - - const handleMouseMove = (moveEvent) => { - const currentX = moveEvent.clientX - rect.left + scrollLeft; - const ct = Math.max(0, Math.min(wallDuration, currentX / zoom)); - const anchor = subTabAnchorRef.current ?? startTime; - onSelectRange(Math.min(anchor, ct), Math.max(anchor, ct)); - }; - - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - }; - - const handleContextMenuInternal = (e) => { - e.preventDefault(); - e.stopPropagation(); - const canvas = canvasRef.current; - const rect = canvas.getBoundingClientRect(); - const parent = canvas.parentElement; - const scrollContainer = parent ? parent.parentElement : null; - const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; - const x = e.clientX - rect.left + scrollLeft; - const clickTime = Math.max(0, Math.min(buffer.duration / (speed || 1.0), x / zoom)); - onContextMenu(e, clickTime); - }; - - // Double-click on automation curve to create a new node - const handleDoubleClick = (e) => { - const canvas = canvasRef.current; - if (!canvas || !buffer) return; - if (activeTool !== 'select' && activeTool !== 'pen') return; - const rect = canvas.getBoundingClientRect(); - const parent = canvas.parentElement; - const scrollContainer = parent ? parent.parentElement : null; - const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; - const x = e.clientX - rect.left + scrollLeft; - const clickTime = Math.max(0, Math.min(buffer.duration / (speed || 1.0), x / zoom)); - const isPan = (graphMode || 'volume') === 'pan'; - const curNodes = isPan ? panningNodes : volumeNodes; - const cTop = 8; - const cHeight = rect.height - 16; - const valFromY = (y) => { - if (isPan) return Math.max(-1, Math.min(1, -(y - cTop) / cHeight * 2 + 1)); - const yOff = (y - cTop) / cHeight; - return yOff <= 2/3 - ? Math.max(0, Math.min(3, 3 * (1 - yOff * 3 / 2))) - : Math.max(-30, Math.min(0, -30 * (yOff - 2/3) * 3)); - }; - - // Interpolate value at click position from existing curve - let interpolatedVal = valFromY(e.clientY - rect.top); - if (curNodes.length >= 2) { - const sorted = [...curNodes].sort((a, b) => a.time - b.time); - for (let i = 0; i < sorted.length - 1; i++) { - if (clickTime >= sorted[i].time && clickTime <= sorted[i+1].time) { - const t = (clickTime - sorted[i].time) / (sorted[i+1].time - sorted[i].time); - const v1 = isPan ? sorted[i].pan : sorted[i].db; - const v2 = isPan ? sorted[i+1].pan : sorted[i+1].db; - interpolatedVal = v1 + t * (v2 - v1); - break; - } - } - } - const newNode = isPan - ? { time: +Math.round(clickTime * 10) / 10, pan: +Math.round(interpolatedVal * 20) / 20 } - : { time: +Math.round(clickTime * 10) / 10, db: +Math.round(interpolatedVal * 2) / 2 }; - const merged = (() => { - const map = new Map(); - curNodes.forEach(n => map.set(n.time, n)); - map.set(newNode.time, newNode); - return Array.from(map.values()).sort((a, b) => a.time - b.time); - })(); - if (onUpdateNodes) onUpdateNodes(merged); - }; - - return ( -
- { - if (canvasRef.current && e.altKey && onSpeedChange) { - const rect = canvasRef.current.getBoundingClientRect(); - const parent = canvasRef.current.parentElement; - const scrollContainer = parent ? parent.parentElement : null; - const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; - const mx = e.clientX - rect.left + scrollLeft; - const wClip = (buffer.duration / speed) * zoom; - const tolerance = 8; - canvasRef.current.style.cursor = (Math.abs(mx - wClip) <= tolerance && !isStretchingRef.current) ? 'ew-resize' : 'crosshair'; - } else if (canvasRef.current && !isStretchingRef.current) { - const rect = canvasRef.current.getBoundingClientRect(); - const parent = canvasRef.current.parentElement; - const scrollContainer = parent ? parent.parentElement : null; - const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; - const mx = e.clientX - rect.left + scrollLeft; - const wClipPx = (buffer.duration / (speed || 1.0)) * zoom; - const cH = canvasRef.current.height / (window.devicePixelRatio || 1) - 16; - const btnX = wClipPx - 28 - 4; - const btnY = 8 + cH - 14 - 2; - const overBtn = mx >= btnX && mx <= btnX + 28 && (e.clientY - rect.top) >= btnY && (e.clientY - rect.top) <= btnY + 14; - canvasRef.current.style.cursor = overBtn ? 'pointer' : 'crosshair'; - } - }} - /> -
- ); + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + canvas.style.cursor = 'crosshair'; }; - - const SubTabToolbar = ({ st, activeTool, setActiveTool, handleSubTabNormalizeWithValue, handleSubTabGainWithValue, handleSubTabPitch, handleSubTabStretch, handleSubTabFade, onPlayPause, onStop, onRewind, onForward, onLoop, onRecord, onRateChange, onCut, onCopy, onPaste, onGlue, snapValue, onSnapChange }) => { - const isPlaying = st?.isPlaying || false; - const isLooping = st?.isLooping || false; - const isRecording = st?.isRecording || false; - - return ( -
- {/* Tools Section - passthrough từ main session */} -
- - - - - -
- - - - - -
- - {/* Snap Section - passthrough */} -
- Snap - -
- - {/* Effects Controls */} -
-
- Normalize: - handleSubTabNormalizeWithValue(st.id, parseFloat(e.target.value))} className="w-20 h-1.5"/> - {st.effects?.normalizeDb || 0} dB -
-
- Gain: - handleSubTabGainWithValue(st.id, parseFloat(e.target.value))} className="w-20 h-1.5"/> - {st.effects?.gainDb || 0} dB -
-
- Pitch: - handleSubTabPitch(st.id, parseFloat(e.target.value))} className="w-20 h-1.5"/> - {st.effects?.pitch || 0} st -
-
- Stretch: - handleSubTabStretch(st.id, parseInt(e.target.value))} className="w-20 h-1.5"/> - {st.effects?.speedStretch || 100}% -
-
- - {/* Fade Buttons */} -
- - -
- - {/* Transport Controls */} -
- - - - - - -
- - {/* Rate Control */} -
- Rate: - -
-
- ); - }; - - // ── Graph Editor Canvas for Volume/Pan/Fade Automation ── - const GraphEditorCanvas = ({ buffer, zoom, timelineWidth, volumeNodes, panningNodes, fadeInLen, fadeOutLen, onUpdateNodes, graphMode }) => { - const canvasRef = useRef(null); - const isDraggingNode = useRef(false); - const dragNodeIdx = useRef(-1); - const isCreatingNode = useRef(false); - - const getNodes = () => graphMode === 'pan' ? panningNodes : volumeNodes; - const nodeLabel = (n) => graphMode === 'pan' ? `${n.pan.toFixed(2)}` : `${n.db.toFixed(1)}dB`; - const nodeY = (n, h) => { - if (graphMode === 'pan') return ((1 - (n.pan + 1) / 2) * h); - const zeroY = (2/3) * h; - return n.db >= 0 ? zeroY - (n.db / 3) * (2/3 * h) : zeroY + (-n.db / 30) * (1/3 * h); - }; - const nodeValFromY = (y, h) => { - if (graphMode === 'pan') return -(y / h * 2 - 1); - const yOff = y / h; - return yOff <= 2/3 ? 3 * (1 - yOff * 3 / 2) : -30 * (yOff - 2/3) * 3; - }; - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas || !buffer) return; - const ctx = canvas.getContext('2d'); - const dpr = window.devicePixelRatio || 1; - const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null; - const scrollLeft = wrapper ? wrapper.scrollLeft : 0; - const vWidth = wrapper ? wrapper.clientWidth : 1200; - const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200)); - const h = canvas.parentElement ? canvas.parentElement.clientHeight : 200; - - canvas.width = Math.round(drawWidth * dpr); - canvas.height = Math.round(h * dpr); - ctx.scale(dpr, dpr); - ctx.imageSmoothingEnabled = false; - - canvas.style.position = 'absolute'; - canvas.style.left = `${scrollLeft}px`; - canvas.style.width = `${drawWidth}px`; - canvas.style.height = `${h}px`; - - ctx.fillStyle = '#1a1a2e'; - ctx.fillRect(0, 0, drawWidth, h); - - ctx.strokeStyle = '#2a2a4e'; - ctx.lineWidth = 0.5; - for (let t = 0; t <= buffer.duration; t += 0.5) { - const x = (t / buffer.duration) * drawWidth; - ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); - } - for (let i = 0; i <= 10; i++) { - const y = (i / 10) * h; - ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(drawWidth, y); ctx.stroke(); - } - - if (fadeInLen > 0) { - const fadeX = (fadeInLen / buffer.duration) * drawWidth; - ctx.fillStyle = 'rgba(16, 185, 129, 0.12)'; - ctx.fillRect(0, 0, fadeX, h); - } - if (fadeOutLen > 0) { - const fadeX = ((buffer.duration - fadeOutLen) / buffer.duration) * drawWidth; - const fadeW = (fadeOutLen / buffer.duration) * drawWidth; - ctx.fillStyle = 'rgba(239, 68, 68, 0.12)'; - ctx.fillRect(fadeX, 0, fadeW, h); - } - - const nodes = getNodes(); - if (nodes.length > 0) { - ctx.strokeStyle = graphMode === 'pan' ? '#a855f7' : '#06b6d4'; - ctx.lineWidth = 2; - ctx.beginPath(); - nodes.forEach((n, i) => { - const x = (n.time / buffer.duration) * drawWidth; - const y = nodeY(n, h); - if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); - }); - ctx.stroke(); - nodes.forEach((n, i) => { - const x = (n.time / buffer.duration) * drawWidth; - const y = nodeY(n, h); - ctx.fillStyle = graphMode === 'pan' ? '#a855f7' : '#06b6d4'; - ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill(); - ctx.fillStyle = '#e4e4e7'; - ctx.font = '9px monospace'; - ctx.fillText(nodeLabel(n), x + 8, y + 3); - }); - } else { - ctx.fillStyle = '#52525b'; - ctx.font = '11px sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText(graphMode === 'pan' ? 'Click to add Pan points' : 'Click to add Volume points', drawWidth / 2, h / 2); - ctx.textAlign = 'start'; - } - - const zeroY = graphMode === 'pan' ? h / 2 : nodeY({ db: 0 }, h); - ctx.strokeStyle = graphMode === 'pan' ? '#a855f744' : '#06b6d444'; - ctx.lineWidth = 1; - ctx.setLineDash([4, 4]); - ctx.beginPath(); ctx.moveTo(0, zeroY); ctx.lineTo(drawWidth, zeroY); ctx.stroke(); - ctx.setLineDash([]); - - }, [buffer, zoom, timelineWidth, volumeNodes, panningNodes, fadeInLen, fadeOutLen, graphMode]); - - const handleMouseDown = (e) => { - const canvas = canvasRef.current; - if (!canvas || !buffer) return; - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - const time = (x / rect.width) * buffer.duration; - const val = nodeValFromY(y, rect.height); - const nodes = getNodes(); - const snapped = Math.max(-30, Math.min(3, val)); - const snappedPan = Math.max(-1, Math.min(1, val)); - - const threshold = 12 / rect.width * buffer.duration; - const nearIdx = nodes.findIndex(n => Math.abs(n.time - time) < threshold); - if (nearIdx >= 0) { isDraggingNode.current = true; dragNodeIdx.current = nearIdx; return; } - - const newNode = graphMode === 'pan' ? { time: +time.toFixed(3), pan: +snappedPan.toFixed(2) } : { time: +time.toFixed(3), db: +snapped.toFixed(1) }; - const sorted = [...nodes, newNode].sort((a, b) => a.time - b.time); - onUpdateNodes(sorted); - const newIdx = sorted.findIndex(n => n.time === newNode.time && (graphMode === 'pan' ? n.pan : n.db) === (graphMode === 'pan' ? newNode.pan : newNode.db)); - isDraggingNode.current = true; dragNodeIdx.current = newIdx; isCreatingNode.current = true; - }; - - const handleMouseMove = (e) => { - if (!isDraggingNode.current || dragNodeIdx.current < 0) return; - const canvas = canvasRef.current; - if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - const time = Math.max(0, Math.min(buffer.duration, (x / rect.width) * buffer.duration)); - const val = nodeValFromY(y, rect.height); - const nodes = [...getNodes()]; - nodes[dragNodeIdx.current] = graphMode === 'pan' - ? { time: +time.toFixed(3), pan: +Math.max(-1, Math.min(1, val)).toFixed(2) } - : { time: +time.toFixed(3), db: +Math.max(-30, Math.min(3, val)).toFixed(1) }; - onUpdateNodes(nodes.sort((a, b) => a.time - b.time)); - }; - - const handleMouseUp = () => { isDraggingNode.current = false; dragNodeIdx.current = -1; isCreatingNode.current = false; }; - const handleContextMenu = (e) => { - e.preventDefault(); - const canvas = canvasRef.current; - if (!canvas || !buffer) return; - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const time = (x / rect.width) * buffer.duration; - const threshold = 12 / rect.width * buffer.duration; - const nodes = getNodes(); - const nearIdx = nodes.findIndex(n => Math.abs(n.time - time) < threshold); - if (nearIdx >= 0) onUpdateNodes(nodes.filter((_, i) => i !== nearIdx)); - }; - - return ( -
- -
- ); - }; - - const AuthModal = ({ isOpen, mode, forceMandatory, onClose, onSuccess }) => { - if (!isOpen) return null; - const [activeTab, setActiveTab] = useState(mode || 'login'); - const [username, setUsername] = useState('admin'); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [oldPassword, setOldPassword] = useState(''); - const [newPassword, setNewPassword] = useState(''); - const [error, setError] = useState(''); - const [loading, setLoading] = useState(false); - useEffect(() => { - if (mode) setActiveTab(mode); - if (mode === 'force_change' && !oldPassword) { - setOldPassword('admin123'); - } - }, [mode]); - const handleSubmit = async (e) => { - e.preventDefault(); setError(''); setLoading(true); - try { - if (activeTab === 'login') { - const targetUsername = username.trim() || 'admin'; - const targetPwd = password.trim() || 'admin123'; - const res = await window.SonicAPI.login(targetUsername, targetPwd); - localStorage.setItem('sonic_token', res.access_token); - localStorage.setItem('sonic_user', JSON.stringify(res.user)); - if (res.user && res.user.must_change_password) { - setOldPassword(targetPwd); - } - onSuccess(res.user, res.access_token); - } else if (activeTab === 'register') { - const res = await window.SonicAPI.register(username.trim(), email.trim(), password.trim()); - localStorage.setItem('sonic_token', res.access_token); - localStorage.setItem('sonic_user', JSON.stringify(res.user)); - onSuccess(res.user, res.access_token); - } else if (activeTab === 'force_change') { - const res = await window.SonicAPI.changePassword(oldPassword.trim(), newPassword.trim()); - localStorage.setItem('sonic_token', res.access_token); - const user = JSON.parse(localStorage.getItem('sonic_user') || '{}'); - user.must_change_password = false; - localStorage.setItem('sonic_user', JSON.stringify(user)); - onSuccess(user, res.access_token); - } - } catch (err) { - setError(err.message || (activeTab === 'login' ? 'Tài khoản hoặc mật khẩu không chính xác. (Nếu bạn đã đổi mật khẩu trước đó, vui lòng nhập mật khẩu mới mà bạn đã tạo)' : 'Thao tác không thành công')); - } finally { setLoading(false); } - }; - const isForceMode = activeTab === 'force_change'; - const canClose = !forceMandatory && !isForceMode; - return ( -
-
-
-

- {isForceMode ? '⚠️ Bắt Buộc Đổi Mật Khẩu Khởi Tạo' : (activeTab === 'login' ? '🔐 Đăng Nhập Hệ Thống' : '📝 Đăng Ký Tài Khoản')} -

- {canClose && } -
- {error && (
{error}
)} -
- {isForceMode ? ( - <> -

- 🔒 Tài khoản của bạn đang dùng mật khẩu khởi tạo mặc định. Để bảo mật hệ thống, bạn phải đổi mật khẩu mới trước khi tiếp tục. -

-
- - setOldPassword(e.target.value)} className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" /> -
-
- - setNewPassword(e.target.value)} className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" /> -
- - ) : ( - <> - {activeTab === 'login' ? ( -
- - setUsername(e.target.value)} className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" /> -
- ) : ( -
- - setUsername(e.target.value)} className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" /> -
- )} - {activeTab === 'register' && ( -
- - setEmail(e.target.value)} className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" /> -
- )} -
- - setPassword(e.target.value)} className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" /> -
- - )} - - {activeTab === 'login' && !isForceMode && ( - - )} -
- {!isForceMode && ( -
- {activeTab === 'login' ? ( - Chưa có tài khoản? - ) : ( - Đã có tài khoản? - )} -
- )} -
-
- ); - }; - - const AIConfigModal = ({ isOpen, onClose }) => { - if (!isOpen) return null; - const defaultProvidersList = [ - { id: 'openai_default', name: 'OpenAI Official', provider_type: 'openai', api_base_url: 'https://api.openai.com/v1', api_key: '', model_name: 'gpt-4o', temperature: 0.7, is_active: true }, - { id: 'openai_compat_default', name: 'OpenAI Compatible (Ollama/LocalAI/DeepSeek)', provider_type: 'openai_compatible', api_base_url: 'http://localhost:11434/v1', api_key: 'ollama', model_name: 'deepseek-r1', temperature: 0.7, is_active: false }, - { id: 'anthropic_default', name: 'Anthropic Claude', provider_type: 'anthropic', api_base_url: 'https://api.anthropic.com/v1', api_key: '', model_name: 'claude-3-5-sonnet', temperature: 0.7, is_active: false }, - { id: 'gemini_default', name: 'Google Gemini', provider_type: 'gemini', api_base_url: 'https://generativelanguage.googleapis.com', api_key: '', model_name: 'gemini-1.5-pro', temperature: 0.7, is_active: false } - ]; - const [providers, setProviders] = useState(defaultProvidersList); - const [selectedId, setSelectedId] = useState('openai_default'); - const [msg, setMsg] = useState(''); - const [error, setError] = useState(''); - const [loading, setLoading] = useState(false); - - useEffect(() => { if (isOpen) loadConfigs(); }, [isOpen]); - - const loadConfigs = async () => { - setLoading(true); setError(''); - try { - const data = await window.SonicAPI.getAIConfigs(); - if (data && data.providers) { - setProviders(data.providers); - if (data.providers.length > 0) setSelectedId(data.providers[0].id); - } - } catch (err) { - setError(err.message || 'Lỗi nạp cấu hình AI'); - } finally { setLoading(false); } - }; - - const handleSave = async (e) => { - e.preventDefault(); setMsg(''); setError(''); setLoading(true); - try { - const res = await window.SonicAPI.saveAIConfigs(providers); - setMsg(res.message || 'Đã lưu cấu hình AI Providers thành công!'); - } catch (err) { - setError(err.message || 'Lỗi khi lưu cấu hình AI'); - } finally { setLoading(false); } - }; - - const updateProviderField = (id, field, value) => { - setProviders(prev => prev.map(p => p.id === id ? { ...p, [field]: value } : p)); - }; - - const activeProvider = providers.find(p => p.id === selectedId) || providers[0]; - - return ( -
-
-
-

- 🤖 Quản Lý & Cấu Hình AI Providers -

- -
- {msg &&
{msg}
} - {error &&
{error}
} - -
-
- Providers - {providers.map(p => ( - - ))} -
- - {activeProvider && ( -
-
- - updateProviderField(activeProvider.id, 'name', e.target.value)} - className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-cyan-500" - /> -
-
- - updateProviderField(activeProvider.id, 'api_base_url', e.target.value)} - className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-cyan-300 focus:outline-none focus:border-cyan-500" - placeholder="https://api.openai.com/v1" - /> -
-
- - updateProviderField(activeProvider.id, 'api_key', e.target.value)} - className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-slate-200 focus:outline-none focus:border-cyan-500" - placeholder="sk-..." - /> -
-
-
- - updateProviderField(activeProvider.id, 'model_name', e.target.value)} - className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-amber-300 focus:outline-none focus:border-cyan-500" - /> -
-
- - updateProviderField(activeProvider.id, 'temperature', parseFloat(e.target.value))} - className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-cyan-500" - /> -
-
-
- - -
-
- )} -
-
-
- ); - }; - - const ProfileModal = ({ isOpen, onClose }) => { - if (!isOpen) return null; - const [profile, setProfile] = useState(null); - const [oldPassword, setOldPassword] = useState(''); - const [newPassword, setNewPassword] = useState(''); - const [msg, setMsg] = useState(''); - const [error, setError] = useState(''); - const [loading, setLoading] = useState(false); - useEffect(() => { if (isOpen) fetchProfile(); }, [isOpen]); - const fetchProfile = async () => { - try { const data = await window.SonicAPI.getProfile(); setProfile(data); } - catch (e) { setError(e.message || 'Không thể tải thông tin profile'); } - }; - const handleChangePassword = async (e) => { - e.preventDefault(); setMsg(''); setError(''); setLoading(true); - try { - const res = await window.SonicAPI.changePassword(oldPassword, newPassword); - setMsg(res.message || 'Đổi mật khẩu thành công!'); - setOldPassword(''); setNewPassword(''); - } catch (err) { setError(err.message || 'Lỗi khi đổi mật khẩu'); } - finally { setLoading(false); } - }; - return ( -
-
-
-

👤 Hồ Sơ Cá Nhân & Hạn Mức Quota

- -
- {profile && ( -
-
-
Tên người dùng{profile.username}
-
Vai trò{profile.role}
-
Email{profile.email}
-
Dung lượng Quota{profile.quota.used_mb} MB / {profile.quota.storage_limit_mb} MB
-
-
-
- Tiến trình sử dụng bộ nhớ Server - {((profile.quota.used_mb / profile.quota.storage_limit_mb) * 100).toFixed(1)}% -
-
-
-
-
-
-

Thay Đổi Mật Khẩu

- {msg &&
{msg}
} - {error &&
{error}
} -
- - setOldPassword(e.target.value)} className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs focus:outline-none focus:border-teal-500" /> -
-
- - setNewPassword(e.target.value)} className="w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs focus:outline-none focus:border-teal-500" /> -
- -
-
- )} -
-
- ); - }; - - const SystemManagerModal = ({ isOpen, onClose }) => { - if (!isOpen) return null; - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); - const [msg, setMsg] = useState(''); - const [error, setError] = useState(''); - const [editingQuotaUser, setEditingQuotaUser] = useState(null); - const [newQuotaMb, setNewQuotaMb] = useState(500); - useEffect(() => { if (isOpen) loadUsers(); }, [isOpen]); - const loadUsers = async () => { - setLoading(true); setError(''); - try { const data = await window.SonicAPI.listUsers(); setUsers(data); } - catch (err) { setError(err.message || 'Không thể tải danh sách người dùng hệ thống'); } - finally { setLoading(false); } - }; - const handleSaveQuota = async (userId) => { - try { - await window.SonicAPI.updateUserQuota(userId, parseInt(newQuotaMb)); - setMsg('Đã cập nhật hạn mức Quota thành công!'); - setEditingQuotaUser(null); loadUsers(); - } catch (err) { setError(err.message || 'Lỗi cập nhật Quota'); } - }; - const handleToggleRole = async (user) => { - const nextRole = user.role === 'admin' ? 'standard' : 'admin'; - try { - await window.SonicAPI.updateUserRole(user.id, nextRole, user.is_active); - setMsg(`Đã đổi vai trò người dùng ${user.username} thành ${nextRole}`); - loadUsers(); - } catch (err) { setError(err.message || 'Lỗi cập nhật vai trò'); } - }; - const handleDeleteUser = async (userId) => { - if (!confirm('Bạn có chắc chắn muốn xóa người dùng này khỏi hệ thống?')) return; - try { - await window.SonicAPI.deleteUser(userId); - setMsg('Đã xóa người dùng thành công'); loadUsers(); - } catch (err) { setError(err.message || 'Lỗi khi xóa người dùng'); } - }; - return ( -
-
-
-

⚙️ Quản Lý Hệ Thống & Phân Quyền Admin

- -
- {msg &&
{msg}
} - {error &&
{error}
} -
- {loading ? ( -
Đang tải thông tin hệ thống...
- ) : ( - - - - - - - - - - - - - {users.map(u => ( - - - - - - - - - ))} - -
Tên Người DùngEmailVai TròDung Lượng Sử DụngHạn Mức QuotaThao Tác
- {u.username} - {u.must_change_password && Mật khẩu gốc} - {u.email}{u.role}{u.used_mb} MB - {editingQuotaUser === u.id ? ( -
- setNewQuotaMb(e.target.value)} className="w-16 bg-[#1e1e1e] border border-[#444] rounded px-1 py-0.5 text-xs text-slate-200" /> - MB - -
- ) : ( - {u.quota_mb} MB - )} -
- - - {u.role !== 'admin' && ( - - )} -
- )} -
-
-
- ); - }; - - - const App = () => { - // ── State Definitions ── - const [tracks, setTracks] = useState([ - { - id: '1', - name: 'Track 01', - buffer: null, - startTime: 0, - height: 128, - volumeDb: 0, - pan: 0, - muted: false, - solo: false, - color: '#0f766e', - markers: [], - serverFileId: null, - clips: [] - }, - { id: '2', name: 'Track 02', buffer: null, startTime: 0, height: 128, 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 } - const [hoveredTrackId, setHoveredTrackId] = useState(null); - const openPanel = (id) => { - if (id === 'export') setShowExportPanel(true); - else if (id === 'ai') setShowAIPanel(true); - else if (id === 'python_tools') setShowPythonToolsPanel(true); - else if (id === 'selection') setShowSelectionPanel(true); - }; - const [activeTool, setActiveTool] = useState('select'); // 'select' | 'grab' | 'razor' - const [snapValue, setSnapValue] = useState('free'); // 'free', '1', '1/2', '1/4', '1/8', '1/16', '1/32' - - const snapTime = (time, snapVal, bpmVal) => { - if (snapVal === 'free') return time; - const beatDuration = 60 / parseFloat(bpmVal || 120); - let divisor = 1; - if (snapVal === '1') divisor = 1; - else if (snapVal === '1/2') divisor = 0.5; - else if (snapVal === '1/4') divisor = 0.25; - else if (snapVal === '1/8') divisor = 0.125; - else if (snapVal === '1/16') divisor = 0.0625; - else if (snapVal === '1/32') divisor = 0.03125; - - const gridSpacing = beatDuration * divisor; - return Math.round(time / gridSpacing) * gridSpacing; - }; - - const snapValueRef = useRef(snapValue); - snapValueRef.current = snapValue; - const bpmRef = useRef(bpm); - bpmRef.current = bpm; - // BMP for Tempo Track - LOOP_EDITOR_2.md §6 - const [selectedTrackId, setSelectedTrackId] = useState('1'); - const [currentTime, setCurrentTime] = useState(0); - const [isPlaying, setIsPlaying] = useState(false); - const [selectionStart, setSelectionStart] = useState(null); - const [selectionEnd, setSelectionEnd] = useState(null); - const [selectionMode, setSelectionMode] = useState(null); // 'global' (from ruler) | 'local' (from track) - const [localSelectionTrackId, setLocalSelectionTrackId] = useState(null); - const [localSelectionStart, setLocalSelectionStart] = useState(null); - 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 [subTabHeight, setSubTabHeight] = useState(96); - const [isExporting, setIsExporting] = useState(false); - const [soloedTrackId, setSoloedTrackId] = useState(null); - const [toastMessage, setToastMessage] = useState(null); - const [showAIConfig, setShowAIConfig] = useState(false); - const [showExportPanel, setShowExportPanel] = useState(true); - const [showAIPanel, setShowAIPanel] = useState(true); - const [showSelectionPanel, setShowSelectionPanel] = useState(true); - const [showPythonToolsPanel, setShowPythonToolsPanel] = useState(true); - const [panelPositions, setPanelPositions] = useState({ export: 'bottom', ai: 'bottom', python_tools: 'bottom', selection: 'bottom' }); - const [panelDropZone, setPanelDropZone] = useState(null); - const [dragGhostPos, setDragGhostPos] = useState(null); - const [dragGhostPanel, setDragGhostPanel] = useState(null); - const panelDragRef = useRef(null); - const workspaceRef = useRef(null); - const [aiConfig, setAiConfig] = useState({ - baseUrl: localStorage.getItem('ai_base_url') || `${API_BASE_URL}`, - apiKey: localStorage.getItem('ai_api_key') || '', - model: localStorage.getItem('ai_model') || 'deepseek-chat', + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return; + } + if (isCtrl) { + setSelectedNodeTime(null); + const pts = []; + let lastKey = ''; + const handleMouseMove = moveEvent => { + const currentX = moveEvent.clientX - rect.left + scrollLeft; + const ct = Math.max(0, Math.min(wallDuration, currentX / zoom)); + const t = +snapTime(ct).toFixed(3); + const v = isPan ? +snapVal(valFromY(moveEvent.clientY - rect.top)).toFixed(2) : +snapVal(valFromY(moveEvent.clientY - rect.top)).toFixed(1); + const key = t + '|' + v; + if (key !== lastKey) { + pts.push({ + time: t, + [isPan ? 'pan' : 'db']: v }); - const [analysisState, setAnalysisState] = useState({ - status: 'Sẵn sàng. Chạy AI để phân tích nhịp.', - data: null, - isRunning: false, - }); - const [exportSettings, setExportSettings] = useState({ - sampleRate: '44100', - bitDepth: '16', - format: 'wav', - }); - const [serverStatus, setServerStatus] = useState('checking...'); - 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 } - const clipboardRef = useRef(null); // { buffer, name, volume, color } for copy/paste - - // ── Undo/Redo Engine (LOOP_EDITOR.md §4) ── - const [undoStack, setUndoStack] = useState([]); - const [redoStack, setRedoStack] = useState([]); - const MAX_UNDO = 30; - - const pushAction = (actionType, trackId, beforeState, afterState) => { - const node = { - action_type: actionType, - track_id: trackId, - timestamp: Date.now(), - before_state: beforeState, - after_state: afterState, - }; - setUndoStack(prev => { - const next = [...prev, node]; - if (next.length > MAX_UNDO) next.shift(); - return next; - }); - setRedoStack([]); - }; - - const handleUndo = () => { - if (undoStack.length === 0) return; - const last = undoStack[undoStack.length - 1]; - setUndoStack(prev => prev.slice(0, -1)); - setRedoStack(prev => [...prev, last]); - applyTrackState(last.track_id, last.before_state); - showToast(`Undo: ${last.action_type}`, 'info'); - }; - - const handleRedo = () => { - if (redoStack.length === 0) return; - const last = redoStack[redoStack.length - 1]; - setRedoStack(prev => prev.slice(0, -1)); - setUndoStack(prev => [...prev, last]); - applyTrackState(last.track_id, last.after_state); - showToast(`Redo: ${last.action_type}`, 'info'); - }; - - const applyTrackState = (trackId, state) => { - setTracks(prev => prev.map(t => { - if (t.id !== trackId) return t; - return { ...t, ...state }; - })); - }; - - const captureTrackSnapshot = (trackId) => { - const track = tracks.find(t => t.id === trackId); - if (!track) return null; - return { - volumeDb: track.volumeDb, - pan: track.pan, - muted: track.muted, - name: track.name, - markers: JSON.parse(JSON.stringify(track.markers || [])), - // buffer is captured via reference copy for undo; we store a clone for redo - buffer: track.buffer, - startTime: track.startTime || 0, - clips: track.clips ? track.clips.map(c => ({ - id: c.id, - buffer: c.buffer, - startTime: c.startTime, - name: c.name - })) : null - }; - }; - - // ── Tab System (LOOP_EDITOR_2.md §1) ── - const [activeTab, setActiveTab] = useState('main'); - const [subTabSelectedNodeTime, setSubTabSelectedNodeTime] = useState(null); - const [subTabNormVal, setSubTabNormVal] = useState(0); - const [subTabGainVal, setSubTabGainVal] = useState(100); - const [subTabPitchVal, setSubTabPitchVal] = useState(0); - const [subTabs, setSubTabs] = useState([]); // [{id, label, trackId, startTime, endTime, buffer, effects, currentTime, selectionStart, selectionEnd, isPlaying}, ...] - const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2 - - // ── Temp Edit Tab (LOOP_EDITOR_2.md §1.2 Sub Tab) ── - const [tempTabActive, setTempTabActive] = useState(false); - const [tempTabBuffer, setTempTabBuffer] = useState(null); - const [tempTabTrackId, setTempTabTrackId] = useState(null); - const [tempTabOrigStart, setTempTabOrigStart] = useState(0); - const [tempTabOrigEnd, setTempTabOrigEnd] = useState(0); - const tempTabCanvasRef = useRef(null); - - // Effect parameters for temp tab - const [tempTabEffects, setTempTabEffects] = useState({ - reverse: false, - gainDb: 0, - fadeInMs: 0, - fadeOutMs: 0, - }); - - // ── Auth / User State ── - const [currentUser, setCurrentUser] = useState(null); - const [authModalOpen, setAuthModalOpen] = useState(false); - const [authMode, setAuthMode] = useState('login'); // 'login' | 'register' | 'force_change' - const [isMandatoryLogin, setIsMandatoryLogin] = useState(false); - const [profileModalOpen, setProfileModalOpen] = useState(false); - const [systemManagerModalOpen, setSystemManagerModalOpen] = useState(false); - const [aiConfigModalOpen, setAiConfigModalOpen] = useState(false); - - useEffect(() => { - const checkAuthStatus = async () => { - const savedToken = localStorage.getItem('sonic_token'); - if (!savedToken) { - setIsMandatoryLogin(true); - setAuthMode('login'); - setAuthModalOpen(true); - return; - } - try { - const profile = await window.SonicAPI.getProfile(); - setCurrentUser(profile); - if (profile.must_change_password) { - setIsMandatoryLogin(true); - setAuthMode('force_change'); - setAuthModalOpen(true); - } else { - setIsMandatoryLogin(false); - setAuthModalOpen(false); - } - } catch (err) { - localStorage.removeItem('sonic_token'); - localStorage.removeItem('sonic_user'); - setCurrentUser(null); - setIsMandatoryLogin(true); - setAuthMode('login'); - setAuthModalOpen(true); - } - }; - checkAuthStatus(); - }, []); - - const loadPendingSfsProject = () => { - const proj = window.__pendingSfsProject; - if (!proj) return; - try { - const restored = (proj.tracks || []).map(t => ({ ...t, buffer: null, channelInfo: t.channelInfo || null, clips: t.clips || [], serverFileId: t.serverFileId || null })); - if (restored.length > 0) { setTracks(restored); showToast(`Đã tải dự án "${proj.name}" từ liên kết .sfs thành công!`, "success"); } - } catch (e) { showToast("Lỗi tải dự án từ .sfs", "error"); } - finally { window.__pendingSfsProject = null; } - }; - - const handleAuthSuccess = (user) => { - setCurrentUser(user); - if (user.must_change_password) { - setIsMandatoryLogin(true); - setAuthMode('force_change'); - setAuthModalOpen(true); - } else { - setIsMandatoryLogin(false); - setAuthModalOpen(false); - loadPendingSfsProject(); - } - }; - - const handleLogout = () => { - localStorage.removeItem('sonic_token'); - localStorage.removeItem('sonic_user'); - setCurrentUser(null); - setIsMandatoryLogin(true); - setAuthMode('login'); - setAuthModalOpen(true); - }; - - // ── Temp project auto-save (local + server) ── - useEffect(() => { - const serializeSafe = (arr) => (arr || []).map(t => ({ - id: t.id, name: t.name, startTime: t.startTime, height: t.height, - volumeDb: t.volumeDb, pan: t.pan, muted: t.muted, solo: t.solo, - color: t.color, markers: t.markers || [], serverFileId: t.serverFileId || null, - channelInfo: t.channelInfo ? { channels: t.channelInfo.channels, isStereo: t.channelInfo.isStereo, label: t.channelInfo.label } : null - })); - window.SonicStorage.scheduleTempAutoSave(() => ({ - id: 'temp_project', - name: 'Dự án tạm chưa lưu', - tracks: serializeSafe(tracks), - subTabs: (subTabs || []).map(s => ({ - id: s.id, label: s.label, trackId: s.trackId, clipId: s.clipId, - startTime: s.startTime, endTime: s.endTime, speed: s.speed, - fadeInLen: s.fadeInLen || 0, fadeOutLen: s.fadeOutLen || 0, - graphMode: s.graphMode, volumeNodes: s.volumeNodes || [], panningNodes: s.panningNodes || [], - channelInfo: s.channelInfo ? { channels: s.channelInfo.channels, isStereo: s.channelInfo.isStereo, label: s.channelInfo.label } : null - })) - })); - }, [tracks, subTabs]); - - // Lucide icons initialization - useEffect(() => { - setTimeout(() => { - if (window.lucide) { - window.lucide.createIcons(); - } - }, 50); - }, [activeTool, activeTab]); - - const timelineWrapperRef = useRef(null); - const tcpContainerRef = useRef(null); - - const handleTimelineScroll = (e) => { - if (tcpContainerRef.current) { - tcpContainerRef.current.scrollTop = e.currentTarget.scrollTop; - } - }; - const handleTCPScroll = (e) => { - if (timelineWrapperRef.current) { - timelineWrapperRef.current.scrollTop = e.currentTarget.scrollTop; - } - }; - const panelDropZoneRef = useRef(null); - const startPanelDrag = (panelId, e) => { - panelDragRef.current = { panelId, startX: e.clientX, startY: e.clientY }; - setPanelDropZone(null); - setDragGhostPanel(panelId); - setDragGhostPos({ x: e.clientX - 120, y: e.clientY - 20 }); - const onMove = (ev) => { - if (!panelDragRef.current || !workspaceRef.current) return; - const rect = workspaceRef.current.getBoundingClientRect(); - const x = ev.clientX - rect.left; - const y = ev.clientY - rect.top; - const w = rect.width; - const h = rect.height; - const margin = 60; - let zone = null; - if (x < margin && x > 10) zone = 'left'; - else if (x > w - margin && x < w - 10) zone = 'right'; - else if (y < margin && y > 10) zone = 'top'; - else if (y > h - margin && y < h - 10) zone = 'bottom'; - panelDropZoneRef.current = zone; - setPanelDropZone(zone); - setDragGhostPos({ x: ev.clientX - 120, y: ev.clientY - 20 }); - }; - const onUp = () => { - if (panelDragRef.current) { - const pid = panelDragRef.current.panelId; - const targetZone = panelDropZoneRef.current; - if (targetZone && targetZone !== panelPositions[pid]) { - setPanelPositions(prev => ({ ...prev, [pid]: targetZone })); - } - panelDragRef.current = null; - panelDropZoneRef.current = null; - setPanelDropZone(null); - setDragGhostPos(null); - setDragGhostPanel(null); - } - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - }; - const rulerRef = useRef(null); - const activeSourcesRef = useRef([]); - const activeTrackNodesRef = useRef({}); // { [trackId]: { gainNode, pannerNode } } - const startOffsetTimeRef = useRef(0); - const startBufferOffsetRef = useRef(0); - const startAudioTimeRef = useRef(0); - const animationFrameIdRef = useRef(null); - const toastTimeoutRef = useRef(null); - const rulerDragStartRef = useRef(null); - const rulerAnchorRef = useRef(null); - const isDraggingRulerRef = useRef(false); - const handlePlayPauseRef = useRef(null); - - // ── Keyboard Shortcuts ── - const handleUndoRef = useRef(handleUndo); - const handleRedoRef = useRef(handleRedo); - handleUndoRef.current = handleUndo; - handleRedoRef.current = handleRedo; - - const selectedClipIdRef = useRef(null); - selectedClipIdRef.current = selectedClipId; - const activeTabRef = useRef(activeTab); - activeTabRef.current = activeTab; - const activePlaybackSpeedRef = useRef(1.0); - const subTabsRef = useRef(subTabs); - subTabsRef.current = subTabs; - const subTabSelectedNodeTimeRef = useRef(null); - subTabSelectedNodeTimeRef.current = subTabSelectedNodeTime; - - const handleSubTabNormalize = (tabId) => { - setSubTabs(prev => prev.map(st => { - if (st.id !== tabId || !st.buffer) return st; - const ctx = getAudioContext(); - const data = st.buffer.getChannelData(0); - const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : 0; - const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : st.buffer.duration; - const startSample = Math.floor(left * st.buffer.sampleRate); - const endSample = Math.floor(right * st.buffer.sampleRate); - - let maxVal = 0; - for (let i = startSample; i < endSample; i++) { - const abs = Math.abs(data[i]); - if (abs > maxVal) maxVal = abs; - } - if (maxVal === 0) return st; - - const scale = 1.0 / maxVal; - const clonedBuffer = ctx.createBuffer(1, data.length, st.buffer.sampleRate); - const clonedData = clonedBuffer.getChannelData(0); - clonedData.set(data); - for (let i = startSample; i < endSample; i++) { - clonedData[i] = Math.max(-1, Math.min(1, clonedData[i] * scale)); - } - showToast('Đã Normalize vùng chọn.', 'success'); - return { ...st, buffer: clonedBuffer }; - })); - }; - - const handleSubTabGain = (tabId, gainDb) => { - setSubTabs(prev => prev.map(st => { - if (st.id !== tabId || !st.buffer) return st; - const ctx = getAudioContext(); - const data = st.buffer.getChannelData(0); - const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : 0; - const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : st.buffer.duration; - const startSample = Math.floor(left * st.buffer.sampleRate); - const endSample = Math.floor(right * st.buffer.sampleRate); - - const scale = Math.pow(10, gainDb / 20); - const clonedBuffer = ctx.createBuffer(1, data.length, st.buffer.sampleRate); - const clonedData = clonedBuffer.getChannelData(0); - clonedData.set(data); - for (let i = startSample; i < endSample; i++) { - clonedData[i] = Math.max(-1, Math.min(1, clonedData[i] * scale)); - } - showToast(`Đã điều chỉnh Gain: ${gainDb} dB.`, 'success'); - return { ...st, buffer: clonedBuffer }; - })); - }; - - const handleSubTabFade = (tabId, type) => { - setSubTabs(prev => prev.map(st => { - if (st.id !== tabId || !st.buffer) return st; - const ctx = getAudioContext(); - const data = st.buffer.getChannelData(0); - const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : 0; - const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : st.buffer.duration; - const startSample = Math.floor(left * st.buffer.sampleRate); - const endSample = Math.floor(right * st.buffer.sampleRate); - const durationSamples = endSample - startSample; - if (durationSamples <= 0) return st; - - const clonedBuffer = ctx.createBuffer(1, data.length, st.buffer.sampleRate); - const clonedData = clonedBuffer.getChannelData(0); - clonedData.set(data); - - if (type === 'in') { - for (let i = 0; i < durationSamples; i++) { - const alpha = i / durationSamples; - clonedData[startSample + i] *= alpha; - } - showToast('Đã áp dụng Fade In.', 'success'); - } else if (type === 'out') { - for (let i = 0; i < durationSamples; i++) { - const alpha = (durationSamples - i) / durationSamples; - clonedData[startSample + i] *= alpha; - } - showToast('Đã áp dụng Fade Out.', 'success'); - } - return { ...st, buffer: clonedBuffer }; - })); - }; - - const handleSubTabCut = (tabId) => { - const st = subTabsRef.current.find(s => s.id === tabId); - if (!st || !st.buffer) return; - const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : null; - const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : null; - if (left === null || right === null || left === right) { - showToast('Vui lòng chọn vùng để Cut.', 'warning'); - return; - } - const ctx = getAudioContext(); - const sr = st.buffer.sampleRate; - const data = st.buffer.getChannelData(0); - const startSample = Math.floor(left * sr); - const endSample = Math.floor(right * sr); - const len = endSample - startSample; - - const cutBuffer = ctx.createBuffer(1, len, sr); - cutBuffer.copyToChannel(data.subarray(startSample, endSample), 0); - clipboardRef.current = { buffer: cutBuffer, name: 'Subtab Clip' }; - - const newBuffer = ctx.createBuffer(1, data.length - len, sr); - const newData = newBuffer.getChannelData(0); - let idx = 0; - for (let i = 0; i < startSample; i++) newData[idx++] = data[i]; - for (let i = endSample; i < data.length; i++) newData[idx++] = data[i]; - - setSubTabs(prev => prev.map(s => s.id === tabId ? { - ...s, - buffer: newBuffer, - currentTime: left, - selectionStart: null, - selectionEnd: null - } : s)); - showToast('Đã Cut vùng chọn.', 'success'); - }; - - const handleSubTabCopy = (tabId) => { - const st = subTabsRef.current.find(s => s.id === tabId); - if (!st || !st.buffer) return; - const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : null; - const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : null; - if (left === null || right === null || left === right) { - showToast('Vui lòng chọn vùng để Copy.', 'warning'); - return; - } - const ctx = getAudioContext(); - const sr = st.buffer.sampleRate; - const data = st.buffer.getChannelData(0); - const startSample = Math.floor(left * sr); - const endSample = Math.floor(right * sr); - const len = endSample - startSample; - - const copyBuffer = ctx.createBuffer(1, len, sr); - copyBuffer.copyToChannel(data.subarray(startSample, endSample), 0); - clipboardRef.current = { buffer: copyBuffer, name: 'Subtab Clip' }; - showToast('Đã Copy vùng chọn.', 'success'); - }; - - const handleSubTabPaste = (tabId) => { - const st = subTabsRef.current.find(s => s.id === tabId); - if (!st || !st.buffer) return; - if (!clipboardRef.current || !clipboardRef.current.buffer) { - showToast('Clipboard trống.', 'warning'); - return; - } - const ctx = getAudioContext(); - const clipBuf = clipboardRef.current.buffer; - const sr = st.buffer.sampleRate; - const data = st.buffer.getChannelData(0); - const insertTime = st.currentTime || 0; - const insertSample = Math.floor(insertTime * sr); - - const newBuffer = ctx.createBuffer(1, data.length + clipBuf.length, sr); - const newData = newBuffer.getChannelData(0); - - let idx = 0; - for (let i = 0; i < insertSample; i++) newData[idx++] = data[i]; - const clipData = clipBuf.getChannelData(0); - for (let i = 0; i < clipBuf.length; i++) newData[idx++] = clipData[i]; - for (let i = insertSample; i < data.length; i++) newData[idx++] = data[i]; - - setSubTabs(prev => prev.map(s => s.id === tabId ? { - ...s, - buffer: newBuffer, - currentTime: insertTime + clipBuf.duration, - selectionStart: null, - selectionEnd: null - } : s)); - showToast('Đã dán dữ liệu âm thanh.', 'success'); - }; - - const handleSubTabDelete = (tabId) => { - const st = subTabsRef.current.find(s => s.id === tabId); - if (!st || !st.buffer) return; - const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : null; - const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : null; - if (left === null || right === null || left === right) { - showToast('Vui lòng chọn vùng để Delete.', 'warning'); - return; - } - const ctx = getAudioContext(); - const sr = st.buffer.sampleRate; - const data = st.buffer.getChannelData(0); - const startSample = Math.floor(left * sr); - const endSample = Math.floor(right * sr); - const len = endSample - startSample; - - const newBuffer = ctx.createBuffer(1, data.length - len, sr); - const newData = newBuffer.getChannelData(0); - let idx = 0; - for (let i = 0; i < startSample; i++) newData[idx++] = data[i]; - for (let i = endSample; i < data.length; i++) newData[idx++] = data[i]; - - setSubTabs(prev => prev.map(s => s.id === tabId ? { - ...s, - buffer: newBuffer, - currentTime: left, - selectionStart: null, - selectionEnd: null - } : s)); - showToast('Đã xóa vùng chọn.', 'success'); - }; - - const handleSubTabLoop = (tabId, loopCount) => { - const st = subTabsRef.current.find(s => s.id === tabId); - if (!st || !st.buffer) return; - const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : null; - const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : null; - if (left === null || right === null || left === right) { - showToast('Vui lòng chọn vùng để Loop.', 'warning'); - return; - } - const ctx = getAudioContext(); - const sr = st.buffer.sampleRate; - const data = st.buffer.getChannelData(0); - const startSample = Math.floor(left * sr); - const endSample = Math.floor(right * sr); - const len = endSample - startSample; - - // Loop payload N times - const segmentData = data.subarray(startSample, endSample); - const addedSamples = len * (loopCount - 1); - const newBuffer = ctx.createBuffer(1, data.length + addedSamples, sr); - const newData = newBuffer.getChannelData(0); - - let idx = 0; - for (let i = 0; i < endSample; i++) newData[idx++] = data[i]; - for (let n = 0; n < loopCount - 1; n++) { - for (let i = 0; i < len; i++) newData[idx++] = segmentData[i]; - } - for (let i = endSample; i < data.length; i++) newData[idx++] = data[i]; - - setSubTabs(prev => prev.map(s => s.id === tabId ? { - ...s, - buffer: newBuffer, - selectionStart: null, - selectionEnd: null - } : s)); - showToast(`Đã lặp vùng chọn ${loopCount} lần.`, 'success'); - }; - - useEffect(() => { - const handler = (e) => { - // Bypass global hotkeys when typing inside input/textarea/contentEditable elements - if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable)) { - return; - } - 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 (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; } - if (ctrl && e.key === 'l') { e.preventDefault(); handleSubTabLoop(curTabId, 4); return; } - if (e.key === 'v' || e.key === 'V') { e.preventDefault(); const val = prompt("Nhập Gain điều chỉnh (dB):", "0"); if(val) handleSubTabGain(curTabId, parseFloat(val) || 0); return; } - if (ctrl && e.key === 'x') { e.preventDefault(); handleSubTabCut(curTabId); return; } - if (ctrl && e.key === 'c') { e.preventDefault(); handleSubTabCopy(curTabId); return; } - if (ctrl && e.key === 'v') { e.preventDefault(); handleSubTabPaste(curTabId); return; } - if (e.key === 'Delete' || e.key === 'Backspace' || e.key === 'Del') { - e.preventDefault(); - if (subTabSelectedNodeTimeRef.current !== null) { - const selTime = subTabSelectedNodeTimeRef.current; - setSubTabs(prev => prev.map(s => { - if (s.id !== curTabId) return s; - const curNodes = s.graphMode === 'pan' ? (s.panningNodes || []) : (s.volumeNodes || []); - const updated = curNodes.filter(n => n.time !== selTime); - return { - ...s, - [s.graphMode === 'pan' ? 'panningNodes' : 'volumeNodes']: updated - }; - })); - setSubTabSelectedNodeTime(null); - } else { - handleSubTabDelete(curTabId); - } - return; - } - 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 && !alt && e.key === 'o') { e.preventDefault(); handleImportSFS(); return; } - if (ctrl && !alt && e.key === 'n') { e.preventDefault(); setTracks([{ id:'1', name:'Track 01', buffer:null, startTime:0, height:128, volumeDb:0, pan:0, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, startTime:0, height:128, 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(); handleExportSFS(); return; } - if ((ctrl && alt && e.key === 's') || (ctrl && e.shiftKey && e.key === 's')) { e.preventDefault(); handleExportSFS(); return; } - if (ctrl && !alt && e.key === 'i') { e.preventDefault(); addNewTrack(); return; } - if (ctrl && alt && e.key === 'i') { e.preventDefault(); showToast('Import audio','info'); return; } - if (ctrl && !alt && e.key === 'e') { e.preventDefault(); openTempTab(); return; } - if (ctrl && !alt && e.key === 'm') { e.preventDefault(); handleMergeTracks(); return; } - if (ctrl && !alt && e.key === 'c') { e.preventDefault(); handleCopyTrack(); return; } - if (ctrl && !alt && e.key === 'x') { e.preventDefault(); handleCutTrack(); return; } - if (ctrl && !alt && e.key === 'v') { e.preventDefault(); handlePasteTrack(); return; } - if (e.key === 'Delete' || e.key === 'Backspace' || e.key === 'Del') { - const selClip = selectedClipIdRef.current; - if (selClip) { - e.preventDefault(); - const { trackId, clipId } = selClip; - setTracks(prev => { - const track = prev.find(t => t.id === trackId); - if (!track) return prev; - - const beforeSnap = captureTrackSnapshotRef.current ? captureTrackSnapshotRef.current(trackId) : null; - const updatedClips = (track.clips || []).filter(c => c.id !== clipId); - const updatedTracks = prev.map(t => { - if (t.id === trackId) { - return { - ...t, - clips: updatedClips, - buffer: updatedClips.length > 0 ? updatedClips[0].buffer : null, - startTime: updatedClips.length > 0 ? updatedClips[0].startTime : 0, - name: updatedClips.length > 0 ? updatedClips[0].name : `Track ${t.id}` - }; - } - return t; - }); - - setTimeout(() => { - const afterSnap = captureTrackSnapshotRef.current ? captureTrackSnapshotRef.current(trackId) : null; - pushAction('DELETE_CLIP', trackId, beforeSnap, afterSnap); - }, 50); - - return updatedTracks; - }); - setSelectedClipId(null); - showToast('Đã xóa clip.', 'info'); - return; - } else { - e.preventDefault(); - handleDeleteTrack(); - return; - } - } - if (!ctrl && !alt && e.key === 's') { e.preventDefault(); handleSplitTrack(selectedTrackId); return; } - }; - window.addEventListener('keydown', handler); - return () => window.removeEventListener('keydown', handler); - }, []); - - // ── Temp Tab: draw isolated waveform ── - useEffect(() => { - if (!tempTabActive || !tempTabBuffer || !tempTabCanvasRef.current) return; - const canvas = tempTabCanvasRef.current; - const ctx = canvas.getContext('2d'); - const dpr = window.devicePixelRatio || 1; - const rect = canvas.getBoundingClientRect(); - canvas.width = rect.width * dpr; - canvas.height = rect.height * dpr; - ctx.scale(dpr, dpr); - - const w = rect.width; - const h = rect.height; - ctx.fillStyle = '#181818'; - ctx.fillRect(0, 0, w, h); - - const data = tempTabBuffer.getChannelData(0); - const sr = tempTabBuffer.sampleRate; - const totalSamples = data.length; - if (totalSamples === 0) return; - - ctx.strokeStyle = '#6ee7b7'; - ctx.lineWidth = 1; - - for (let px = 0; px < w; px++) { - const startSample = Math.floor((px / w) * totalSamples); - const endSample = Math.floor(((px + 1) / w) * totalSamples); - let maxVal = 0; - for (let i = startSample; i < endSample && i < totalSamples; i++) { - const abs = Math.abs(data[i]); - if (abs > maxVal) maxVal = abs; - } - const mid = h / 2; - const peakHeight = maxVal * (h * 0.4); - ctx.beginPath(); - ctx.moveTo(px, mid - peakHeight); - ctx.lineTo(px, mid + peakHeight); - ctx.stroke(); - } - }, [tempTabActive, tempTabBuffer]); - - // ── Sub Tab: open as new tab instead of modal (LOOP_EDITOR_2.md §1) ── - const openTempTab = () => { - const useLocal = selectionMode === 'local' && localSelectionTrackId; - const trackId = useLocal ? localSelectionTrackId : selectedTrackId; - const t = tracks.find(x => x.id === trackId); - if (!t || !t.buffer) { - showToast('Vui lòng chọn track có dữ liệu âm thanh.', 'warning'); - return; - } - if (selLeft === null || selRight === null || selRight <= selLeft) { - showToast('Vui lòng chọn một khoảng thời gian trên sóng âm.', 'warning'); - return; - } - - // Check if a subtab for this track+range already exists - const existing = subTabs.find(s => s.trackId === trackId && s.startTime === selLeft && s.endTime === selRight); - if (existing) { - setActiveTab(existing.id); - showToast(`Sub Tab already open.`, 'info'); - return; - } - - const sr = t.buffer.sampleRate; - const trackStart = t.startTime || 0; - const relSelLeft = Math.max(0, selLeft - trackStart); - const relSelRight = Math.max(0, selRight - trackStart); - const startSample = Math.max(0, Math.floor(relSelLeft * sr)); - const endSample = Math.min(t.buffer.length, Math.floor(relSelRight * sr)); - const len = endSample - startSample; - if (len < 100) { - showToast('Khoảng chọn quá ngắn.', 'warning'); - return; - } - - const ctx = getAudioContext(); - const numChannels = t.buffer.numberOfChannels || 1; - const subBuffer = ctx.createBuffer(numChannels, len, sr); - for (let c = 0; c < numChannels; c++) { - subBuffer.copyToChannel(t.buffer.getChannelData(c).subarray(startSample, endSample), c); - } - const subChannelInfo = window.SonicAudio.analyzeAudioBufferChannels(subBuffer); - - const tabId = 'subtab_' + Date.now(); - const tabLabel = `Edit_${t.name.replace('.wav','').slice(0,10)}_${selLeft.toFixed(1)}s`; - - setSubTabs(prev => [...prev, { - id: tabId, - label: tabLabel, - trackId: trackId, - startTime: selLeft, - endTime: selRight, - buffer: subBuffer, - channelInfo: subChannelInfo, - effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0, normalizeDb: 0, pitch: 0, speedStretch: 100 }, - currentTime: 0, - selectionStart: null, - selectionEnd: null, - isPlaying: false, - speed: 1.0, - volumeNodes: [], - panningNodes: [], - fadeInLen: 0, - fadeOutLen: 0, - graphMode: null, - isLooping: false, - loopCount: 0 - }]); - setActiveTab(tabId); - }; - - const handleEditClipInSubTab = (trackId, clipId) => { - const track = tracks.find(t => t.id === trackId); - if (!track) return; - - const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ - id: 'default_' + track.id, - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name - }] : []); - - const clip = clips.find(c => c.id === clipId || (clipId === 'default' && c.id === 'default_' + trackId)); - if (!clip || !clip.buffer) return; - - const resolvedClipId = clip.id === 'default' ? 'default_' + trackId : clip.id; - const existing = subTabs.find(s => s.clipId === resolvedClipId && s.trackId === trackId); - if (existing) { - setActiveTab(existing.id); - showToast(`Sub Tab for "${clip.name}" already open.`, 'info'); - return; - } - - const sr = clip.buffer.sampleRate; - const len = clip.buffer.length; - const numChannels = clip.buffer.numberOfChannels || 1; - const ctx = getAudioContext(); - const subBuffer = ctx.createBuffer(numChannels, len, sr); - for (let c = 0; c < numChannels; c++) { - subBuffer.copyToChannel(clip.buffer.getChannelData(c), c); - } - const subChannelInfo = window.SonicAudio.analyzeAudioBufferChannels(subBuffer); - - - const tabId = 'subtab_' + Date.now(); - const tabLabel = `Edit_${clip.name.replace('.wav','').slice(0,10)}`; - - setSubTabs(prev => [...prev, { - id: tabId, - label: tabLabel, - trackId: trackId, - clipId: resolvedClipId, - startTime: clip.startTime, - endTime: clip.startTime + clip.buffer.duration, - buffer: subBuffer, - channelInfo: subChannelInfo, - effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0, normalizeDb: 0, pitch: 0, speedStretch: 100 }, - currentTime: 0, - selectionStart: null, - selectionEnd: null, - isPlaying: false, - speed: 1.0, - volumeNodes: [], - panningNodes: [], - fadeInLen: 0, - fadeOutLen: 0, - graphMode: null, - isLooping: false, - loopCount: 0 - }]); - setActiveTab(tabId); + lastKey = key; + } + }; + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + canvas.style.cursor = 'crosshair'; + const merged = mergeNodes(curNodes, pts); + if (onUpdateNodes) onUpdateNodes(merged); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + } else { + const newNode = isPan ? { + time: +snapTime(startTime).toFixed(3), + pan: +snapVal(valFromY(e.clientY - rect.top)).toFixed(2) + } : { + time: +snapTime(startTime).toFixed(3), + db: +snapVal(valFromY(e.clientY - rect.top)).toFixed(1) + }; + setSelectedNodeTime(newNode.time); + const merged = mergeNodes(curNodes, [newNode]); + if (onUpdateNodes) onUpdateNodes(merged); + + // Now drag this newly created node + let working = [...merged]; + let dragIdx = working.findIndex(n => n.time === newNode.time && (isPan ? n.pan === newNode.pan : n.db === newNode.db)); + const handleMouseMove = moveEvent => { + const currentX = moveEvent.clientX - rect.left + scrollLeft; + const ct = Math.max(0, Math.min(wallDuration, currentX / zoom)); + const val = snapVal(valFromY(moveEvent.clientY - rect.top)); + const updated = isPan ? { + time: Math.min(snapTime(ct), wallDuration), + pan: val + } : { + time: Math.min(snapTime(ct), wallDuration), + db: val + }; + const cleaned = mergeNodes(working.filter((_, i) => i !== dragIdx), [updated]); + if (onUpdateNodes) onUpdateNodes(cleaned); + working = [...cleaned]; + dragIdx = working.findIndex(n => n.time === updated.time && (isPan ? n.pan === updated.pan : n.db === updated.db)); + setSelectedNodeTime(updated.time); + }; + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + canvas.style.cursor = 'crosshair'; + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + } + return; + } + + // Select tool (default): drag to select range + subTabAnchorRef.current = startTime; + setSelectedNodeTime(null); + onSelectRange(startTime, startTime); + onPlayheadSet(startTime); + const handleMouseMove = moveEvent => { + const currentX = moveEvent.clientX - rect.left + scrollLeft; + const ct = Math.max(0, Math.min(wallDuration, currentX / zoom)); + const anchor = subTabAnchorRef.current ?? startTime; + onSelectRange(Math.min(anchor, ct), Math.max(anchor, ct)); + }; + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + const handleContextMenuInternal = e => { + e.preventDefault(); + e.stopPropagation(); + const canvas = canvasRef.current; + const rect = canvas.getBoundingClientRect(); + const parent = canvas.parentElement; + const scrollContainer = parent ? parent.parentElement : null; + const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; + const x = e.clientX - rect.left + scrollLeft; + const clickTime = Math.max(0, Math.min(buffer.duration / (speed || 1.0), x / zoom)); + onContextMenu(e, clickTime); + }; + + // Double-click on automation curve to create a new node + const handleDoubleClick = e => { + const canvas = canvasRef.current; + if (!canvas || !buffer) return; + if (activeTool !== 'select' && activeTool !== 'pen') return; + const rect = canvas.getBoundingClientRect(); + const parent = canvas.parentElement; + const scrollContainer = parent ? parent.parentElement : null; + const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; + const x = e.clientX - rect.left + scrollLeft; + const clickTime = Math.max(0, Math.min(buffer.duration / (speed || 1.0), x / zoom)); + const isPan = (graphMode || 'volume') === 'pan'; + const curNodes = isPan ? panningNodes : volumeNodes; + const cTop = 8; + const cHeight = rect.height - 16; + const valFromY = y => { + if (isPan) return Math.max(-1, Math.min(1, -(y - cTop) / cHeight * 2 + 1)); + const yOff = (y - cTop) / cHeight; + return yOff <= 2 / 3 ? Math.max(0, Math.min(3, 3 * (1 - yOff * 3 / 2))) : Math.max(-30, Math.min(0, -30 * (yOff - 2 / 3) * 3)); + }; + + // Interpolate value at click position from existing curve + let interpolatedVal = valFromY(e.clientY - rect.top); + if (curNodes.length >= 2) { + const sorted = [...curNodes].sort((a, b) => a.time - b.time); + for (let i = 0; i < sorted.length - 1; i++) { + if (clickTime >= sorted[i].time && clickTime <= sorted[i + 1].time) { + const t = (clickTime - sorted[i].time) / (sorted[i + 1].time - sorted[i].time); + const v1 = isPan ? sorted[i].pan : sorted[i].db; + const v2 = isPan ? sorted[i + 1].pan : sorted[i + 1].db; + interpolatedVal = v1 + t * (v2 - v1); + break; + } + } + } + const newNode = isPan ? { + time: +Math.round(clickTime * 10) / 10, + pan: +Math.round(interpolatedVal * 20) / 20 + } : { + time: +Math.round(clickTime * 10) / 10, + db: +Math.round(interpolatedVal * 2) / 2 + }; + const merged = (() => { + const map = new Map(); + curNodes.forEach(n => map.set(n.time, n)); + map.set(newNode.time, newNode); + return Array.from(map.values()).sort((a, b) => a.time - b.time); + })(); + if (onUpdateNodes) onUpdateNodes(merged); + }; + return /*#__PURE__*/React.createElement("div", { + style: { + width: `${timelineWidth}px`, + height: '100%', + position: 'relative', + overflow: 'hidden' + } + }, /*#__PURE__*/React.createElement("canvas", { + ref: canvasRef, + style: { + position: 'absolute', + top: 0, + left: 0, + imageRendering: 'pixelated' + }, + className: "cursor-crosshair rounded border border-zinc-800", + onMouseDown: handleMouseDown, + onDoubleClick: handleDoubleClick, + onContextMenu: handleContextMenuInternal, + onMouseMove: e => { + if (canvasRef.current && e.altKey && onSpeedChange) { + const rect = canvasRef.current.getBoundingClientRect(); + const parent = canvasRef.current.parentElement; + const scrollContainer = parent ? parent.parentElement : null; + const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; + const mx = e.clientX - rect.left + scrollLeft; + const wClip = buffer.duration / speed * zoom; + const tolerance = 8; + canvasRef.current.style.cursor = Math.abs(mx - wClip) <= tolerance && !isStretchingRef.current ? 'ew-resize' : 'crosshair'; + } else if (canvasRef.current && !isStretchingRef.current) { + const rect = canvasRef.current.getBoundingClientRect(); + const parent = canvasRef.current.parentElement; + const scrollContainer = parent ? parent.parentElement : null; + const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; + const mx = e.clientX - rect.left + scrollLeft; + const wClipPx = buffer.duration / (speed || 1.0) * zoom; + const cH = canvasRef.current.height / (window.devicePixelRatio || 1) - 16; + const btnX = wClipPx - 28 - 4; + const btnY = 8 + cH - 14 - 2; + const overBtn = mx >= btnX && mx <= btnX + 28 && e.clientY - rect.top >= btnY && e.clientY - rect.top <= btnY + 14; + canvasRef.current.style.cursor = overBtn ? 'pointer' : 'crosshair'; + } + } + })); +}; +const SubTabToolbar = ({ + st, + activeTool, + setActiveTool, + handleSubTabNormalizeWithValue, + handleSubTabGainWithValue, + handleSubTabPitch, + handleSubTabStretch, + handleSubTabFade, + onPlayPause, + onStop, + onRewind, + onForward, + onLoop, + onRecord, + onRateChange, + onCut, + onCopy, + onPaste, + onGlue, + snapValue, + onSnapChange +}) => { + const isPlaying = st?.isPlaying || false; + const isLooping = st?.isLooping || false; + const isRecording = st?.isRecording || false; + return /*#__PURE__*/React.createElement("div", { + className: "daw-header flex h-16 items-center px-4 border-b daw-border gap-3 bg-zinc-800" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center space-x-1 border-r border-zinc-700 pr-3" + }, /*#__PURE__*/React.createElement("button", { + className: `p-1 rounded ${activeTool === 'select' ? 'bg-cyan-700' : 'bg-zinc-700'}`, + onClick: () => setActiveTool('select'), + title: "Select Tool" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "mouse-pointer", + className: "w-4 h-4" + }))), /*#__PURE__*/React.createElement("button", { + className: `p-1 rounded ${activeTool === 'grab' ? 'bg-cyan-700' : 'bg-zinc-700'}`, + onClick: () => setActiveTool('grab'), + title: "Grab Tool" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "hand", + className: "w-4 h-4" + }))), /*#__PURE__*/React.createElement("button", { + className: `p-1 rounded ${activeTool === 'razor' ? 'bg-cyan-700' : 'bg-zinc-700'}`, + onClick: () => setActiveTool('razor'), + title: "Razor Tool" + }, /*#__PURE__*/React.createElement("svg", { + className: "w-4 h-4 text-orange-400", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: "2.5", + strokeLinecap: "round", + strokeLinejoin: "round" + }, /*#__PURE__*/React.createElement("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" + }), /*#__PURE__*/React.createElement("path", { + d: "M4 9h16l-3 9H7z" + }), /*#__PURE__*/React.createElement("circle", { + cx: "12", + cy: "6", + r: "1" + }))), /*#__PURE__*/React.createElement("button", { + className: `p-1 rounded ${activeTool === 'pen' ? 'bg-cyan-700' : 'bg-zinc-700'}`, + onClick: () => setActiveTool('pen'), + title: "Pen Tool" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "pen-tool", + className: "w-4 h-4" + }))), /*#__PURE__*/React.createElement("div", { + className: "w-[1px] h-4 bg-zinc-800 mx-0.5" + }), /*#__PURE__*/React.createElement("button", { + onClick: onGlue, + className: "p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-purple-400 transition", + title: "Glue Clips" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "link", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("button", { + onClick: onCut, + className: "p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-red-400 transition", + title: "Cut (Ctrl+X)" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "scissors", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("button", { + onClick: onCopy, + className: "p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-blue-400 transition", + title: "Copy (Ctrl+C)" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "copy", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("button", { + onClick: onPaste, + className: "p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-emerald-400 transition", + title: "Paste (Ctrl+V)" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "clipboard", + className: "w-3.5 h-3.5" + })))), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1 border-r border-zinc-700 pr-3" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[9px] text-zinc-500 font-bold uppercase" + }, "Snap"), /*#__PURE__*/React.createElement("select", { + value: snapValue || 'free', + onChange: e => onSnapChange(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 font-mono" + }, /*#__PURE__*/React.createElement("option", { + value: "free" + }, "Free"), /*#__PURE__*/React.createElement("option", { + value: "1" + }, "1"), /*#__PURE__*/React.createElement("option", { + value: "1/2" + }, "1/2"), /*#__PURE__*/React.createElement("option", { + value: "1/4" + }, "1/4"), /*#__PURE__*/React.createElement("option", { + value: "1/8" + }, "1/8"), /*#__PURE__*/React.createElement("option", { + value: "1/16" + }, "1/16"), /*#__PURE__*/React.createElement("option", { + value: "1/32" + }, "1/32"))), /*#__PURE__*/React.createElement("div", { + className: "flex items-center space-x-2 flex-1 overflow-x-auto no-scrollbar" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[10px] text-zinc-400" + }, "Normalize:"), /*#__PURE__*/React.createElement("input", { + type: "range", + min: "-12", + max: "0", + value: st.effects?.normalizeDb || 0, + step: "0.1", + onChange: e => handleSubTabNormalizeWithValue(st.id, parseFloat(e.target.value)), + className: "w-20 h-1.5" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[10px] text-zinc-500 w-10 text-right" + }, st.effects?.normalizeDb || 0, " dB")), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[10px] text-zinc-400" + }, "Gain:"), /*#__PURE__*/React.createElement("input", { + type: "range", + min: "-40", + max: "24", + value: st.effects?.gainDb || 0, + step: "0.1", + onChange: e => handleSubTabGainWithValue(st.id, parseFloat(e.target.value)), + className: "w-20 h-1.5" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[10px] text-zinc-500 w-10 text-right" + }, st.effects?.gainDb || 0, " dB")), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[10px] text-zinc-400" + }, "Pitch:"), /*#__PURE__*/React.createElement("input", { + type: "range", + min: "-12", + max: "12", + value: st.effects?.pitch || 0, + step: "0.1", + onChange: e => handleSubTabPitch(st.id, parseFloat(e.target.value)), + className: "w-20 h-1.5" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[10px] text-zinc-500 w-10 text-right" + }, st.effects?.pitch || 0, " st")), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[10px] text-zinc-400" + }, "Stretch:"), /*#__PURE__*/React.createElement("input", { + type: "range", + min: "50", + max: "200", + value: st.effects?.speedStretch || 100, + step: "1", + onChange: e => handleSubTabStretch(st.id, parseInt(e.target.value)), + className: "w-20 h-1.5" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[10px] text-zinc-500 w-10 text-right" + }, st.effects?.speedStretch || 100, "%"))), /*#__PURE__*/React.createElement("div", { + className: "flex items-center space-x-1 border-r border-zinc-700 pr-3" + }, /*#__PURE__*/React.createElement("button", { + onClick: () => handleSubTabFade(st.id, 'in'), + className: "px-2 py-1 text-[10px] rounded hover:bg-zinc-700" + }, "Fade In"), /*#__PURE__*/React.createElement("button", { + onClick: () => handleSubTabFade(st.id, 'out'), + className: "px-2 py-1 text-[10px] rounded hover:bg-zinc-700" + }, "Fade Out")), /*#__PURE__*/React.createElement("div", { + className: "flex items-center space-x-1 border-r border-zinc-700 pr-3" + }, /*#__PURE__*/React.createElement("button", { + onClick: onRewind, + className: "w-8 h-8 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", + title: "Rewind" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "skip-back", + className: "w-4 h-4" + }))), /*#__PURE__*/React.createElement("button", { + onClick: onPlayPause, + className: `w-8 h-8 flex items-center justify-center rounded border transition ${isPlaying ? 'bg-emerald-600 text-black border-emerald-500 hover:bg-emerald-500' : 'bg-cyan-600 text-white border-cyan-500 hover:bg-cyan-500'}`, + title: isPlaying ? "Pause" : "Play" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": isPlaying ? 'pause' : 'play', + className: "w-4 h-4 fill-current" + }))), /*#__PURE__*/React.createElement("button", { + onClick: onStop, + className: "w-8 h-8 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", + title: "Stop" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "square", + className: "w-4 h-4 fill-current" + }))), /*#__PURE__*/React.createElement("button", { + onClick: onForward, + className: "w-8 h-8 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", + title: "Forward" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "skip-forward", + className: "w-4 h-4" + }))), /*#__PURE__*/React.createElement("button", { + onClick: onLoop, + className: `w-8 h-8 flex items-center justify-center rounded border transition ${isLooping ? 'bg-amber-600 text-black border-amber-500 hover:bg-amber-500' : 'bg-zinc-800 text-zinc-200 border-zinc-700 hover:bg-zinc-700'}`, + title: isLooping ? "Loop On" : "Loop Off" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "repeat", + className: "w-4 h-4" + }))), /*#__PURE__*/React.createElement("button", { + onClick: onRecord, + className: `w-8 h-8 flex items-center justify-center rounded border transition ${isRecording ? 'bg-red-600 text-white border-red-500' : 'bg-zinc-800 text-zinc-200 border-zinc-700 hover:bg-zinc-700'}`, + title: isRecording ? "Recording" : "Record" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "circle", + className: "w-4 h-4" + })))), /*#__PURE__*/React.createElement("div", { + className: "flex items-center space-x-1" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[10px] text-zinc-400" + }, "Rate:"), /*#__PURE__*/React.createElement("select", { + value: st.playbackRate || 1, + onChange: e => onRateChange(parseFloat(e.target.value)), + className: "bg-zinc-700 text-zinc-100 text-[10px] px-1 py-0.5 rounded border border-zinc-600" + }, /*#__PURE__*/React.createElement("option", { + value: "0.5" + }, "0.5x"), /*#__PURE__*/React.createElement("option", { + value: "0.75" + }, "0.75x"), /*#__PURE__*/React.createElement("option", { + value: "1" + }, "1x"), /*#__PURE__*/React.createElement("option", { + value: "1.25" + }, "1.25x"), /*#__PURE__*/React.createElement("option", { + value: "1.5" + }, "1.5x"), /*#__PURE__*/React.createElement("option", { + value: "2" + }, "2x")))); +}; + +// ── Graph Editor Canvas for Volume/Pan/Fade Automation ── +const GraphEditorCanvas = ({ + buffer, + zoom, + timelineWidth, + volumeNodes, + panningNodes, + fadeInLen, + fadeOutLen, + onUpdateNodes, + graphMode +}) => { + const canvasRef = useRef(null); + const isDraggingNode = useRef(false); + const dragNodeIdx = useRef(-1); + const isCreatingNode = useRef(false); + const getNodes = () => graphMode === 'pan' ? panningNodes : volumeNodes; + const nodeLabel = n => graphMode === 'pan' ? `${n.pan.toFixed(2)}` : `${n.db.toFixed(1)}dB`; + const nodeY = (n, h) => { + if (graphMode === 'pan') return (1 - (n.pan + 1) / 2) * h; + const zeroY = 2 / 3 * h; + return n.db >= 0 ? zeroY - n.db / 3 * (2 / 3 * h) : zeroY + -n.db / 30 * (1 / 3 * h); + }; + const nodeValFromY = (y, h) => { + if (graphMode === 'pan') return -(y / h * 2 - 1); + const yOff = y / h; + return yOff <= 2 / 3 ? 3 * (1 - yOff * 3 / 2) : -30 * (yOff - 2 / 3) * 3; + }; + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !buffer) return; + const ctx = canvas.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null; + const scrollLeft = wrapper ? wrapper.scrollLeft : 0; + const vWidth = wrapper ? wrapper.clientWidth : 1200; + const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200)); + const h = canvas.parentElement ? canvas.parentElement.clientHeight : 200; + canvas.width = Math.round(drawWidth * dpr); + canvas.height = Math.round(h * dpr); + ctx.scale(dpr, dpr); + ctx.imageSmoothingEnabled = false; + canvas.style.position = 'absolute'; + canvas.style.left = `${scrollLeft}px`; + canvas.style.width = `${drawWidth}px`; + canvas.style.height = `${h}px`; + ctx.fillStyle = '#1a1a2e'; + ctx.fillRect(0, 0, drawWidth, h); + ctx.strokeStyle = '#2a2a4e'; + ctx.lineWidth = 0.5; + for (let t = 0; t <= buffer.duration; t += 0.5) { + const x = t / buffer.duration * drawWidth; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + } + for (let i = 0; i <= 10; i++) { + const y = i / 10 * h; + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(drawWidth, y); + ctx.stroke(); + } + if (fadeInLen > 0) { + const fadeX = fadeInLen / buffer.duration * drawWidth; + ctx.fillStyle = 'rgba(16, 185, 129, 0.12)'; + ctx.fillRect(0, 0, fadeX, h); + } + if (fadeOutLen > 0) { + const fadeX = (buffer.duration - fadeOutLen) / buffer.duration * drawWidth; + const fadeW = fadeOutLen / buffer.duration * drawWidth; + ctx.fillStyle = 'rgba(239, 68, 68, 0.12)'; + ctx.fillRect(fadeX, 0, fadeW, h); + } + const nodes = getNodes(); + if (nodes.length > 0) { + ctx.strokeStyle = graphMode === 'pan' ? '#a855f7' : '#06b6d4'; + ctx.lineWidth = 2; + ctx.beginPath(); + nodes.forEach((n, i) => { + const x = n.time / buffer.duration * drawWidth; + const y = nodeY(n, h); + if (i === 0) ctx.moveTo(x, y);else ctx.lineTo(x, y); + }); + ctx.stroke(); + nodes.forEach((n, i) => { + const x = n.time / buffer.duration * drawWidth; + const y = nodeY(n, h); + ctx.fillStyle = graphMode === 'pan' ? '#a855f7' : '#06b6d4'; + ctx.beginPath(); + ctx.arc(x, y, 5, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#e4e4e7'; + ctx.font = '9px monospace'; + ctx.fillText(nodeLabel(n), x + 8, y + 3); + }); + } else { + ctx.fillStyle = '#52525b'; + ctx.font = '11px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(graphMode === 'pan' ? 'Click to add Pan points' : 'Click to add Volume points', drawWidth / 2, h / 2); + ctx.textAlign = 'start'; + } + const zeroY = graphMode === 'pan' ? h / 2 : nodeY({ + db: 0 + }, h); + ctx.strokeStyle = graphMode === 'pan' ? '#a855f744' : '#06b6d444'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(0, zeroY); + ctx.lineTo(drawWidth, zeroY); + ctx.stroke(); + ctx.setLineDash([]); + }, [buffer, zoom, timelineWidth, volumeNodes, panningNodes, fadeInLen, fadeOutLen, graphMode]); + const handleMouseDown = e => { + const canvas = canvasRef.current; + if (!canvas || !buffer) return; + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const time = x / rect.width * buffer.duration; + const val = nodeValFromY(y, rect.height); + const nodes = getNodes(); + const snapped = Math.max(-30, Math.min(3, val)); + const snappedPan = Math.max(-1, Math.min(1, val)); + const threshold = 12 / rect.width * buffer.duration; + const nearIdx = nodes.findIndex(n => Math.abs(n.time - time) < threshold); + if (nearIdx >= 0) { + isDraggingNode.current = true; + dragNodeIdx.current = nearIdx; + return; + } + const newNode = graphMode === 'pan' ? { + time: +time.toFixed(3), + pan: +snappedPan.toFixed(2) + } : { + time: +time.toFixed(3), + db: +snapped.toFixed(1) + }; + const sorted = [...nodes, newNode].sort((a, b) => a.time - b.time); + onUpdateNodes(sorted); + const newIdx = sorted.findIndex(n => n.time === newNode.time && (graphMode === 'pan' ? n.pan : n.db) === (graphMode === 'pan' ? newNode.pan : newNode.db)); + isDraggingNode.current = true; + dragNodeIdx.current = newIdx; + isCreatingNode.current = true; + }; + const handleMouseMove = e => { + if (!isDraggingNode.current || dragNodeIdx.current < 0) return; + const canvas = canvasRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const time = Math.max(0, Math.min(buffer.duration, x / rect.width * buffer.duration)); + const val = nodeValFromY(y, rect.height); + const nodes = [...getNodes()]; + nodes[dragNodeIdx.current] = graphMode === 'pan' ? { + time: +time.toFixed(3), + pan: +Math.max(-1, Math.min(1, val)).toFixed(2) + } : { + time: +time.toFixed(3), + db: +Math.max(-30, Math.min(3, val)).toFixed(1) + }; + onUpdateNodes(nodes.sort((a, b) => a.time - b.time)); + }; + const handleMouseUp = () => { + isDraggingNode.current = false; + dragNodeIdx.current = -1; + isCreatingNode.current = false; + }; + const handleContextMenu = e => { + e.preventDefault(); + const canvas = canvasRef.current; + if (!canvas || !buffer) return; + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const time = x / rect.width * buffer.duration; + const threshold = 12 / rect.width * buffer.duration; + const nodes = getNodes(); + const nearIdx = nodes.findIndex(n => Math.abs(n.time - time) < threshold); + if (nearIdx >= 0) onUpdateNodes(nodes.filter((_, i) => i !== nearIdx)); + }; + return /*#__PURE__*/React.createElement("div", { + style: { + width: `${timelineWidth}px`, + height: '100%', + position: 'relative', + overflow: 'hidden' + } + }, /*#__PURE__*/React.createElement("canvas", { + ref: canvasRef, + style: { + position: 'absolute', + top: 0, + left: 0, + imageRendering: 'pixelated' + }, + className: "cursor-crosshair rounded border border-zinc-700", + onMouseDown: handleMouseDown, + onMouseMove: handleMouseMove, + onMouseUp: handleMouseUp, + onMouseLeave: handleMouseUp, + onContextMenu: handleContextMenu + })); +}; +const AuthModal = ({ + isOpen, + mode, + forceMandatory, + onClose, + onSuccess +}) => { + if (!isOpen) return null; + const [activeTab, setActiveTab] = useState(mode || 'login'); + const [username, setUsername] = useState('admin'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [oldPassword, setOldPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + useEffect(() => { + if (mode) setActiveTab(mode); + if (mode === 'force_change' && !oldPassword) { + setOldPassword('admin123'); + } + }, [mode]); + const handleSubmit = async e => { + e.preventDefault(); + setError(''); + setLoading(true); + try { + if (activeTab === 'login') { + const targetUsername = username.trim() || 'admin'; + const targetPwd = password.trim() || 'admin123'; + const res = await window.SonicAPI.login(targetUsername, targetPwd); + localStorage.setItem('sonic_token', res.access_token); + localStorage.setItem('sonic_user', JSON.stringify(res.user)); + if (res.user && res.user.must_change_password) { + setOldPassword(targetPwd); + } + onSuccess(res.user, res.access_token); + } else if (activeTab === 'register') { + const res = await window.SonicAPI.register(username.trim(), email.trim(), password.trim()); + localStorage.setItem('sonic_token', res.access_token); + localStorage.setItem('sonic_user', JSON.stringify(res.user)); + onSuccess(res.user, res.access_token); + } else if (activeTab === 'force_change') { + const res = await window.SonicAPI.changePassword(oldPassword.trim(), newPassword.trim()); + localStorage.setItem('sonic_token', res.access_token); + const user = JSON.parse(localStorage.getItem('sonic_user') || '{}'); + user.must_change_password = false; + localStorage.setItem('sonic_user', JSON.stringify(user)); + onSuccess(user, res.access_token); + } + } catch (err) { + setError(err.message || (activeTab === 'login' ? 'Tài khoản hoặc mật khẩu không chính xác. (Nếu bạn đã đổi mật khẩu trước đó, vui lòng nhập mật khẩu mới mà bạn đã tạo)' : 'Thao tác không thành công')); + } finally { + setLoading(false); + } + }; + const isForceMode = activeTab === 'force_change'; + const canClose = !forceMandatory && !isForceMode; + return /*#__PURE__*/React.createElement("div", { + className: "fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md" + }, /*#__PURE__*/React.createElement("div", { + className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-md p-6 text-slate-200" + }, /*#__PURE__*/React.createElement("div", { + className: "flex justify-between items-center pb-4 border-b border-[#383838]" + }, /*#__PURE__*/React.createElement("h3", { + className: "text-lg font-bold text-teal-400" + }, isForceMode ? '⚠️ Bắt Buộc Đổi Mật Khẩu Khởi Tạo' : activeTab === 'login' ? '🔐 Đăng Nhập Hệ Thống' : '📝 Đăng Ký Tài Khoản'), canClose && /*#__PURE__*/React.createElement("button", { + onClick: onClose, + className: "text-slate-400 hover:text-slate-200" + }, "✕")), error && /*#__PURE__*/React.createElement("div", { + className: "mt-4 p-3 bg-red-900/40 border border-red-700 rounded-lg text-red-200 text-sm" + }, error), /*#__PURE__*/React.createElement("form", { + onSubmit: handleSubmit, + className: "mt-4 space-y-4" + }, isForceMode ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("p", { + className: "text-xs text-amber-400 bg-amber-950/60 p-2.5 border border-amber-800/80 rounded leading-relaxed" + }, "🔒 Tài khoản của bạn đang dùng mật khẩu khởi tạo mặc định. Để bảo mật hệ thống, bạn phải đổi mật khẩu mới trước khi tiếp tục."), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-xs font-semibold mb-1 text-slate-400" + }, "Mật khẩu hiện tại (Mặc định: admin123)"), /*#__PURE__*/React.createElement("input", { + type: "password", + autoComplete: "current-password", + required: true, + value: oldPassword, + onChange: e => setOldPassword(e.target.value), + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" + })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-xs font-semibold mb-1 text-slate-400" + }, "Mật khẩu mới"), /*#__PURE__*/React.createElement("input", { + type: "password", + autoComplete: "new-password", + required: true, + value: newPassword, + onChange: e => setNewPassword(e.target.value), + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" + }))) : /*#__PURE__*/React.createElement(React.Fragment, null, activeTab === 'login' ? /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-xs font-semibold mb-1 text-slate-400" + }, "Tên đăng nhập ", /*#__PURE__*/React.createElement("span", { + className: "text-teal-400 font-normal" + }, "(Tùy chọn - Admin có thể bỏ trống)")), /*#__PURE__*/React.createElement("input", { + type: "text", + placeholder: "Mặc định: admin", + value: username, + onChange: e => setUsername(e.target.value), + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" + })) : /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-xs font-semibold mb-1 text-slate-400" + }, "Tên đăng nhập"), /*#__PURE__*/React.createElement("input", { + type: "text", + required: true, + value: username, + onChange: e => setUsername(e.target.value), + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" + })), activeTab === 'register' && /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-xs font-semibold mb-1 text-slate-400" + }, "Email"), /*#__PURE__*/React.createElement("input", { + type: "email", + required: true, + value: email, + onChange: e => setEmail(e.target.value), + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" + })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-xs font-semibold mb-1 text-slate-400" + }, "Mật khẩu ", activeTab === 'login' && /*#__PURE__*/React.createElement("span", { + className: "text-amber-400 font-normal" + }, "(Lần đầu: admin123)")), /*#__PURE__*/React.createElement("input", { + type: "password", + autoComplete: activeTab === 'login' ? 'current-password' : 'new-password', + required: true, + value: password, + onChange: e => setPassword(e.target.value), + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" + }))), /*#__PURE__*/React.createElement("button", { + type: "submit", + disabled: loading, + className: "w-full py-2 bg-teal-600 hover:bg-teal-500 text-white font-semibold rounded-lg shadow transition duration-150" + }, loading ? 'Đang xác thực...' : isForceMode ? 'Đổi Mật Khẩu Ngay' : activeTab === 'login' ? 'Đăng Nhập System' : 'Tạo Tài Khoản Mới'), activeTab === 'login' && !isForceMode && /*#__PURE__*/React.createElement("button", { + type: "button", + onClick: () => { + setUsername('admin'); + setPassword('admin123'); + setError(''); + }, + className: "w-full mt-2 py-1.5 bg-amber-950/60 hover:bg-amber-900/80 text-amber-300 border border-amber-800/60 text-xs font-semibold rounded-lg transition flex items-center justify-center gap-1.5" + }, "🔑 Điền Nhanh Tài Khoản Admin (admin / admin123)")), !isForceMode && /*#__PURE__*/React.createElement("div", { + className: "mt-4 pt-4 border-t border-[#383838] text-center text-xs text-slate-400" + }, activeTab === 'login' ? /*#__PURE__*/React.createElement("span", null, "Chưa có tài khoản? ", /*#__PURE__*/React.createElement("button", { + onClick: () => setActiveTab('register'), + className: "text-teal-400 hover:underline" + }, "Đăng ký ngay")) : /*#__PURE__*/React.createElement("span", null, "Đã có tài khoản? ", /*#__PURE__*/React.createElement("button", { + onClick: () => setActiveTab('login'), + className: "text-teal-400 hover:underline" + }, "Đăng nhập"))))); +}; +const AIConfigModal = ({ + isOpen, + onClose +}) => { + if (!isOpen) return null; + const defaultProvidersList = [{ + id: 'openai_default', + name: 'OpenAI Official', + provider_type: 'openai', + api_base_url: 'https://api.openai.com/v1', + api_key: '', + model_name: 'gpt-4o', + temperature: 0.7, + is_active: true + }, { + id: 'openai_compat_default', + name: 'OpenAI Compatible (Ollama/LocalAI/DeepSeek)', + provider_type: 'openai_compatible', + api_base_url: 'http://localhost:11434/v1', + api_key: 'ollama', + model_name: 'deepseek-r1', + temperature: 0.7, + is_active: false + }, { + id: 'anthropic_default', + name: 'Anthropic Claude', + provider_type: 'anthropic', + api_base_url: 'https://api.anthropic.com/v1', + api_key: '', + model_name: 'claude-3-5-sonnet', + temperature: 0.7, + is_active: false + }, { + id: 'gemini_default', + name: 'Google Gemini', + provider_type: 'gemini', + api_base_url: 'https://generativelanguage.googleapis.com', + api_key: '', + model_name: 'gemini-1.5-pro', + temperature: 0.7, + is_active: false + }]; + const [providers, setProviders] = useState(defaultProvidersList); + const [selectedId, setSelectedId] = useState('openai_default'); + const [msg, setMsg] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + useEffect(() => { + if (isOpen) loadConfigs(); + }, [isOpen]); + const loadConfigs = async () => { + setLoading(true); + setError(''); + try { + const data = await window.SonicAPI.getAIConfigs(); + if (data && data.providers) { + setProviders(data.providers); + if (data.providers.length > 0) setSelectedId(data.providers[0].id); + } + } catch (err) { + setError(err.message || 'Lỗi nạp cấu hình AI'); + } finally { + setLoading(false); + } + }; + const handleSave = async e => { + e.preventDefault(); + setMsg(''); + setError(''); + setLoading(true); + try { + const res = await window.SonicAPI.saveAIConfigs(providers); + setMsg(res.message || 'Đã lưu cấu hình AI Providers thành công!'); + } catch (err) { + setError(err.message || 'Lỗi khi lưu cấu hình AI'); + } finally { + setLoading(false); + } + }; + const updateProviderField = (id, field, value) => { + setProviders(prev => prev.map(p => p.id === id ? { + ...p, + [field]: value + } : p)); + }; + const activeProvider = providers.find(p => p.id === selectedId) || providers[0]; + return /*#__PURE__*/React.createElement("div", { + className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm select-none" + }, /*#__PURE__*/React.createElement("div", { + className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-2xl p-6 text-slate-200" + }, /*#__PURE__*/React.createElement("div", { + className: "flex justify-between items-center pb-4 border-b border-[#383838]" + }, /*#__PURE__*/React.createElement("h3", { + className: "text-lg font-bold text-cyan-400 flex items-center gap-2" + }, "🤖 Quản Lý & Cấu Hình AI Providers"), /*#__PURE__*/React.createElement("button", { + onClick: onClose, + className: "text-slate-400 hover:text-slate-200" + }, "✕")), msg && /*#__PURE__*/React.createElement("div", { + className: "mt-3 p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs" + }, msg), error && /*#__PURE__*/React.createElement("div", { + className: "mt-3 p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs" + }, error), /*#__PURE__*/React.createElement("div", { + className: "mt-4 grid grid-cols-3 gap-4" + }, /*#__PURE__*/React.createElement("div", { + className: "space-y-1.5 border-r border-[#383838] pr-3" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[10px] uppercase font-bold text-slate-400 block mb-2" + }, "Providers"), providers.map(p => /*#__PURE__*/React.createElement("button", { + key: p.id, + onClick: () => setSelectedId(p.id), + className: `w-full text-left px-3 py-2 rounded-lg text-xs font-semibold flex items-center justify-between transition ${selectedId === p.id ? 'bg-cyan-600 text-white shadow' : 'bg-[#1e1e1e] text-slate-300 hover:bg-[#2e2e2e]'}` + }, /*#__PURE__*/React.createElement("span", { + className: "truncate" + }, p.name), p.is_active && /*#__PURE__*/React.createElement("span", { + className: "w-2 h-2 rounded-full bg-emerald-400" + })))), activeProvider && /*#__PURE__*/React.createElement("form", { + onSubmit: handleSave, + className: "col-span-2 space-y-3.5" + }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-xs font-semibold text-slate-400 mb-1" + }, "Tên Provider"), /*#__PURE__*/React.createElement("input", { + type: "text", + value: activeProvider.name, + onChange: e => updateProviderField(activeProvider.id, 'name', e.target.value), + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-cyan-500" + })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-xs font-semibold text-slate-400 mb-1" + }, "API Base URL (Endpoint)"), /*#__PURE__*/React.createElement("input", { + type: "text", + value: activeProvider.api_base_url || '', + onChange: e => updateProviderField(activeProvider.id, 'api_base_url', e.target.value), + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-cyan-300 focus:outline-none focus:border-cyan-500", + placeholder: "https://api.openai.com/v1" + })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-xs font-semibold text-slate-400 mb-1" + }, "API Key Cá Nhân"), /*#__PURE__*/React.createElement("input", { + type: "password", + value: activeProvider.api_key || '', + onChange: e => updateProviderField(activeProvider.id, 'api_key', e.target.value), + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-slate-200 focus:outline-none focus:border-cyan-500", + placeholder: "sk-..." + })), /*#__PURE__*/React.createElement("div", { + className: "grid grid-cols-2 gap-3" + }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-xs font-semibold text-slate-400 mb-1" + }, "Model Name"), /*#__PURE__*/React.createElement("input", { + type: "text", + value: activeProvider.model_name || '', + onChange: e => updateProviderField(activeProvider.id, 'model_name', e.target.value), + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-amber-300 focus:outline-none focus:border-cyan-500" + })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-xs font-semibold text-slate-400 mb-1" + }, "Temperature"), /*#__PURE__*/React.createElement("input", { + type: "number", + step: "0.1", + min: "0", + max: "2", + value: activeProvider.temperature ?? 0.7, + onChange: e => updateProviderField(activeProvider.id, 'temperature', parseFloat(e.target.value)), + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-cyan-500" + }))), /*#__PURE__*/React.createElement("div", { + className: "pt-2 flex items-center justify-between" + }, /*#__PURE__*/React.createElement("label", { + className: "flex items-center gap-2 cursor-pointer text-xs text-slate-300" + }, /*#__PURE__*/React.createElement("input", { + type: "checkbox", + checked: activeProvider.is_active, + onChange: e => updateProviderField(activeProvider.id, 'is_active', e.target.checked), + className: "rounded accent-cyan-500" + }), "Kích hoạt Provider này"), /*#__PURE__*/React.createElement("button", { + type: "submit", + disabled: loading, + className: "px-4 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-white font-semibold text-xs rounded-lg shadow transition" + }, loading ? 'Đang lưu...' : 'Lưu Cấu Hình AI')))))); +}; +const ProfileModal = ({ + isOpen, + onClose +}) => { + if (!isOpen) return null; + const [profile, setProfile] = useState(null); + const [oldPassword, setOldPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [msg, setMsg] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + useEffect(() => { + if (isOpen) fetchProfile(); + }, [isOpen]); + const fetchProfile = async () => { + try { + const data = await window.SonicAPI.getProfile(); + setProfile(data); + } catch (e) { + setError(e.message || 'Không thể tải thông tin profile'); + } + }; + const handleChangePassword = async e => { + e.preventDefault(); + setMsg(''); + setError(''); + setLoading(true); + try { + const res = await window.SonicAPI.changePassword(oldPassword, newPassword); + setMsg(res.message || 'Đổi mật khẩu thành công!'); + setOldPassword(''); + setNewPassword(''); + } catch (err) { + setError(err.message || 'Lỗi khi đổi mật khẩu'); + } finally { + setLoading(false); + } + }; + return /*#__PURE__*/React.createElement("div", { + className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" + }, /*#__PURE__*/React.createElement("div", { + className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-lg p-6 text-slate-200" + }, /*#__PURE__*/React.createElement("div", { + className: "flex justify-between items-center pb-4 border-b border-[#383838]" + }, /*#__PURE__*/React.createElement("h3", { + className: "text-lg font-bold text-teal-400" + }, "👤 Hồ Sơ Cá Nhân & Hạn Mức Quota"), /*#__PURE__*/React.createElement("button", { + onClick: onClose, + className: "text-slate-400 hover:text-slate-200" + }, "✕")), profile && /*#__PURE__*/React.createElement("div", { + className: "mt-4 space-y-4" + }, /*#__PURE__*/React.createElement("div", { + className: "bg-[#1e1e1e] p-4 rounded-lg border border-[#333] grid grid-cols-2 gap-4 text-xs" + }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", { + className: "text-slate-500 block" + }, "Tên người dùng"), /*#__PURE__*/React.createElement("span", { + className: "font-bold text-teal-300 text-sm" + }, profile.username)), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", { + className: "text-slate-500 block" + }, "Vai trò"), /*#__PURE__*/React.createElement("span", { + className: "uppercase font-semibold text-amber-400" + }, profile.role)), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", { + className: "text-slate-500 block" + }, "Email"), /*#__PURE__*/React.createElement("span", null, profile.email)), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", { + className: "text-slate-500 block" + }, "Dung lượng Quota"), /*#__PURE__*/React.createElement("span", { + className: "font-semibold text-slate-200" + }, profile.quota.used_mb, " MB / ", profile.quota.storage_limit_mb, " MB"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("div", { + className: "flex justify-between text-xs mb-1" + }, /*#__PURE__*/React.createElement("span", { + className: "text-slate-400" + }, "Tiến trình sử dụng bộ nhớ Server"), /*#__PURE__*/React.createElement("span", { + className: "font-bold text-teal-400" + }, (profile.quota.used_mb / profile.quota.storage_limit_mb * 100).toFixed(1), "%")), /*#__PURE__*/React.createElement("div", { + className: "w-full h-2 bg-slate-800 rounded-full overflow-hidden" + }, /*#__PURE__*/React.createElement("div", { + className: "h-full bg-teal-500 rounded-full transition-all duration-300", + style: { + width: `${Math.min(100, profile.quota.used_mb / profile.quota.storage_limit_mb * 100)}%` + } + }))), /*#__PURE__*/React.createElement("form", { + onSubmit: handleChangePassword, + className: "pt-4 border-t border-[#383838] space-y-3" + }, /*#__PURE__*/React.createElement("h4", { + className: "text-xs font-bold text-slate-300 uppercase" + }, "Thay Đổi Mật Khẩu"), msg && /*#__PURE__*/React.createElement("div", { + className: "p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs" + }, msg), error && /*#__PURE__*/React.createElement("div", { + className: "p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs" + }, error), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-xs text-slate-400 mb-1" + }, "Mật khẩu cũ"), /*#__PURE__*/React.createElement("input", { + type: "password", + autoComplete: "current-password", + required: true, + value: oldPassword, + onChange: e => setOldPassword(e.target.value), + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs focus:outline-none focus:border-teal-500" + })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-xs text-slate-400 mb-1" + }, "Mật khẩu mới"), /*#__PURE__*/React.createElement("input", { + type: "password", + autoComplete: "new-password", + required: true, + value: newPassword, + onChange: e => setNewPassword(e.target.value), + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs focus:outline-none focus:border-teal-500" + })), /*#__PURE__*/React.createElement("button", { + type: "submit", + disabled: loading, + className: "w-full py-1.5 bg-teal-600 hover:bg-teal-500 text-white font-semibold text-xs rounded transition" + }, loading ? 'Đang cập nhật...' : 'Cập Nhật Mật Khẩu'))))); +}; +const SystemManagerModal = ({ + isOpen, + onClose +}) => { + if (!isOpen) return null; + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [msg, setMsg] = useState(''); + const [error, setError] = useState(''); + const [editingQuotaUser, setEditingQuotaUser] = useState(null); + const [newQuotaMb, setNewQuotaMb] = useState(500); + useEffect(() => { + if (isOpen) loadUsers(); + }, [isOpen]); + const loadUsers = async () => { + setLoading(true); + setError(''); + try { + const data = await window.SonicAPI.listUsers(); + setUsers(data); + } catch (err) { + setError(err.message || 'Không thể tải danh sách người dùng hệ thống'); + } finally { + setLoading(false); + } + }; + const handleSaveQuota = async userId => { + try { + await window.SonicAPI.updateUserQuota(userId, parseInt(newQuotaMb)); + setMsg('Đã cập nhật hạn mức Quota thành công!'); + setEditingQuotaUser(null); + loadUsers(); + } catch (err) { + setError(err.message || 'Lỗi cập nhật Quota'); + } + }; + const handleToggleRole = async user => { + const nextRole = user.role === 'admin' ? 'standard' : 'admin'; + try { + await window.SonicAPI.updateUserRole(user.id, nextRole, user.is_active); + setMsg(`Đã đổi vai trò người dùng ${user.username} thành ${nextRole}`); + loadUsers(); + } catch (err) { + setError(err.message || 'Lỗi cập nhật vai trò'); + } + }; + const handleDeleteUser = async userId => { + if (!confirm('Bạn có chắc chắn muốn xóa người dùng này khỏi hệ thống?')) return; + try { + await window.SonicAPI.deleteUser(userId); + setMsg('Đã xóa người dùng thành công'); + loadUsers(); + } catch (err) { + setError(err.message || 'Lỗi khi xóa người dùng'); + } + }; + return /*#__PURE__*/React.createElement("div", { + className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" + }, /*#__PURE__*/React.createElement("div", { + className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-4xl p-6 text-slate-200" + }, /*#__PURE__*/React.createElement("div", { + className: "flex justify-between items-center pb-4 border-b border-[#383838]" + }, /*#__PURE__*/React.createElement("h3", { + className: "text-lg font-bold text-amber-400" + }, "⚙️ Quản Lý Hệ Thống & Phân Quyền Admin"), /*#__PURE__*/React.createElement("button", { + onClick: onClose, + className: "text-slate-400 hover:text-slate-200" + }, "✕")), msg && /*#__PURE__*/React.createElement("div", { + className: "mt-3 p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs" + }, msg), error && /*#__PURE__*/React.createElement("div", { + className: "mt-3 p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs" + }, error), /*#__PURE__*/React.createElement("div", { + className: "mt-4 overflow-x-auto max-h-96 no-scrollbar" + }, loading ? /*#__PURE__*/React.createElement("div", { + className: "py-8 text-center text-slate-400 text-xs" + }, "Đang tải thông tin hệ thống...") : /*#__PURE__*/React.createElement("table", { + className: "w-full text-left text-xs border-collapse" + }, /*#__PURE__*/React.createElement("thead", null, /*#__PURE__*/React.createElement("tr", { + className: "border-b border-[#383838] text-slate-400 bg-[#1e1e1e]" + }, /*#__PURE__*/React.createElement("th", { + className: "p-3" + }, "Tên Người Dùng"), /*#__PURE__*/React.createElement("th", { + className: "p-3" + }, "Email"), /*#__PURE__*/React.createElement("th", { + className: "p-3" + }, "Vai Trò"), /*#__PURE__*/React.createElement("th", { + className: "p-3" + }, "Dung Lượng Sử Dụng"), /*#__PURE__*/React.createElement("th", { + className: "p-3" + }, "Hạn Mức Quota"), /*#__PURE__*/React.createElement("th", { + className: "p-3 text-right" + }, "Thao Tác"))), /*#__PURE__*/React.createElement("tbody", { + className: "divide-y divide-[#333]" + }, users.map(u => /*#__PURE__*/React.createElement("tr", { + key: u.id, + className: "hover:bg-[#2e2e2e]" + }, /*#__PURE__*/React.createElement("td", { + className: "p-3 font-semibold text-teal-300" + }, u.username, u.must_change_password && /*#__PURE__*/React.createElement("span", { + className: "ml-2 text-[10px] bg-amber-900/60 text-amber-300 px-1.5 py-0.5 rounded" + }, "Mật khẩu gốc")), /*#__PURE__*/React.createElement("td", { + className: "p-3 text-slate-300" + }, u.email), /*#__PURE__*/React.createElement("td", { + className: "p-3 uppercase font-bold text-amber-400" + }, u.role), /*#__PURE__*/React.createElement("td", { + className: "p-3" + }, u.used_mb, " MB"), /*#__PURE__*/React.createElement("td", { + className: "p-3" + }, editingQuotaUser === u.id ? /*#__PURE__*/React.createElement("div", { + className: "flex items-center space-x-1" + }, /*#__PURE__*/React.createElement("input", { + type: "number", + value: newQuotaMb, + onChange: e => setNewQuotaMb(e.target.value), + className: "w-16 bg-[#1e1e1e] border border-[#444] rounded px-1 py-0.5 text-xs text-slate-200" + }), /*#__PURE__*/React.createElement("span", null, "MB"), /*#__PURE__*/React.createElement("button", { + onClick: () => handleSaveQuota(u.id), + className: "px-2 py-0.5 bg-teal-600 rounded text-[10px]" + }, "Lưu")) : /*#__PURE__*/React.createElement("span", { + className: "font-semibold" + }, u.quota_mb, " MB")), /*#__PURE__*/React.createElement("td", { + className: "p-3 text-right space-x-2" + }, /*#__PURE__*/React.createElement("button", { + onClick: () => { + setEditingQuotaUser(u.id); + setNewQuotaMb(u.quota_mb); + }, + className: "px-2 py-1 bg-slate-700 hover:bg-slate-600 rounded text-[11px]" + }, "Sửa Quota"), /*#__PURE__*/React.createElement("button", { + onClick: () => handleToggleRole(u), + className: "px-2 py-1 bg-amber-700/60 hover:bg-amber-600 rounded text-[11px]" + }, "Đổi Role"), u.role !== 'admin' && /*#__PURE__*/React.createElement("button", { + onClick: () => handleDeleteUser(u.id), + className: "px-2 py-1 bg-red-700/60 hover:bg-red-600 rounded text-[11px]" + }, "Xóa"))))))))); +}; +const App = () => { + // ── State Definitions ── + const [tracks, setTracks] = useState([{ + id: '1', + name: 'Track 01', + buffer: null, + startTime: 0, + height: 128, + volumeDb: 0, + pan: 0, + muted: false, + solo: false, + color: '#0f766e', + markers: [], + serverFileId: null, + clips: [] + }, { + id: '2', + name: 'Track 02', + buffer: null, + startTime: 0, + height: 128, + 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 } + const [hoveredTrackId, setHoveredTrackId] = useState(null); + const openPanel = id => { + if (id === 'export') setShowExportPanel(true);else if (id === 'ai') setShowAIPanel(true);else if (id === 'python_tools') setShowPythonToolsPanel(true);else if (id === 'selection') setShowSelectionPanel(true); + }; + const [activeTool, setActiveTool] = useState('select'); // 'select' | 'grab' | 'razor' + const [snapValue, setSnapValue] = useState('free'); // 'free', '1', '1/2', '1/4', '1/8', '1/16', '1/32' + + const snapTime = (time, snapVal, bpmVal) => { + if (snapVal === 'free') return time; + const beatDuration = 60 / parseFloat(bpmVal || 120); + let divisor = 1; + if (snapVal === '1') divisor = 1;else if (snapVal === '1/2') divisor = 0.5;else if (snapVal === '1/4') divisor = 0.25;else if (snapVal === '1/8') divisor = 0.125;else if (snapVal === '1/16') divisor = 0.0625;else if (snapVal === '1/32') divisor = 0.03125; + const gridSpacing = beatDuration * divisor; + return Math.round(time / gridSpacing) * gridSpacing; + }; + const snapValueRef = useRef(snapValue); + snapValueRef.current = snapValue; + const bpmRef = useRef(bpm); + bpmRef.current = bpm; + // BMP for Tempo Track - LOOP_EDITOR_2.md §6 + const [selectedTrackId, setSelectedTrackId] = useState('1'); + const [currentTime, setCurrentTime] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + const [selectionStart, setSelectionStart] = useState(null); + const [selectionEnd, setSelectionEnd] = useState(null); + const [selectionMode, setSelectionMode] = useState(null); // 'global' (from ruler) | 'local' (from track) + const [localSelectionTrackId, setLocalSelectionTrackId] = useState(null); + const [localSelectionStart, setLocalSelectionStart] = useState(null); + 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 [subTabHeight, setSubTabHeight] = useState(96); + const [isExporting, setIsExporting] = useState(false); + const [soloedTrackId, setSoloedTrackId] = useState(null); + const [toastMessage, setToastMessage] = useState(null); + const [showAIConfig, setShowAIConfig] = useState(false); + const [showExportPanel, setShowExportPanel] = useState(true); + const [showAIPanel, setShowAIPanel] = useState(true); + const [showSelectionPanel, setShowSelectionPanel] = useState(true); + const [showPythonToolsPanel, setShowPythonToolsPanel] = useState(true); + const [panelPositions, setPanelPositions] = useState({ + export: 'bottom', + ai: 'bottom', + python_tools: 'bottom', + selection: 'bottom' + }); + const [panelDropZone, setPanelDropZone] = useState(null); + const [dragGhostPos, setDragGhostPos] = useState(null); + const [dragGhostPanel, setDragGhostPanel] = useState(null); + const panelDragRef = useRef(null); + const workspaceRef = useRef(null); + const [aiConfig, setAiConfig] = useState({ + baseUrl: localStorage.getItem('ai_base_url') || `${API_BASE_URL}`, + apiKey: localStorage.getItem('ai_api_key') || '', + model: localStorage.getItem('ai_model') || 'deepseek-chat' + }); + const [analysisState, setAnalysisState] = useState({ + status: 'Sẵn sàng. Chạy AI để phân tích nhịp.', + data: null, + isRunning: false + }); + const [exportSettings, setExportSettings] = useState({ + sampleRate: '44100', + bitDepth: '16', + format: 'wav' + }); + const [serverStatus, setServerStatus] = useState('checking...'); + 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 } + const clipboardRef = useRef(null); // { buffer, name, volume, color } for copy/paste + + // ── Undo/Redo Engine (LOOP_EDITOR.md §4) ── + const [undoStack, setUndoStack] = useState([]); + const [redoStack, setRedoStack] = useState([]); + const MAX_UNDO = 30; + const pushAction = (actionType, trackId, beforeState, afterState) => { + const node = { + action_type: actionType, + track_id: trackId, + timestamp: Date.now(), + before_state: beforeState, + after_state: afterState + }; + setUndoStack(prev => { + const next = [...prev, node]; + if (next.length > MAX_UNDO) next.shift(); + return next; + }); + setRedoStack([]); + }; + const handleUndo = () => { + if (undoStack.length === 0) return; + const last = undoStack[undoStack.length - 1]; + setUndoStack(prev => prev.slice(0, -1)); + setRedoStack(prev => [...prev, last]); + applyTrackState(last.track_id, last.before_state); + showToast(`Undo: ${last.action_type}`, 'info'); + }; + const handleRedo = () => { + if (redoStack.length === 0) return; + const last = redoStack[redoStack.length - 1]; + setRedoStack(prev => prev.slice(0, -1)); + setUndoStack(prev => [...prev, last]); + applyTrackState(last.track_id, last.after_state); + showToast(`Redo: ${last.action_type}`, 'info'); + }; + const applyTrackState = (trackId, state) => { + setTracks(prev => prev.map(t => { + if (t.id !== trackId) return t; + return { + ...t, + ...state + }; + })); + }; + const captureTrackSnapshot = trackId => { + const track = tracks.find(t => t.id === trackId); + if (!track) return null; + return { + volumeDb: track.volumeDb, + pan: track.pan, + muted: track.muted, + name: track.name, + markers: JSON.parse(JSON.stringify(track.markers || [])), + // buffer is captured via reference copy for undo; we store a clone for redo + buffer: track.buffer, + startTime: track.startTime || 0, + clips: track.clips ? track.clips.map(c => ({ + id: c.id, + buffer: c.buffer, + startTime: c.startTime, + name: c.name + })) : null + }; + }; + + // ── Tab System (LOOP_EDITOR_2.md §1) ── + const [activeTab, setActiveTab] = useState('main'); + const [subTabSelectedNodeTime, setSubTabSelectedNodeTime] = useState(null); + const [subTabNormVal, setSubTabNormVal] = useState(0); + const [subTabGainVal, setSubTabGainVal] = useState(100); + const [subTabPitchVal, setSubTabPitchVal] = useState(0); + const [subTabs, setSubTabs] = useState([]); // [{id, label, trackId, startTime, endTime, buffer, effects, currentTime, selectionStart, selectionEnd, isPlaying}, ...] + const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2 + + // ── Temp Edit Tab (LOOP_EDITOR_2.md §1.2 Sub Tab) ── + const [tempTabActive, setTempTabActive] = useState(false); + const [tempTabBuffer, setTempTabBuffer] = useState(null); + const [tempTabTrackId, setTempTabTrackId] = useState(null); + const [tempTabOrigStart, setTempTabOrigStart] = useState(0); + const [tempTabOrigEnd, setTempTabOrigEnd] = useState(0); + const tempTabCanvasRef = useRef(null); + + // Effect parameters for temp tab + const [tempTabEffects, setTempTabEffects] = useState({ + reverse: false, + gainDb: 0, + fadeInMs: 0, + fadeOutMs: 0 + }); + + // ── Auth / User State ── + const [currentUser, setCurrentUser] = useState(null); + const [authModalOpen, setAuthModalOpen] = useState(false); + const [authMode, setAuthMode] = useState('login'); // 'login' | 'register' | 'force_change' + const [isMandatoryLogin, setIsMandatoryLogin] = useState(false); + const [profileModalOpen, setProfileModalOpen] = useState(false); + const [systemManagerModalOpen, setSystemManagerModalOpen] = useState(false); + const [aiConfigModalOpen, setAiConfigModalOpen] = useState(false); + useEffect(() => { + const checkAuthStatus = async () => { + const savedToken = localStorage.getItem('sonic_token'); + if (!savedToken) { + setIsMandatoryLogin(true); + setAuthMode('login'); + setAuthModalOpen(true); + return; + } + try { + const profile = await window.SonicAPI.getProfile(); + setCurrentUser(profile); + if (profile.must_change_password) { + setIsMandatoryLogin(true); + setAuthMode('force_change'); + setAuthModalOpen(true); + } else { + setIsMandatoryLogin(false); + setAuthModalOpen(false); + } + } catch (err) { + localStorage.removeItem('sonic_token'); + localStorage.removeItem('sonic_user'); + setCurrentUser(null); + setIsMandatoryLogin(true); + setAuthMode('login'); + setAuthModalOpen(true); + } + }; + checkAuthStatus(); + }, []); + const loadPendingSfsProject = () => { + const proj = window.__pendingSfsProject; + if (!proj) return; + try { + const restored = (proj.tracks || []).map(t => ({ + ...t, + buffer: null, + channelInfo: t.channelInfo || null, + clips: t.clips || [], + serverFileId: t.serverFileId || null + })); + if (restored.length > 0) { + setTracks(restored); + showToast(`Đã tải dự án "${proj.name}" từ liên kết .sfs thành công!`, "success"); + } + } catch (e) { + showToast("Lỗi tải dự án từ .sfs", "error"); + } finally { + window.__pendingSfsProject = null; + } + }; + const handleAuthSuccess = user => { + setCurrentUser(user); + if (user.must_change_password) { + setIsMandatoryLogin(true); + setAuthMode('force_change'); + setAuthModalOpen(true); + } else { + setIsMandatoryLogin(false); + setAuthModalOpen(false); + loadPendingSfsProject(); + } + }; + const handleLogout = () => { + localStorage.removeItem('sonic_token'); + localStorage.removeItem('sonic_user'); + setCurrentUser(null); + setIsMandatoryLogin(true); + setAuthMode('login'); + setAuthModalOpen(true); + }; + + // ── Temp project auto-save (local + server) ── + useEffect(() => { + const serializeSafe = arr => (arr || []).map(t => ({ + id: t.id, + name: t.name, + startTime: t.startTime, + height: t.height, + volumeDb: t.volumeDb, + pan: t.pan, + muted: t.muted, + solo: t.solo, + color: t.color, + markers: t.markers || [], + serverFileId: t.serverFileId || null, + channelInfo: t.channelInfo ? { + channels: t.channelInfo.channels, + isStereo: t.channelInfo.isStereo, + label: t.channelInfo.label + } : null + })); + window.SonicStorage.scheduleTempAutoSave(() => ({ + id: 'temp_project', + name: 'Dự án tạm chưa lưu', + tracks: serializeSafe(tracks), + subTabs: (subTabs || []).map(s => ({ + id: s.id, + label: s.label, + trackId: s.trackId, + clipId: s.clipId, + startTime: s.startTime, + endTime: s.endTime, + speed: s.speed, + fadeInLen: s.fadeInLen || 0, + fadeOutLen: s.fadeOutLen || 0, + graphMode: s.graphMode, + volumeNodes: s.volumeNodes || [], + panningNodes: s.panningNodes || [], + channelInfo: s.channelInfo ? { + channels: s.channelInfo.channels, + isStereo: s.channelInfo.isStereo, + label: s.channelInfo.label + } : null + })) + })); + }, [tracks, subTabs]); + + // Lucide icons initialization + useEffect(() => { + setTimeout(() => { + if (window.lucide) { + window.lucide.createIcons(); + } + }, 50); + }, [activeTool, activeTab]); + const timelineWrapperRef = useRef(null); + const tcpContainerRef = useRef(null); + const handleTimelineScroll = e => { + if (tcpContainerRef.current) { + tcpContainerRef.current.scrollTop = e.currentTarget.scrollTop; + } + }; + const handleTCPScroll = e => { + if (timelineWrapperRef.current) { + timelineWrapperRef.current.scrollTop = e.currentTarget.scrollTop; + } + }; + const panelDropZoneRef = useRef(null); + const startPanelDrag = (panelId, e) => { + panelDragRef.current = { + panelId, + startX: e.clientX, + startY: e.clientY + }; + setPanelDropZone(null); + setDragGhostPanel(panelId); + setDragGhostPos({ + x: e.clientX - 120, + y: e.clientY - 20 + }); + const onMove = ev => { + if (!panelDragRef.current || !workspaceRef.current) return; + const rect = workspaceRef.current.getBoundingClientRect(); + const x = ev.clientX - rect.left; + const y = ev.clientY - rect.top; + const w = rect.width; + const h = rect.height; + const margin = 60; + let zone = null; + if (x < margin && x > 10) zone = 'left';else if (x > w - margin && x < w - 10) zone = 'right';else if (y < margin && y > 10) zone = 'top';else if (y > h - margin && y < h - 10) zone = 'bottom'; + panelDropZoneRef.current = zone; + setPanelDropZone(zone); + setDragGhostPos({ + x: ev.clientX - 120, + y: ev.clientY - 20 + }); + }; + const onUp = () => { + if (panelDragRef.current) { + const pid = panelDragRef.current.panelId; + const targetZone = panelDropZoneRef.current; + if (targetZone && targetZone !== panelPositions[pid]) { + setPanelPositions(prev => ({ + ...prev, + [pid]: targetZone + })); + } + panelDragRef.current = null; + panelDropZoneRef.current = null; + setPanelDropZone(null); + setDragGhostPos(null); + setDragGhostPanel(null); + } + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }; + const rulerRef = useRef(null); + const activeSourcesRef = useRef([]); + const activeTrackNodesRef = useRef({}); // { [trackId]: { gainNode, pannerNode } } + const startOffsetTimeRef = useRef(0); + const startBufferOffsetRef = useRef(0); + const startAudioTimeRef = useRef(0); + const animationFrameIdRef = useRef(null); + const toastTimeoutRef = useRef(null); + const rulerDragStartRef = useRef(null); + const rulerAnchorRef = useRef(null); + const isDraggingRulerRef = useRef(false); + const handlePlayPauseRef = useRef(null); + + // ── Keyboard Shortcuts ── + const handleUndoRef = useRef(handleUndo); + const handleRedoRef = useRef(handleRedo); + handleUndoRef.current = handleUndo; + handleRedoRef.current = handleRedo; + const selectedClipIdRef = useRef(null); + selectedClipIdRef.current = selectedClipId; + const activeTabRef = useRef(activeTab); + activeTabRef.current = activeTab; + const activePlaybackSpeedRef = useRef(1.0); + const subTabsRef = useRef(subTabs); + subTabsRef.current = subTabs; + const subTabSelectedNodeTimeRef = useRef(null); + subTabSelectedNodeTimeRef.current = subTabSelectedNodeTime; + const handleSubTabNormalize = tabId => { + setSubTabs(prev => prev.map(st => { + if (st.id !== tabId || !st.buffer) return st; + const ctx = getAudioContext(); + const data = st.buffer.getChannelData(0); + const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : 0; + const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : st.buffer.duration; + const startSample = Math.floor(left * st.buffer.sampleRate); + const endSample = Math.floor(right * st.buffer.sampleRate); + let maxVal = 0; + for (let i = startSample; i < endSample; i++) { + const abs = Math.abs(data[i]); + if (abs > maxVal) maxVal = abs; + } + if (maxVal === 0) return st; + const scale = 1.0 / maxVal; + const clonedBuffer = ctx.createBuffer(1, data.length, st.buffer.sampleRate); + const clonedData = clonedBuffer.getChannelData(0); + clonedData.set(data); + for (let i = startSample; i < endSample; i++) { + clonedData[i] = Math.max(-1, Math.min(1, clonedData[i] * scale)); + } + showToast('Đã Normalize vùng chọn.', 'success'); + return { + ...st, + buffer: clonedBuffer + }; + })); + }; + const handleSubTabGain = (tabId, gainDb) => { + setSubTabs(prev => prev.map(st => { + if (st.id !== tabId || !st.buffer) return st; + const ctx = getAudioContext(); + const data = st.buffer.getChannelData(0); + const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : 0; + const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : st.buffer.duration; + const startSample = Math.floor(left * st.buffer.sampleRate); + const endSample = Math.floor(right * st.buffer.sampleRate); + const scale = Math.pow(10, gainDb / 20); + const clonedBuffer = ctx.createBuffer(1, data.length, st.buffer.sampleRate); + const clonedData = clonedBuffer.getChannelData(0); + clonedData.set(data); + for (let i = startSample; i < endSample; i++) { + clonedData[i] = Math.max(-1, Math.min(1, clonedData[i] * scale)); + } + showToast(`Đã điều chỉnh Gain: ${gainDb} dB.`, 'success'); + return { + ...st, + buffer: clonedBuffer + }; + })); + }; + const handleSubTabFade = (tabId, type) => { + setSubTabs(prev => prev.map(st => { + if (st.id !== tabId || !st.buffer) return st; + const ctx = getAudioContext(); + const data = st.buffer.getChannelData(0); + const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : 0; + const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : st.buffer.duration; + const startSample = Math.floor(left * st.buffer.sampleRate); + const endSample = Math.floor(right * st.buffer.sampleRate); + const durationSamples = endSample - startSample; + if (durationSamples <= 0) return st; + const clonedBuffer = ctx.createBuffer(1, data.length, st.buffer.sampleRate); + const clonedData = clonedBuffer.getChannelData(0); + clonedData.set(data); + if (type === 'in') { + for (let i = 0; i < durationSamples; i++) { + const alpha = i / durationSamples; + clonedData[startSample + i] *= alpha; + } + showToast('Đã áp dụng Fade In.', 'success'); + } else if (type === 'out') { + for (let i = 0; i < durationSamples; i++) { + const alpha = (durationSamples - i) / durationSamples; + clonedData[startSample + i] *= alpha; + } + showToast('Đã áp dụng Fade Out.', 'success'); + } + return { + ...st, + buffer: clonedBuffer + }; + })); + }; + const handleSubTabCut = tabId => { + const st = subTabsRef.current.find(s => s.id === tabId); + if (!st || !st.buffer) return; + const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : null; + const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : null; + if (left === null || right === null || left === right) { + showToast('Vui lòng chọn vùng để Cut.', 'warning'); + return; + } + const ctx = getAudioContext(); + const sr = st.buffer.sampleRate; + const data = st.buffer.getChannelData(0); + const startSample = Math.floor(left * sr); + const endSample = Math.floor(right * sr); + const len = endSample - startSample; + const cutBuffer = ctx.createBuffer(1, len, sr); + cutBuffer.copyToChannel(data.subarray(startSample, endSample), 0); + clipboardRef.current = { + buffer: cutBuffer, + name: 'Subtab Clip' + }; + const newBuffer = ctx.createBuffer(1, data.length - len, sr); + const newData = newBuffer.getChannelData(0); + let idx = 0; + for (let i = 0; i < startSample; i++) newData[idx++] = data[i]; + for (let i = endSample; i < data.length; i++) newData[idx++] = data[i]; + setSubTabs(prev => prev.map(s => s.id === tabId ? { + ...s, + buffer: newBuffer, + currentTime: left, + selectionStart: null, + selectionEnd: null + } : s)); + showToast('Đã Cut vùng chọn.', 'success'); + }; + const handleSubTabCopy = tabId => { + const st = subTabsRef.current.find(s => s.id === tabId); + if (!st || !st.buffer) return; + const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : null; + const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : null; + if (left === null || right === null || left === right) { + showToast('Vui lòng chọn vùng để Copy.', 'warning'); + return; + } + const ctx = getAudioContext(); + const sr = st.buffer.sampleRate; + const data = st.buffer.getChannelData(0); + const startSample = Math.floor(left * sr); + const endSample = Math.floor(right * sr); + const len = endSample - startSample; + const copyBuffer = ctx.createBuffer(1, len, sr); + copyBuffer.copyToChannel(data.subarray(startSample, endSample), 0); + clipboardRef.current = { + buffer: copyBuffer, + name: 'Subtab Clip' + }; + showToast('Đã Copy vùng chọn.', 'success'); + }; + const handleSubTabPaste = tabId => { + const st = subTabsRef.current.find(s => s.id === tabId); + if (!st || !st.buffer) return; + if (!clipboardRef.current || !clipboardRef.current.buffer) { + showToast('Clipboard trống.', 'warning'); + return; + } + const ctx = getAudioContext(); + const clipBuf = clipboardRef.current.buffer; + const sr = st.buffer.sampleRate; + const data = st.buffer.getChannelData(0); + const insertTime = st.currentTime || 0; + const insertSample = Math.floor(insertTime * sr); + const newBuffer = ctx.createBuffer(1, data.length + clipBuf.length, sr); + const newData = newBuffer.getChannelData(0); + let idx = 0; + for (let i = 0; i < insertSample; i++) newData[idx++] = data[i]; + const clipData = clipBuf.getChannelData(0); + for (let i = 0; i < clipBuf.length; i++) newData[idx++] = clipData[i]; + for (let i = insertSample; i < data.length; i++) newData[idx++] = data[i]; + setSubTabs(prev => prev.map(s => s.id === tabId ? { + ...s, + buffer: newBuffer, + currentTime: insertTime + clipBuf.duration, + selectionStart: null, + selectionEnd: null + } : s)); + showToast('Đã dán dữ liệu âm thanh.', 'success'); + }; + const handleSubTabDelete = tabId => { + const st = subTabsRef.current.find(s => s.id === tabId); + if (!st || !st.buffer) return; + const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : null; + const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : null; + if (left === null || right === null || left === right) { + showToast('Vui lòng chọn vùng để Delete.', 'warning'); + return; + } + const ctx = getAudioContext(); + const sr = st.buffer.sampleRate; + const data = st.buffer.getChannelData(0); + const startSample = Math.floor(left * sr); + const endSample = Math.floor(right * sr); + const len = endSample - startSample; + const newBuffer = ctx.createBuffer(1, data.length - len, sr); + const newData = newBuffer.getChannelData(0); + let idx = 0; + for (let i = 0; i < startSample; i++) newData[idx++] = data[i]; + for (let i = endSample; i < data.length; i++) newData[idx++] = data[i]; + setSubTabs(prev => prev.map(s => s.id === tabId ? { + ...s, + buffer: newBuffer, + currentTime: left, + selectionStart: null, + selectionEnd: null + } : s)); + showToast('Đã xóa vùng chọn.', 'success'); + }; + const handleSubTabLoop = (tabId, loopCount) => { + const st = subTabsRef.current.find(s => s.id === tabId); + if (!st || !st.buffer) return; + const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : null; + const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : null; + if (left === null || right === null || left === right) { + showToast('Vui lòng chọn vùng để Loop.', 'warning'); + return; + } + const ctx = getAudioContext(); + const sr = st.buffer.sampleRate; + const data = st.buffer.getChannelData(0); + const startSample = Math.floor(left * sr); + const endSample = Math.floor(right * sr); + const len = endSample - startSample; + + // Loop payload N times + const segmentData = data.subarray(startSample, endSample); + const addedSamples = len * (loopCount - 1); + const newBuffer = ctx.createBuffer(1, data.length + addedSamples, sr); + const newData = newBuffer.getChannelData(0); + let idx = 0; + for (let i = 0; i < endSample; i++) newData[idx++] = data[i]; + for (let n = 0; n < loopCount - 1; n++) { + for (let i = 0; i < len; i++) newData[idx++] = segmentData[i]; + } + for (let i = endSample; i < data.length; i++) newData[idx++] = data[i]; + setSubTabs(prev => prev.map(s => s.id === tabId ? { + ...s, + buffer: newBuffer, + selectionStart: null, + selectionEnd: null + } : s)); + showToast(`Đã lặp vùng chọn ${loopCount} lần.`, 'success'); + }; + useEffect(() => { + const handler = e => { + // Bypass global hotkeys when typing inside input/textarea/contentEditable elements + if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable)) { + return; + } + 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 (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; + } + if (ctrl && e.key === 'l') { + e.preventDefault(); + handleSubTabLoop(curTabId, 4); + return; + } + if (e.key === 'v' || e.key === 'V') { + e.preventDefault(); + const val = prompt("Nhập Gain điều chỉnh (dB):", "0"); + if (val) handleSubTabGain(curTabId, parseFloat(val) || 0); + return; + } + if (ctrl && e.key === 'x') { + e.preventDefault(); + handleSubTabCut(curTabId); + return; + } + if (ctrl && e.key === 'c') { + e.preventDefault(); + handleSubTabCopy(curTabId); + return; + } + if (ctrl && e.key === 'v') { + e.preventDefault(); + handleSubTabPaste(curTabId); + return; + } + if (e.key === 'Delete' || e.key === 'Backspace' || e.key === 'Del') { + e.preventDefault(); + if (subTabSelectedNodeTimeRef.current !== null) { + const selTime = subTabSelectedNodeTimeRef.current; + setSubTabs(prev => prev.map(s => { + if (s.id !== curTabId) return s; + const curNodes = s.graphMode === 'pan' ? s.panningNodes || [] : s.volumeNodes || []; + const updated = curNodes.filter(n => n.time !== selTime); + return { + ...s, + [s.graphMode === 'pan' ? 'panningNodes' : 'volumeNodes']: updated }; - - // ── Sub Tab: apply effects & merge back (LOOP_EDITOR_2.md §1.2) ── - const applySubTab = (tabId) => { - const subTab = subTabs.find(s => s.id === tabId); - if (!subTab || !subTab.buffer) return; - const track = tracks.find(t => t.id === subTab.trackId); - if (!track || !track.buffer) return; - - const beforeSnap = captureTrackSnapshot(subTab.trackId); - - // Clone buffer and apply effects - const ctx = getAudioContext(); - const sr = subTab.buffer.sampleRate; - const eff = subTab.buffer.getChannelData(0); - let resultBuffer = ctx.createBuffer(1, eff.length, sr); - let resultData = resultBuffer.getChannelData(0); - resultData.set(eff); - - // Apply effects inline - const fx = subTab.effects || {}; - - const applyResample = (data, ratio) => { - const newLen = Math.round(data.length * ratio); - const out = new Float32Array(newLen); - for (let i = 0; i < newLen; i++) { - const srcIdx = i / ratio; - const idx0 = Math.floor(srcIdx); - const idx1 = Math.min(idx0 + 1, data.length - 1); - const frac = srcIdx - idx0; - out[i] = data[idx0] * (1 - frac) + data[idx1] * frac; - } - return out; - }; - - if (fx.reverse) { - const rev = new Float32Array(resultData); - for (let i = 0; i < resultData.length; i++) rev[i] = resultData[resultData.length - 1 - i]; - resultData.set(rev); - } - if (fx.gainDb !== 0) { - const gain = Math.pow(10, fx.gainDb / 20); - for (let i = 0; i < resultData.length; i++) resultData[i] = Math.max(-1, Math.min(1, resultData[i] * gain)); - } - if (fx.fadeInMs > 0) { - const fadeSamples = Math.min(resultData.length, Math.floor(fx.fadeInMs / 1000 * sr)); - for (let i = 0; i < fadeSamples; i++) resultData[i] *= i / fadeSamples; - } - // Graph Editor fade curves (trigonometric, 15_GRAPH_EDIT.md §2.3) - const gFadeIn = subTab.fadeInLen || 0; - const gFadeOut = subTab.fadeOutLen || 0; - if (gFadeIn > 0) { - const fadeSamples = Math.min(resultData.length, Math.floor(gFadeIn * sr)); - for (let i = 0; i < fadeSamples; i++) { - resultData[i] *= (1 - Math.cos(Math.PI * i / fadeSamples)) / 2; - } - } - if (gFadeOut > 0) { - const fadeSamples = Math.min(resultData.length, Math.floor(gFadeOut * sr)); - for (let i = resultData.length - fadeSamples; i < resultData.length; i++) { - const t = i - (resultData.length - fadeSamples); - resultData[i] *= (1 + Math.cos(Math.PI * t / fadeSamples)) / 2; - } - } - - // Graph Editor volume automation spline (Monotone Cubic Hermite Spline, 16_FIX_GRAPH.md §2.1) - const volNodes = subTab.volumeNodes || []; - if (volNodes.length > 0) { - const sortedNodes = [...volNodes].sort((a, b) => a.time - b.time); - const computeHermiteTangents = (pts) => { - const n = pts.length; - if (n < 2) return []; - const m = new Array(n); - for (let i = 1; i < n - 1; i++) { - const hP = pts[i].time - pts[i-1].time; - const hN = pts[i+1].time - pts[i].time; - const sP = (pts[i].db - pts[i-1].db) / hP; - const sN = (pts[i+1].db - pts[i].db) / hN; - m[i] = (sP + sN) / 2; - } - m[0] = n > 1 ? (pts[1].db - pts[0].db) / (pts[1].time - pts[0].time) : 0; - m[n-1] = n > 1 ? (pts[n-1].db - pts[n-2].db) / (pts[n-1].time - pts[n-2].time) : 0; - return m; - }; - - const tangents = computeHermiteTangents(sortedNodes); - - const getVolumeGainAtTime = (t) => { - if (sortedNodes.length === 1) { - return Math.pow(10, sortedNodes[0].db / 20); - } - if (t <= sortedNodes[0].time) { - return Math.pow(10, sortedNodes[0].db / 20); - } - if (t >= sortedNodes[sortedNodes.length - 1].time) { - return Math.pow(10, sortedNodes[sortedNodes.length - 1].db / 20); - } - for (let i = 0; i < sortedNodes.length - 1; i++) { - const n1 = sortedNodes[i]; - const n2 = sortedNodes[i+1]; - if (t >= n1.time && t <= n2.time) { - const h = n2.time - n1.time; - if (h <= 0) return Math.pow(10, n1.db / 20); - const frac = (t - n1.time) / h; - const frac2 = frac * frac, frac3 = frac2 * frac; - const db = (2*frac3 - 3*frac2 + 1) * n1.db + (frac3 - 2*frac2 + frac) * h * tangents[i] + (-2*frac3 + 3*frac2) * n2.db + (frac3 - frac2) * h * tangents[i+1]; - return Math.pow(10, db / 20); - } - } - return 1.0; - }; - for (let i = 0; i < resultData.length; i++) { - const t = i / sr; - resultData[i] *= getVolumeGainAtTime(t); - } - } - if (fx.normalizeDb !== 0) { - let maxVal = 0; - for (let i = 0; i < resultData.length; i++) { const abs = Math.abs(resultData[i]); if (abs > maxVal) maxVal = abs; } - if (maxVal > 0) { - const targetAmp = Math.pow(10, fx.normalizeDb / 20); - const scale = targetAmp / maxVal; - for (let i = 0; i < resultData.length; i++) resultData[i] = Math.max(-1, Math.min(1, resultData[i] * scale)); - } - } - if (fx.pitch !== 0) { - const ratio = Math.pow(2, fx.pitch / 12); - const newData = applyResample(resultData, 1 / ratio); - resultBuffer = ctx.createBuffer(1, newData.length, sr); - resultData = resultBuffer.getChannelData(0); - resultData.set(newData); - } - const stretchSpeed = subTab.speed || 1.0; - if (stretchSpeed !== 1.0) { - const ratio = 1.0 / stretchSpeed; - const newData = applyResample(resultData, ratio); - resultBuffer = ctx.createBuffer(1, newData.length, sr); - resultData = resultBuffer.getChannelData(0); - resultData.set(newData); - } - if (fx.speedStretch !== 100) { - const ratio = fx.speedStretch / 100; - const newData = applyResample(resultData, ratio); - resultBuffer = ctx.createBuffer(1, newData.length, sr); - resultData = resultBuffer.getChannelData(0); - resultData.set(newData); - } - - if (subTab.clipId) { - // Clip-based merge - setTracks(prev => prev.map(t => { - if (t.id !== subTab.trackId) return t; - const updatedClips = (t.clips || []).map(c => { - if (c.id === subTab.clipId) { - return { - ...c, - buffer: resultBuffer, - name: c.name.endsWith('(edited)') ? c.name : c.name + ' (edited)' - }; - } - return c; - }); - return { - ...t, - clips: updatedClips, - buffer: updatedClips[0]?.buffer, - startTime: updatedClips[0]?.startTime || 0, - name: updatedClips[0]?.name || t.name - }; - })); - } else { - // Crossfade merge into original track (§2.2) - const sr = track.buffer.sampleRate; - const origData = track.buffer.getChannelData(0); - const trackStart = track.startTime || 0; - const startSample = Math.floor((subTab.startTime - trackStart) * sr); - const endSample = Math.floor((subTab.endTime - trackStart) * sr); - const crossfadeLen = Math.min(100, Math.floor(0.01 * sr)); // 10ms - - const mergedBuffer = ctx.createBuffer(1, track.buffer.length, sr); - const mergedData = mergedBuffer.getChannelData(0); - for (let i = 0; i < startSample; i++) mergedData[i] = origData[i]; - for (let i = endSample; i < track.buffer.length; i++) mergedData[i] = origData[i]; - - for (let i = 0; i < resultData.length; i++) { - const globalIdx = startSample + i; - let val = resultData[i]; - if (i < crossfadeLen) { - const alpha = i / crossfadeLen; - val = (1 - alpha) * (origData[globalIdx] || 0) + alpha * resultData[i]; - } else if (i > resultData.length - crossfadeLen) { - const distFromEnd = resultData.length - 1 - i; - const alpha = distFromEnd / crossfadeLen; - const origEndIdx = endSample - (resultData.length - i); - val = alpha * (origEndIdx >= 0 ? origData[origEndIdx] : 0) + (1 - alpha) * resultData[i]; - } - mergedData[globalIdx] = val; - } - - setTracks(prev => prev.map(t => { - if (t.id !== subTab.trackId) return t; - return { ...t, buffer: mergedBuffer, name: t.name + ' (edited)' }; - })); - } - - const afterSnap = captureTrackSnapshot(subTab.trackId); - pushAction('EDIT_TAB', subTab.trackId, beforeSnap, afterSnap); - - // Tab Lifetime: Engaging the Apply trigger propagates data back to the primary environment but does not close down the active sub-tab view. - // closeSubTab(tabId); - showToast('Đã áp dụng chỉnh sửa vào track chính.', 'success'); - }; - - const applySubTabEffect = (tabId, effectType, value) => { - const subTab = subTabs.find(s => s.id === tabId); - if (!subTab || !subTab.buffer) return; - - const ctx = getAudioContext(); - const sr = subTab.buffer.sampleRate; - const eff = subTab.buffer.getChannelData(0); - - // Clone buffer - let resultBuffer = ctx.createBuffer(1, eff.length, sr); - let resultData = resultBuffer.getChannelData(0); - resultData.set(eff); - - // Calculate selection start/end in unstretched sample indices - const speed = subTab.speed || 1.0; - const hasSelection = subTab.selectionStart !== null && subTab.selectionEnd !== null && subTab.selectionStart !== subTab.selectionEnd; - - const tStart = hasSelection ? Math.min(subTab.selectionStart, subTab.selectionEnd) * speed : 0; - const tEnd = hasSelection ? Math.max(subTab.selectionStart, subTab.selectionEnd) * speed : subTab.buffer.duration; - - const startSample = Math.max(0, Math.min(eff.length, Math.floor(tStart * sr))); - const endSample = Math.max(0, Math.min(eff.length, Math.floor(tEnd * sr))); - - if (effectType === 'normalize') { - let maxVal = 0; - for (let i = startSample; i < endSample; i++) { - const abs = Math.abs(resultData[i]); - if (abs > maxVal) maxVal = abs; - } - if (maxVal > 0) { - const targetAmp = Math.pow(10, value / 20); - const scale = targetAmp / maxVal; - for (let i = startSample; i < endSample; i++) { - resultData[i] = Math.max(-1, Math.min(1, resultData[i] * scale)); - } - } - } else if (effectType === 'gain') { - const gainScale = value / 100; - for (let i = startSample; i < endSample; i++) { - resultData[i] = Math.max(-1, Math.min(1, resultData[i] * gainScale)); - } - } else if (effectType === 'pitch') { - const ratio = Math.pow(2, value / 12); - const applyResample = (data, r) => { - const newLen = Math.round(data.length * r); - const out = new Float32Array(newLen); - for (let i = 0; i < newLen; i++) { - const srcIdx = i / r; - const idx0 = Math.floor(srcIdx); - const idx1 = Math.min(idx0 + 1, data.length - 1); - const frac = srcIdx - idx0; - out[i] = data[idx0] * (1 - frac) + data[idx1] * frac; - } - return out; - }; - const sub = resultData.slice(startSample, endSample); - const subResampled = applyResample(sub, 1 / ratio); - for (let i = 0; i < endSample - startSample; i++) { - resultData[startSample + i] = i < subResampled.length ? subResampled[i] : 0.0; - } - } - - // Update subTab buffer state - setSubTabs(prev => prev.map(s => { - if (s.id !== tabId) return s; - return { - ...s, - buffer: resultBuffer, - selectionStart: null, - selectionEnd: null - }; - })); - showToast(`Đã áp dụng ${effectType === 'normalize' ? 'Normalize' : effectType === 'gain' ? 'Gain' : 'Pitch'} cho ${hasSelection ? 'vùng chọn' : 'toàn bộ clip'}.`, 'success'); - }; - - const exportSubTabBuffer = async (tabId) => { - const subTab = subTabs.find(s => s.id === tabId); - if (!subTab || !subTab.buffer) return; - try { - const buffer = subTab.buffer; - const sr = buffer.sampleRate; - const monoData = buffer.getChannelData(0); - const bufferLength = monoData.length; - const bitDepth = 16; - const bytesPerSample = 2; - const headerSize = 44; - const fileSizeBytes = headerSize + (bufferLength * bytesPerSample); - const fileBuffer = new ArrayBuffer(fileSizeBytes); - const view = new DataView(fileBuffer); - - const writeString = (offset, string) => { - for (let i = 0; i < string.length; i++) { - view.setUint8(offset + i, string.charCodeAt(i)); - } - }; - - writeString(0, 'RIFF'); - view.setUint32(4, fileSizeBytes - 8, true); - writeString(8, 'WAVE'); - writeString(12, 'fmt '); - view.setUint32(16, 16, true); - view.setUint16(20, 1, true); - view.setUint16(22, 1, true); - view.setUint32(24, sr, true); - view.setUint32(28, sr * bytesPerSample, true); - view.setUint16(32, bytesPerSample, true); - view.setUint16(34, bitDepth, true); - writeString(36, 'data'); - view.setUint32(40, bufferLength * bytesPerSample, true); - - let offset = 44; - for (let i = 0; i < bufferLength; i++) { - const sample = Math.max(-1, Math.min(1, monoData[i])); - view.setInt16(offset, Math.floor(sample < 0 ? sample * 0x8000 : sample * 0x7FFF), true); - offset += bytesPerSample; - } - - const blob = new Blob([view], { type: 'audio/wav' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${subTab.label || 'subtab-export'}.wav`; - a.click(); - URL.revokeObjectURL(url); - showToast("Xuất bản sub-tab hoàn tất!", "success"); - } catch (err) { - showToast("Lỗi xuất âm thanh: " + err.message, "error"); - } - }; - - const closeSubTab = (tabId) => { - setSubTabs(prev => prev.filter(s => s.id !== tabId)); - if (activeTab === tabId) setActiveTab('main'); - }; - - const updateSubTabEffects = (tabId, effects) => { - setSubTabs(prev => prev.map(s => s.id === tabId ? { ...s, effects: { ...s.effects, ...effects } } : s)); - }; - - const handleSubTabNormalizeWithValue = (tabId, normalizeDb) => { - updateSubTabEffects(tabId, { normalizeDb }); - }; - - const handleSubTabGainWithValue = (tabId, gainDb) => { - updateSubTabEffects(tabId, { gainDb }); - }; - - const handleSubTabPitch = (tabId, pitch) => { - updateSubTabEffects(tabId, { pitch }); - }; - - const handleSubTabStretch = (tabId, speedStretch) => { - updateSubTabEffects(tabId, { speedStretch }); - }; - - const handleSubTabFadeState = (tabId, type) => { - const st = subTabs.find(s => s.id === tabId); - if (!st) return; - const fx = st.effects || {}; - if (type === 'in') { - updateSubTabEffects(tabId, { fadeInMs: (fx.fadeInMs || 0) + 10 }); - } else if (type === 'out') { - updateSubTabEffects(tabId, { fadeOutMs: (fx.fadeOutMs || 0) + 10 }); - } - }; - - // ── Context Menu Handlers ── - const handleContextMenu = (e, trackId, clickTime) => { - e.preventDefault(); - e.stopPropagation(); - setContextMenu({ x: e.clientX, y: e.clientY, trackId, time: clickTime || currentTime }); - }; - - const closeContextMenu = () => setContextMenu(null); - - // Close context menu on any click outside - useEffect(() => { - const handler = () => { if (contextMenu) closeContextMenu(); }; - if (contextMenu) { - window.addEventListener('click', handler); - return () => window.removeEventListener('click', handler); - } - }, [contextMenu]); - - const contextMenuEdit = () => { - const track = tracks.find(t => t.id === contextMenu.trackId); - if (track) setSelectedTrackId(contextMenu.trackId); - closeContextMenu(); - - const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ - id: 'default', - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name, - speed: track.speed || 1.0 - }] : []); - const clickedClip = clips.find(c => contextMenu.time >= c.startTime && contextMenu.time < c.startTime + c.buffer.duration / (c.speed || 1.0)); - - if (clickedClip) { - handleEditClipInSubTab(contextMenu.trackId, clickedClip.id); - } else { - openTempTab(); - } - }; - - const contextMenuSplit = () => { - closeContextMenu(); - handleSplitTrack(contextMenu.trackId); - }; - - const contextMenuDelete = () => { - const tid = contextMenu.trackId; - const beforeSnap = captureTrackSnapshot(tid); - setTracks(prev => prev.filter(t => t.id !== tid)); - const afterSnap = captureTrackSnapshot(tid); - pushAction('DELETE', tid, beforeSnap, afterSnap); - if (selectedTrackId === tid) { - setSelectedTrackId(tracks.filter(t => t.id !== tid)[0]?.id || '1'); - } - closeContextMenu(); - showToast('Đã xoá track.', 'info'); - }; - - const contextMenuCopy = () => { - const track = tracks.find(t => t.id === contextMenu.trackId); - if (!track || !track.buffer) return; - - const sr = track.buffer.sampleRate; - const data = track.buffer.getChannelData(0); - - // Copy selected region if selection exists - if (selLeft !== null && selRight !== null && selRight > selLeft) { - const trackStart = track.startTime || 0; - const relSelLeft = Math.max(0, selLeft - trackStart); - const relSelRight = Math.max(0, selRight - trackStart); - const startSample = Math.floor(relSelLeft * sr); - const endSample = Math.min(data.length, Math.floor(relSelRight * sr)); - const len = endSample - startSample; - if (len > 0) { - const ctx = getAudioContext(); - const clipBuffer = ctx.createBuffer(1, len, sr); - clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); - 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; - } - } - - // No selection: copy entire track - clipboardRef.current = { - buffer: track.buffer, - name: track.name, - volumeDb: track.volumeDb, - pan: track.pan, - color: track.color, - }; - closeContextMenu(); - showToast('Đã sao chép toàn bộ track.', 'info'); - }; - - const contextMenuCut = () => { - const t = tracks.find(x => x.id === contextMenu.trackId); - if (!t || !t.buffer) { contextMenuDelete(); return; } - const beforeSnap = captureTrackSnapshot(contextMenu.trackId); - const sr = t.buffer.sampleRate; - const data = t.buffer.getChannelData(0); - if (selLeft !== null && selRight !== null && selRight > selLeft) { - const trackStart = t.startTime || 0; - const relSelLeft = Math.max(0, selLeft - trackStart); - const relSelRight = Math.max(0, selRight - trackStart); - const startSample = Math.floor(relSelLeft * sr); - const endSample = Math.min(data.length, Math.floor(relSelRight * sr)); - const len = endSample - startSample; - if (len > 0) { - const ctx = getAudioContext(); - const clipBuffer = ctx.createBuffer(1, len, sr); - clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); - 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); - let idx = 0; - for (let i = 0; i < startSample; i++) newData[idx++] = data[i]; - for (let i = endSample; i < data.length; i++) newData[idx++] = data[i]; - setTracks(p => p.map(tr => tr.id === contextMenu.trackId ? { ...tr, buffer: newBuffer } : tr)); - const afterSnap = captureTrackSnapshot(contextMenu.trackId); - pushAction('CUT', contextMenu.trackId, beforeSnap, afterSnap); - closeContextMenu(); - showToast('Đã cắt vùng chọn vào clipboard.', 'info'); - return; - } - } - contextMenuCopy(); - contextMenuDelete(); - }; - - const doPaste = (targetTrackId, pasteTime) => { - if (!clipboardRef.current || !clipboardRef.current.buffer) { - showToast('Clipboard trống.', 'warning'); - return null; - } - const { buffer: clipBuffer, name, volumeDb, pan, color } = clipboardRef.current; - const ctx = getAudioContext(); - - const targetTrack = tracks.find(t => t.id === targetTrackId); - const newClip = { - id: 'clip_' + Date.now() + '_' + Math.floor(Math.random() * 1000), - startTime: pasteTime, - buffer: clipBuffer, - name: name || 'Pasted Clip' - }; - - if (targetTrack) { - setTracks(p => p.map(t => { - if (t.id === targetTrackId) { - const existingClips = t.clips && t.clips.length > 0 ? t.clips : (t.buffer ? [{ - id: 'default_' + t.id, - buffer: t.buffer, - startTime: t.startTime || 0, - name: t.name - }] : []); - - const updatedClips = [...existingClips, newClip]; - - return { - ...t, - clips: updatedClips, - buffer: updatedClips[0].buffer, - startTime: updatedClips[0].startTime, - name: name || t.name, - volumeDb: volumeDb ?? t.volumeDb, - pan: pan ?? t.pan, - color: color || t.color - }; - } - return t; - })); - setCurrentTime(pasteTime); - showToast('Đã dán clip vào track.', 'success'); - return targetTrackId; - } - - // No matching track — create a new one - const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; - const newId = 'track_pasted_' + Date.now(); - setTracks(prev => [...prev, { - id: newId, - name: `Pasted_${name || 'track'}`, - buffer: clipBuffer, - startTime: pasteTime, - clips: [newClip], - volumeDb: volumeDb ?? 0, pan: pan ?? 0, muted: false, solo: false, - color: color || colors[prev.length % colors.length], - markers: [], serverFileId: null, - }]); - setSelectedTrackId(newId); - setCurrentTime(pasteTime); - showToast('Đã dán track mới từ clipboard.', 'success'); - return newId; - }; - - const handlePasteTrack = () => doPaste(selectedTrackId, currentTime); - - const contextMenuPaste = () => { - const result = doPaste(contextMenu.trackId, contextMenu.time || currentTime); - closeContextMenu(); - }; - - const contextMenuMerge = () => { - const activeTracks = tracks.filter(t => t.buffer && !t.muted); - if (activeTracks.length < 2) { - showToast('Cần ít nhất 2 track có dữ liệu để merge.', 'warning'); - closeContextMenu(); - return; - } - const ctx = getAudioContext(); - const maxDur = Math.max(...activeTracks.map(t => (t.startTime || 0) + t.buffer.duration)); - const sr = activeTracks[0].buffer.sampleRate; - const merged = ctx.createBuffer(1, Math.ceil(maxDur * sr), sr); - const mergedData = merged.getChannelData(0); - activeTracks.forEach(t => { - const data = t.buffer.getChannelData(0); - const startSample = Math.floor((t.startTime || 0) * sr); - 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] * volLinear; - } - } - }); - let maxPeak = 0; - for (let i = 0; i < mergedData.length; i++) { - const abs = Math.abs(mergedData[i]); - if (abs > maxPeak) maxPeak = abs; - } - if (maxPeak > 1.0) { - for (let i = 0; i < mergedData.length; i++) mergedData[i] /= maxPeak; - } - const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; - const newId = 'track_merged_' + Date.now(); - const names = activeTracks.map(t => t.name).join('+').slice(0, 30); - setTracks(prev => [...prev, { - id: newId, name: `Merged_${names}.wav`, buffer: merged, startTime: 0, - volumeDb: 0, pan: 0, muted: false, solo: false, - color: colors[prev.length % colors.length], markers: [], serverFileId: null, - }]); - setSelectedTrackId(newId); - closeContextMenu(); - showToast(`Đã merge ${activeTracks.length} tracks.`, 'success'); - }; - - // Menu bar direct handlers (don't rely on contextMenu state) - const handleMergeTracks = () => { - const at = tracks.filter(t => t.buffer && !t.muted); - if (at.length < 2) { showToast('Cần 2+ tracks để merge.','warning'); return; } - const actx = getAudioContext(); - const maxDur = Math.max(...at.map(t => (t.startTime || 0) + t.buffer.duration)); - const sr = at[0].buffer.sampleRate; - const mb = actx.createBuffer(1, Math.ceil(maxDur * sr), sr); - const mdata = mb.getChannelData(0); - at.forEach(t => { - const d = t.buffer.getChannelData(0); - 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] * 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: 128, 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 = () => { - const t = tracks.find(x => x.id === selectedTrackId); - if (!t || !t.buffer) return; - const sr = t.buffer.sampleRate; - const data = t.buffer.getChannelData(0); - // If selection exists, copy only the selected region - if (selLeft !== null && selRight !== null && selRight > selLeft) { - const trackStart = t.startTime || 0; - const relSelLeft = Math.max(0, selLeft - trackStart); - const relSelRight = Math.max(0, selRight - trackStart); - const startSample = Math.floor(relSelLeft * sr); - const endSample = Math.min(data.length, Math.floor(relSelRight * sr)); - const len = endSample - startSample; - if (len > 0) { - const ctx = getAudioContext(); - const clipBuffer = ctx.createBuffer(1, len, sr); - clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); - 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, volumeDb: t.volumeDb, color: t.color }; - showToast('Copied track to clipboard.', 'info'); - }; - const handleCutTrack = () => { - const t = tracks.find(x => x.id === selectedTrackId); - if (!t || !t.buffer) return; - const beforeSnap = captureTrackSnapshot(selectedTrackId); - const sr = t.buffer.sampleRate; - const data = t.buffer.getChannelData(0); - // If selection exists, cut only the selected region - if (selLeft !== null && selRight !== null && selRight > selLeft) { - const trackStart = t.startTime || 0; - const relSelLeft = Math.max(0, selLeft - trackStart); - const relSelRight = Math.max(0, selRight - trackStart); - const startSample = Math.floor(relSelLeft * sr); - const endSample = Math.min(data.length, Math.floor(relSelRight * sr)); - const len = endSample - startSample; - if (len > 0) { - // Copy selection to clipboard - const ctx = getAudioContext(); - const clipBuffer = ctx.createBuffer(1, len, sr); - clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); - 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); - const newData = newBuffer.getChannelData(0); - let idx = 0; - for (let i = 0; i < startSample; i++) newData[idx++] = data[i]; - for (let i = endSample; i < data.length; i++) newData[idx++] = data[i]; - setTracks(p => p.map(tr => tr.id === selectedTrackId ? { ...tr, buffer: newBuffer } : tr)); - const afterSnap = captureTrackSnapshot(selectedTrackId); - pushAction('CUT', selectedTrackId, beforeSnap, afterSnap); - showToast('Cut selection to clipboard.', 'info'); - return; - } - } - // No selection: cut entire track (copy + delete) - handleCopyTrack(); - handleDeleteTrack(); - }; - const handleDeleteTrack = () => { - const tid = selectedTrackId; - setTracks(p => p.filter(t => t.id !== tid)); - setSelectedTrackId(tracks.filter(t => t.id !== tid)[0]?.id || '1'); - showToast('Deleted track.','info'); - }; - - // ── Server Health Check ── - const [viewportWidth, setViewportWidth] = useState(1200); - - useEffect(() => { - const wrapper = timelineWrapperRef.current; - if (!wrapper) return; - const observer = new ResizeObserver(entries => { - for (let entry of entries) { - setViewportWidth(entry.contentRect.width); - } - }); - observer.observe(wrapper); - return () => observer.disconnect(); - }, []); - - useEffect(() => { - fetch(API_BASE_URL) - .then(r => { if (r.ok) setServerStatus('connected'); else setServerStatus('error'); }) - .catch(() => setServerStatus('offline')); - }, []); - - // ── Computed Values ── - const maxDuration = useMemo(() => { - let max = 10; - tracks.forEach(t => { - const clips = t.clips && t.clips.length > 0 ? t.clips : (t.buffer ? [{ - id: 'default', - buffer: t.buffer, - startTime: t.startTime || 0, - name: t.name, - speed: t.speed || 1.0 - }] : []); - clips.forEach(c => { - if (c.buffer) { - const cStart = c.startTime || 0; - const cDur = c.buffer.duration / (c.speed || 1.0); - max = Math.max(max, cStart + cDur); - } - }); - }); - return max; - }, [tracks]); - - const maxDurationRef = useRef(maxDuration); - maxDurationRef.current = maxDuration; - - const minZoom = useMemo(() => { - return viewportWidth / maxDuration; - }, [viewportWidth, maxDuration]); - - const timelineWidth = useMemo(() => { - return Math.max(zoom * maxDuration, viewportWidth); - }, [zoom, maxDuration, viewportWidth]); - - useEffect(() => { - if (zoom < minZoom) { - setZoom(minZoom); - } - }, [minZoom]); - - const playheadLeftPos = useMemo(() => currentTime * zoom, [currentTime, zoom]); - - const selLeft = useMemo(() => { - if (selectionMode === 'local' && localSelectionStart !== null && localSelectionEnd !== null) { - return Math.min(localSelectionStart, localSelectionEnd); - } - if (selectionStart === null || selectionEnd === null) return null; - return Math.min(selectionStart, selectionEnd); - }, [selectionStart, selectionEnd, selectionMode, localSelectionStart, localSelectionEnd]); - - const selRight = useMemo(() => { - if (selectionMode === 'local' && localSelectionStart !== null && localSelectionEnd !== null) { - return Math.max(localSelectionStart, localSelectionEnd); - } - if (selectionStart === null || selectionEnd === null) return null; - return Math.max(selectionStart, selectionEnd); - }, [selectionStart, selectionEnd, selectionMode, localSelectionStart, localSelectionEnd]); - - // ── Toast helper ── - const showToast = (text, type = 'info') => { - if (toastTimeoutRef.current) clearTimeout(toastTimeoutRef.current); - setToastMessage({ text, type }); - toastTimeoutRef.current = setTimeout(() => setToastMessage(null), 3500); - }; - - // ── Server-side upload ── - const uploadToServer = async (file, trackId) => { - const formData = new FormData(); - formData.append('file', file); - - try { - const resp = await fetch(`${API_AUDIO}/upload`, { - method: 'POST', - body: formData, - }); - if (!resp.ok) throw new Error(`Upload failed: ${resp.status}`); - const data = await resp.json(); - serverFileIdMap[trackId] = data.file_id; - return data; - } catch (err) { - console.warn('Server upload failed, using client-side only:', err.message); - return null; - } - }; - - // ── Server-side waveform loading ── - const loadServerWaveform = async (fileId) => { - try { - const resp = await fetch(`${API_AUDIO}/waveform/${fileId}?num_peaks=800`); - if (!resp.ok) return null; - return await resp.json(); - } catch { - return null; - } - }; - - // ── Check Celery task result ── - const pollTaskResult = async (taskId, maxPoll = 10) => { - for (let i = 0; i < maxPoll; i++) { - await new Promise(r => setTimeout(r, 1500)); - try { - const resp = await fetch(`${API_TASKS}/${taskId}`); - if (!resp.ok) continue; - const data = await resp.json(); - if (data.status === 'SUCCESS') return data.result; - if (data.status === 'FAILURE') throw new Error(data.error || 'Task failed'); - } catch (err) { - throw err; - } - } - throw new Error('Task polling timeout'); - }; - - // ── Wheel Zoom ── - useEffect(() => { - const timeline = timelineWrapperRef.current; - if (!timeline) return; - - const handleWheel = (e) => { - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - const rect = timeline.getBoundingClientRect(); - const mouseXInViewport = e.clientX - rect.left; - const mouseXInCanvas = mouseXInViewport + timeline.scrollLeft; - const anchorTime = mouseXInCanvas / zoom; - - const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; - - setZoom(prevZoom => { - let newZoom = prevZoom * zoomFactor; - if (newZoom < minZoom) newZoom = minZoom; - if (newZoom > 50000) newZoom = 50000; - - const newMouseXInCanvas = anchorTime * newZoom; - requestAnimationFrame(() => { - timeline.scrollLeft = newMouseXInCanvas - mouseXInViewport; - }); - return newZoom; - }); - } else if (e.shiftKey) { - e.preventDefault(); - timeline.scrollLeft += e.deltaY; - } - }; - - timeline.addEventListener('wheel', handleWheel, { passive: false }); - return () => timeline.removeEventListener('wheel', handleWheel); - }, [zoom, minZoom, maxDuration, activeTab]); - - // ── Update Playhead ── - const startSubTabPlayback = (st, offsetWallTime) => { - const context = getAudioContext(); - const speed = st.speed || 1.0; - const offsetBuffer = offsetWallTime * speed; - 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.playbackRate.value = speed; - activePlaybackSpeedRef.current = speed; - - // Graph Editor Automation Node Chain (15_GRAPH_EDIT.md §3) - // source → volumeGainNode → pannerNode → fadeGainNode → destination - - const volumeGainNode = context.createGain(); - volumeGainNode.gain.setValueAtTime(1.0, context.currentTime); - - const pannerNode = context.createStereoPanner(); - pannerNode.pan.setValueAtTime(0.0, context.currentTime); - - const fadeGainNode = context.createGain(); - fadeGainNode.gain.setValueAtTime(1.0, context.currentTime); - - // Schedule volume automation nodes - const volNodes = st.volumeNodes || []; - if (volNodes.length > 0) { - volumeGainNode.gain.cancelScheduledValues(context.currentTime); - volNodes.forEach((n, i) => { - const t = context.currentTime + (n.time / speed); - const linearGain = Math.pow(10, n.db / 20); - if (i === 0) volumeGainNode.gain.setValueAtTime(linearGain, t); - else volumeGainNode.gain.linearRampToValueAtTime(linearGain, t); - }); - } - - // Schedule panning automation nodes - const panNodes = st.panningNodes || []; - if (panNodes.length > 0) { - pannerNode.pan.cancelScheduledValues(context.currentTime); - panNodes.forEach((n, i) => { - const t = context.currentTime + (n.time / speed); - const clamped = Math.max(-1, Math.min(1, n.pan)); - if (i === 0) pannerNode.pan.setValueAtTime(clamped, t); - else pannerNode.pan.linearRampToValueAtTime(clamped, t); - }); - } - - // Schedule fade curves - const duration = st.buffer.duration; - const fIn = st.fadeInLen || 0; - const fOut = st.fadeOutLen || 0; - if (fIn > 0) { - fadeGainNode.gain.setValueAtTime(0.0, context.currentTime); - fadeGainNode.gain.linearRampToValueAtTime(1.0, context.currentTime + fIn / speed); - } - if (fOut > 0) { - const fadeOutStart = (duration - fOut) / speed; - fadeGainNode.gain.setValueAtTime(1.0, context.currentTime + Math.max(0, fadeOutStart)); - fadeGainNode.gain.linearRampToValueAtTime(0.0, context.currentTime + duration / speed); - } - - source.connect(volumeGainNode); - volumeGainNode.connect(pannerNode); - pannerNode.connect(fadeGainNode); - fadeGainNode.connect(context.destination); - - source.start(context.currentTime, offsetBuffer); - activeSourcesRef.current = [source]; - activeTrackNodesRef.current[st.trackId] = { gainNode: volumeGainNode, pannerNode, source }; - startOffsetTimeRef.current = offsetWallTime; - startBufferOffsetRef.current = offsetBuffer; - startAudioTimeRef.current = context.currentTime; - }; - - const updatePlayhead = () => { - if (activeTabRef.current !== 'main') { - const st = subTabsRef.current.find(s => s.id === activeTabRef.current); - if (!st || !st.isPlaying || !st.buffer) return; - const context = getAudioContext(); - const speedFactor = st.speed || 1.0; - const elapsed = context.currentTime - startAudioTimeRef.current; - const wallTime = startOffsetTimeRef.current + elapsed; - const bufferPos = startBufferOffsetRef.current + elapsed * speedFactor; - - // Loop sub-tab selection (bufferPos is buffer-time) - if (st.selectionStart !== null && st.selectionEnd !== null && st.selectionStart !== st.selectionEnd) { - const start = Math.min(st.selectionStart, st.selectionEnd); - const end = Math.max(st.selectionStart, st.selectionEnd); - if (bufferPos >= end) { - stopAllPlayback(); - setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? { - ...s, - currentTime: wallTime, - isPlaying: true - } : s)); - startSubTabPlayback(st, wallTime); - animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); - return; - } - } - - if (bufferPos >= st.buffer.duration) { - stopAllPlayback(); - if (isLoopingSelection) { - setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? { - ...s, - currentTime: 0, - isPlaying: true - } : s)); - startSubTabPlayback(st, 0); - animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); - } else { - setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? { ...s, currentTime: 0, isPlaying: false } : s)); - } - return; - } - - setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? { ...s, currentTime: wallTime } : s)); - animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); - return; - } - if (!isPlaying) return; - const context = getAudioContext(); - const elapsed = context.currentTime - startAudioTimeRef.current; - const updatedTime = startOffsetTimeRef.current + elapsed; - - // Selection Loop - LOOP_MAKER.md + LOOP_EDITOR_2.md §4.2 - // If selection cleared by user, play linearly (don't loop) - if (!selectionCleared && isLoopingSelection && selLeft !== null && selRight !== null) { - if (selRight > selLeft && updatedTime >= selRight) { - if (selectionMode === 'local') { - // Local Solo Loop: only restart the selected track - stopAllPlayback(); - startOffsetTimeRef.current = selLeft; - startAudioTimeRef.current = context.currentTime; - startLocalTrackPlayback(localSelectionTrackId, selLeft); - setCurrentTime(selLeft); - setIsPlaying(true); - } else { - // Global Master Loop: restart all tracks - stopAllPlayback(); - startOffsetTimeRef.current = selLeft; - startAudioTimeRef.current = context.currentTime; - startTrackPlayback(selLeft); - setCurrentTime(selLeft); - setIsPlaying(true); - } - animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); - return; - } - } - - if (updatedTime >= maxDurationRef.current) { - stopAllPlayback(); - setCurrentTime(0); - return; - } - - setCurrentTime(updatedTime); - animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); - }; - - useEffect(() => { - if (isPlaying || subTabs.some(s => s.isPlaying)) { - animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); - } else { - cancelAnimationFrame(animationFrameIdRef.current); - } - return () => cancelAnimationFrame(animationFrameIdRef.current); - }, [isPlaying, subTabs, isLoopingSelection, selLeft, selRight, selectionMode, localSelectionTrackId, selectionCleared, activeTab]); - - // ── Playback ── - const startTrackPlayback = (offsetTime) => { - const context = getAudioContext(); - const hasSolo = tracks.some(t => t.solo) || soloedTrackId !== null; - - tracks.forEach(track => { - const isPlayable = hasSolo - ? (track.id === soloedTrackId || track.solo) - : !track.muted; - - 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, - startTime: track.startTime || 0, - name: track.name, - speed: track.speed || 1.0 - }] : []); - - clips.forEach(clip => { - if (!clip.buffer) return; - - const source = context.createBufferSource(); - source.buffer = clip.buffer; - source.playbackRate.value = clip.speed || 1.0; - - source.connect(gainNode); - gainNode.connect(pannerNode); - - const clipStart = clip.startTime || 0; - const clipDuration = clip.buffer.duration / (clip.speed || 1.0); - const clipEnd = clipStart + clipDuration; - - if (offsetTime < clipStart) { - const delay = clipStart - offsetTime; - source.start(context.currentTime + delay, 0); - activeSourcesRef.current.push(source); - } else if (offsetTime < clipEnd) { - const playOffset = offsetTime - clipStart; - source.start(context.currentTime, playOffset * (clip.speed || 1.0)); - activeSourcesRef.current.push(source); - } - }); - }); - }; - - // Solo playback for Local Selection Loop (LOOP_MAKER.md §2.2) - const startLocalTrackPlayback = (trackId, offsetTime) => { - const context = getAudioContext(); - const track = tracks.find(t => t.id === trackId); - if (!track) return; - - const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ - id: 'default', - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name, - 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; - - const source = context.createBufferSource(); - source.buffer = clip.buffer; - source.playbackRate.value = clip.speed || 1.0; - - source.connect(gainNode); - gainNode.connect(pannerNode); - - const clipStart = clip.startTime || 0; - const clipDuration = clip.buffer.duration / (clip.speed || 1.0); - const clipEnd = clipStart + clipDuration; - - if (offsetTime < clipStart) { - const delay = clipStart - offsetTime; - source.start(context.currentTime + delay, 0); - activeSourcesRef.current.push(source); - } else if (offsetTime < clipEnd) { - const playOffset = offsetTime - clipStart; - source.start(context.currentTime, playOffset * (clip.speed || 1.0)); - activeSourcesRef.current.push(source); - } - }); - }; - - const handlePlayPause = () => { - if (activeTab !== 'main') { - // Sub-tab playback transport - const st = subTabs.find(s => s.id === activeTab); - if (!st || !st.buffer) return; - - if (st.isPlaying) { - stopAllPlayback(); - setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, isPlaying: false } : s)); - } else { - stopAllPlayback(); - const startOffset = st.currentTime || 0; - startSubTabPlayback(st, startOffset); - setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, isPlaying: true, currentTime: startOffset } : s)); - } - return; - } - const context = getAudioContext(); - if (isPlaying) { - stopAllPlayback(); - } else { - startOffsetTimeRef.current = currentTime; - startAudioTimeRef.current = context.currentTime; - startTrackPlayback(currentTime); - setIsPlaying(true); - } - }; - handlePlayPauseRef.current = handlePlayPause; - - const handlePause = () => { - if (isPlaying || subTabs.some(s => s.isPlaying)) stopAllPlayback(); - }; - - const stopAllPlayback = () => { - activeSourcesRef.current.forEach(src => { - try { src.stop(); } catch(e) {} - }); - activeSourcesRef.current = []; - activeTrackNodesRef.current = {}; - setIsPlaying(false); - setSubTabs(prev => prev.map(s => ({ ...s, isPlaying: false }))); - }; - - const handleStop = () => { - stopAllPlayback(); - if (activeTab !== 'main') { - setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, currentTime: 0 } : s)); - } else { - setCurrentTime(0); - } - }; - - const handleSubTabResizeMouseDown = (e) => { - e.preventDefault(); - e.stopPropagation(); - const startY = e.clientY; - const startHeight = subTabHeight; - - const handleMouseMove = (moveEvent) => { - const deltaY = moveEvent.clientY - startY; - const newHeight = Math.max(48, Math.min(400, startHeight + deltaY)); - setSubTabHeight(newHeight); - }; - - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - }; - - // ── Playhead set with seek+play ── - const handlePlayheadSet = (time, shiftKey) => { - localSelectionAnchorRef.current = time; - if (isPlaying) { - // Click during playback: seek to position and continue playing - setCurrentTime(time); - stopAllPlayback(); - setTimeout(() => { - startOffsetTimeRef.current = time; - startAudioTimeRef.current = getAudioContext().currentTime; - startTrackPlayback(time); - setIsPlaying(true); - }, 50); - } else { - // Normal click: just set playhead - setCurrentTime(time); - } - }; - const clearLocalSelection = () => { - setSelectionMode(null); - setLocalSelectionTrackId(null); - setLocalSelectionStart(null); - setLocalSelectionEnd(null); - }; - - const handleRulerMouseDown = (e) => { - if (e.ctrlKey) { - e.preventDefault(); - e.stopPropagation(); - clearLocalSelection(); - setSelectionMode(null); - setSelectionStart(null); - setSelectionEnd(null); - return; - } - const wrapper = timelineWrapperRef.current; - if (!wrapper) return; - const rect = wrapper.getBoundingClientRect(); - const scrollLeft = wrapper.scrollLeft; - const mouseX = e.clientX - rect.left + scrollLeft; - const rawTime = mouseX / zoom; - const time = snapValue !== 'free' ? snapTime(rawTime, snapValue, bpm) : rawTime; - - clearLocalSelection(); - setSelectionMode('global'); - rulerDragStartRef.current = time; - isDraggingRulerRef.current = true; - if (e.shiftKey) { - e.preventDefault(); - e.stopPropagation(); - // Shift+click on ruler: lock existing anchor (or currentTime fallback) and extend global selection - const anchor = rulerAnchorRef.current !== null && rulerAnchorRef.current !== undefined ? rulerAnchorRef.current : (selectionStart !== null && selectionStart !== undefined ? selectionStart : currentTime); - const selS = Math.min(anchor, time); - const selE = Math.max(anchor, time); - setSelectionStart(selS); - setSelectionEnd(selE); - } else { - rulerAnchorRef.current = time; - setSelectionStart(time); - setSelectionEnd(time); - } - handlePlayheadSet(time); - }; - - // Global Ruler mousemove is tracked via document listener set up in useEffect - useEffect(() => { - const handleMouseMove = (e) => { - if (!isDraggingRulerRef.current) return; - const wrapper = timelineWrapperRef.current; - if (!wrapper) return; - const rect = wrapper.getBoundingClientRect(); - const scrollLeft = wrapper.scrollLeft; - const mouseX = e.clientX - rect.left + scrollLeft; - const rawTime = Math.max(0, Math.min(maxDuration, mouseX / zoom)); - const time = snapValueRef.current !== 'free' ? snapTime(rawTime, snapValueRef.current, bpmRef.current) : rawTime; - - const anchor = rulerAnchorRef.current ?? rulerDragStartRef.current ?? time; - setSelectionStart(Math.min(anchor, time)); - setSelectionEnd(Math.max(anchor, time)); - }; - const handleMouseUp = () => { - if (isDraggingRulerRef.current) { - isDraggingRulerRef.current = false; - rulerDragStartRef.current = null; - } - }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - }, [zoom, maxDuration]); - - // ── Track Lane Local Selection Drag ── - const localDragInProgressRef = useRef(false); - const localDragTrackRef = useRef(null); - const localDragStartTimeRef = useRef(0); - const localSelectionAnchorRef = useRef(null); - - const handleTrackLaneMouseDown = (trackId, time) => { - setSelectedTrackId(trackId); - clearLocalSelection(); - localSelectionAnchorRef.current = time; - setSelectionMode('local'); - setLocalSelectionTrackId(trackId); - setLocalSelectionStart(time); - setLocalSelectionEnd(time); - setSelectionStart(time); - setSelectionEnd(time); - - localDragInProgressRef.current = true; - localDragTrackRef.current = trackId; - localDragStartTimeRef.current = time; - }; - - // Document-level mousemove/mouseup for local selection drag - useEffect(() => { - const handleMouseMove = (e) => { - if (!localDragInProgressRef.current) return; - const wrapper = timelineWrapperRef.current; - if (!wrapper) return; - const rect = wrapper.getBoundingClientRect(); - const scrollLeft = wrapper.scrollLeft; - const mouseX = e.clientX - rect.left + scrollLeft; - const time = Math.max(0, Math.min(maxDuration, mouseX / zoom)); - const anchor = localSelectionAnchorRef.current ?? localDragStartTimeRef.current; - const selS = Math.min(anchor, time); - const selE = Math.max(anchor, time); - setLocalSelectionStart(selS); - setLocalSelectionEnd(selE); - setSelectionStart(selS); - setSelectionEnd(selE); - }; - const handleMouseUp = () => { - if (localDragInProgressRef.current) { - localDragInProgressRef.current = false; - localDragTrackRef.current = null; - localDragStartTimeRef.current = 0; - } - }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - }, [zoom, maxDuration]); - - const draggedClipRef = useRef(null); - draggedClipRef.current = draggedClip; - const hoveredTrackIdRef = useRef(null); - hoveredTrackIdRef.current = hoveredTrackId; - const captureTrackSnapshotRef = useRef(null); - captureTrackSnapshotRef.current = captureTrackSnapshot; - - const handleClipDragStart = (trackId, clipId, clickOffset, isDuplicate = false) => { - const track = tracks.find(t => t.id === trackId); - if (!track) return; - - const existingClips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ - id: 'default_' + track.id, - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name - }] : []); - - const clip = existingClips.find(c => c.id === clipId || (clipId === 'default' && c.id === 'default_' + track.id)); - if (!clip) return; - - const beforeSnap = captureTrackSnapshot(trackId); - - let targetClipId = clip.id; - - if (isDuplicate) { - const cloneId = clip.id + '_copy_' + Date.now(); - const clone = { - ...clip, - id: cloneId, - name: clip.name + ' (Copy)' - }; - - setTracks(prev => prev.map(t => { - if (t.id === trackId) { - const newClips = [...existingClips, clone]; - return { - ...t, - clips: newClips, - buffer: newClips[0].buffer, - startTime: newClips[0].startTime, - name: newClips[0].name - }; - } - return t; - })); - - targetClipId = cloneId; - } else { - if (!track.clips || track.clips.length === 0) { - setTracks(prev => prev.map(t => { - if (t.id === trackId) { - return { - ...t, - clips: existingClips - }; - } - return t; - })); - } - } - - setDraggedClip({ - trackId: trackId, - clipId: targetClipId, - clickOffset: clickOffset, - buffer: clip.buffer, - name: clip.name, - beforeSnap: beforeSnap - }); - }; - - const stretchedClipRef = useRef(null); - stretchedClipRef.current = stretchedClip; - - const handleClipStretchStart = (trackId, clipId, clickTime) => { - const track = tracks.find(t => t.id === trackId); - if (!track) return; - const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ - id: 'default_' + track.id, - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name, - speed: track.speed || 1.0 - }] : []); - - const clip = clips.find(c => c.id === clipId || (clipId === 'default' && c.id === 'default_' + trackId)); - if (!clip || !clip.buffer) return; - - const beforeSnap = captureTrackSnapshot(trackId); - - if (!track.clips || track.clips.length === 0) { - setTracks(prev => prev.map(t => t.id === trackId ? { ...t, clips } : t)); - } - - setStretchedClip({ - trackId, - clipId: clip.id === 'default' ? 'default_' + trackId : clip.id, - originalDuration: clip.buffer.duration, - startTime: clip.startTime, - originalSpeed: clip.speed || 1.0, - beforeSnap - }); - }; - - const handleSelectionEdgeDragStart = (e, trackId, side) => { - const startX = e.clientX; - const initialLeft = Math.min(localSelectionStart, localSelectionEnd); - const initialRight = Math.max(localSelectionStart, localSelectionEnd); - - const handleMouseMove = (moveEvent) => { - const deltaX = moveEvent.clientX - startX; - const deltaSec = deltaX / zoom; - if (side === 'left') { - const newLeft = Math.max(0, Math.min(initialRight - 0.05, initialLeft + deltaSec)); - setLocalSelectionStart(newLeft); - setLocalSelectionEnd(initialRight); - } else { - const newRight = Math.max(initialLeft + 0.05, Math.min(maxDuration, initialRight + deltaSec)); - setLocalSelectionStart(initialLeft); - setLocalSelectionEnd(newRight); - } - }; - - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - }; - - const handleTrackResizeMouseDown = (e, trackId) => { - e.preventDefault(); - e.stopPropagation(); - const startY = e.clientY; - const track = tracks.find(t => t.id === trackId); - const startHeight = track ? (track.height || 96) : 96; - - const handleMouseMove = (moveEvent) => { - const deltaY = moveEvent.clientY - startY; - const newHeight = Math.max(110, Math.min(300, startHeight + deltaY)); - setTracks(prev => prev.map(t => t.id === trackId ? { ...t, height: newHeight } : t)); - }; - - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - }; - - const deleteTrack = (trackId) => { - const beforeSnap = captureTrackSnapshot(trackId); - setTracks(prev => { - const filtered = prev.filter(t => t.id !== trackId); - if (filtered.length > 0) { - setSelectedTrackId(filtered[0].id); - } - return filtered; - }); - showToast('Đã xóa track.', 'info'); - }; - - useEffect(() => { - const handleMouseMove = (e) => { - const drag = draggedClipRef.current; - if (!drag) return; - - const wrapper = timelineWrapperRef.current; - if (!wrapper) return; - - const rect = wrapper.getBoundingClientRect(); - const scrollLeft = wrapper.scrollLeft; - const mouseX = e.clientX - rect.left + scrollLeft; - const time = mouseX / zoom; - const rawStart = Math.max(0, time - drag.clickOffset); - const newStart = snapTime(rawStart, snapValueRef.current, bpmRef.current); - - const targetTrackId = hoveredTrackIdRef.current || drag.trackId; - - setTracks(prev => prev.map(t => { - // Clear the clip from its previous track if it moved to a new track - if (t.id === drag.trackId && drag.trackId !== targetTrackId) { - const updatedClips = (t.clips || []).filter(c => c.id !== drag.clipId); - return { - ...t, - clips: updatedClips, - buffer: updatedClips.length > 0 ? updatedClips[0].buffer : null, - startTime: updatedClips.length > 0 ? updatedClips[0].startTime : 0, - name: updatedClips.length > 0 ? updatedClips[0].name : `Track ${t.id}` - }; - } - // Update/set clip on target track - if (t.id === targetTrackId) { - const existingClips = t.clips && t.clips.length > 0 ? t.clips : (t.buffer ? [{ - id: 'default_' + t.id, - buffer: t.buffer, - startTime: t.startTime || 0, - name: t.name - }] : []); - - const hasClip = existingClips.some(c => c.id === drag.clipId); - let updatedClips; - if (hasClip) { - updatedClips = existingClips.map(c => c.id === drag.clipId ? { ...c, startTime: newStart } : c); - } else { - updatedClips = [...existingClips, { - id: drag.clipId, - buffer: drag.buffer, - startTime: newStart, - name: drag.name - }]; - } - return { - ...t, - clips: updatedClips, - buffer: updatedClips[0].buffer, - startTime: updatedClips[0].startTime, - name: updatedClips[0].name - }; - } - return t; - })); - - if (drag.trackId !== targetTrackId) { - setDraggedClip(prev => ({ ...prev, trackId: targetTrackId })); - } - }; - - const handleMouseUp = () => { - const drag = draggedClipRef.current; - if (!drag) return; - - const afterSnap = captureTrackSnapshotRef.current(drag.trackId); - pushAction('MOVE_CLIP', drag.trackId, drag.beforeSnap, afterSnap); - - setDraggedClip(null); - showToast('Đã di chuyển clip.', 'success'); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - }, [zoom]); - - // Document-level mousemove/mouseup for clip stretching - useEffect(() => { - const handleMouseMove = (e) => { - const stretch = stretchedClipRef.current; - if (!stretch) return; - - const wrapper = timelineWrapperRef.current; - if (!wrapper) return; - - const rect = wrapper.getBoundingClientRect(); - const scrollLeft = wrapper.scrollLeft; - const mouseX = e.clientX - rect.left + scrollLeft; - const time = mouseX / zoom; - - const newDuration = Math.max(0.1, time - stretch.startTime); - const speedRatio = stretch.originalDuration / newDuration; - - setTracks(prev => prev.map(t => { - if (t.id === stretch.trackId) { - const updatedClips = (t.clips || []).map(c => { - if (c.id === stretch.clipId) { - return { - ...c, - speed: speedRatio - }; - } - return c; - }); - return { - ...t, - clips: updatedClips, - buffer: updatedClips[0]?.buffer, - startTime: updatedClips[0]?.startTime || 0, - speed: updatedClips[0]?.speed || 1.0 - }; - } - return t; - })); - }; - - const handleMouseUp = () => { - const stretch = stretchedClipRef.current; - if (!stretch) return; - - const afterSnap = captureTrackSnapshotRef.current(stretch.trackId); - pushAction('STRETCH_CLIP', stretch.trackId, stretch.beforeSnap, afterSnap); - - setStretchedClip(null); - showToast('Đã giãn thời gian clip.', 'success'); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - }, [zoom]); - - const handleSelectRange = (start, end, reset) => { - const maxLen = maxDuration; - const cleanStart = Math.max(0, Math.min(maxLen, start)); - const cleanEnd = Math.max(0, Math.min(maxLen, end)); - - if (reset) { - setSelectionStart(cleanStart); - setSelectionEnd(cleanEnd); - } else { - setSelectionEnd(cleanEnd); - } - // LOOP_EDITOR_2.md §4.2: new selection = enable looping - setSelectionCleared(false); - }; - - const handleSelectionInputChange = (field, val) => { - const numericVal = Math.max(0, parseFloat(val) || 0); - if (selectionMode === 'local') { - // Editing local selection directly - if (field === 'start') { - setLocalSelectionStart(numericVal); - } else { - setLocalSelectionEnd(numericVal); - } - } else { - if (field === 'start') { - setSelectionStart(numericVal); - } else { - setSelectionEnd(numericVal); - } - } - }; - - const selectionStats = useMemo(() => { - if (selLeft === null || selRight === null) { - return { start: 0, end: 0, length: 0 }; - } - const s = Math.min(selLeft, selRight); - const e = Math.max(selLeft, selRight); + })); + setSubTabSelectedNodeTime(null); + } else { + handleSubTabDelete(curTabId); + } + return; + } + 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 && !alt && e.key === 'o') { + e.preventDefault(); + handleImportSFS(); + return; + } + if (ctrl && !alt && e.key === 'n') { + e.preventDefault(); + setTracks([{ + id: '1', + name: 'Track 01', + buffer: null, + startTime: 0, + height: 128, + volumeDb: 0, + pan: 0, + muted: false, + solo: false, + color: '#0f766e', + markers: [], + serverFileId: null + }, { + id: '2', + name: 'Track 02', + buffer: null, + startTime: 0, + height: 128, + 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(); + handleExportSFS(); + return; + } + if (ctrl && alt && e.key === 's' || ctrl && e.shiftKey && e.key === 's') { + e.preventDefault(); + handleExportSFS(); + return; + } + if (ctrl && !alt && e.key === 'i') { + e.preventDefault(); + addNewTrack(); + return; + } + if (ctrl && alt && e.key === 'i') { + e.preventDefault(); + showToast('Import audio', 'info'); + return; + } + if (ctrl && !alt && e.key === 'e') { + e.preventDefault(); + openTempTab(); + return; + } + if (ctrl && !alt && e.key === 'm') { + e.preventDefault(); + handleMergeTracks(); + return; + } + if (ctrl && !alt && e.key === 'c') { + e.preventDefault(); + handleCopyTrack(); + return; + } + if (ctrl && !alt && e.key === 'x') { + e.preventDefault(); + handleCutTrack(); + return; + } + if (ctrl && !alt && e.key === 'v') { + e.preventDefault(); + handlePasteTrack(); + return; + } + if (e.key === 'Delete' || e.key === 'Backspace' || e.key === 'Del') { + const selClip = selectedClipIdRef.current; + if (selClip) { + e.preventDefault(); + const { + trackId, + clipId + } = selClip; + setTracks(prev => { + const track = prev.find(t => t.id === trackId); + if (!track) return prev; + const beforeSnap = captureTrackSnapshotRef.current ? captureTrackSnapshotRef.current(trackId) : null; + const updatedClips = (track.clips || []).filter(c => c.id !== clipId); + const updatedTracks = prev.map(t => { + if (t.id === trackId) { return { - start: parseFloat(s.toFixed(3)), - end: parseFloat(e.toFixed(3)), - length: parseFloat((e - s).toFixed(3)) + ...t, + clips: updatedClips, + buffer: updatedClips.length > 0 ? updatedClips[0].buffer : null, + startTime: updatedClips.length > 0 ? updatedClips[0].startTime : 0, + name: updatedClips.length > 0 ? updatedClips[0].name : `Track ${t.id}` }; - }, [selLeft, selRight]); + } + return t; + }); + setTimeout(() => { + const afterSnap = captureTrackSnapshotRef.current ? captureTrackSnapshotRef.current(trackId) : null; + pushAction('DELETE_CLIP', trackId, beforeSnap, afterSnap); + }, 50); + return updatedTracks; + }); + setSelectedClipId(null); + showToast('Đã xóa clip.', 'info'); + return; + } else { + e.preventDefault(); + handleDeleteTrack(); + return; + } + } + if (!ctrl && !alt && e.key === 's') { + e.preventDefault(); + handleSplitTrack(selectedTrackId); + return; + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); - // ── Handle Drag (selection resize) ── - const handleHandleDragStart = (e, side) => { - e.preventDefault(); - e.stopPropagation(); - const startX = e.clientX; - const useLocal = selectionMode === 'local'; - const currentStart = useLocal ? localSelectionStart : selectionStart; - const currentEnd = useLocal ? localSelectionEnd : selectionEnd; - const initialLeft = Math.min(currentStart, currentEnd); - const initialRight = Math.max(currentStart, currentEnd); - const setStart = useLocal ? setLocalSelectionStart : setSelectionStart; - const setEnd = useLocal ? setLocalSelectionEnd : setSelectionEnd; + // ── Temp Tab: draw isolated waveform ── + useEffect(() => { + if (!tempTabActive || !tempTabBuffer || !tempTabCanvasRef.current) return; + const canvas = tempTabCanvasRef.current; + const ctx = canvas.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + ctx.scale(dpr, dpr); + const w = rect.width; + const h = rect.height; + ctx.fillStyle = '#181818'; + ctx.fillRect(0, 0, w, h); + const data = tempTabBuffer.getChannelData(0); + const sr = tempTabBuffer.sampleRate; + const totalSamples = data.length; + if (totalSamples === 0) return; + ctx.strokeStyle = '#6ee7b7'; + ctx.lineWidth = 1; + for (let px = 0; px < w; px++) { + const startSample = Math.floor(px / w * totalSamples); + const endSample = Math.floor((px + 1) / w * totalSamples); + let maxVal = 0; + for (let i = startSample; i < endSample && i < totalSamples; i++) { + const abs = Math.abs(data[i]); + if (abs > maxVal) maxVal = abs; + } + const mid = h / 2; + const peakHeight = maxVal * (h * 0.4); + ctx.beginPath(); + ctx.moveTo(px, mid - peakHeight); + ctx.lineTo(px, mid + peakHeight); + ctx.stroke(); + } + }, [tempTabActive, tempTabBuffer]); - const handleMouseMove = (moveEvent) => { - const deltaX = moveEvent.clientX - startX; - const deltaSec = deltaX / zoom; - if (side === 'left') { - const newLeft = Math.max(0, Math.min(initialRight - 0.05, initialLeft + deltaSec)); - setStart(newLeft); - setEnd(initialRight); - } else { - const newRight = Math.max(initialLeft + 0.05, Math.min(maxDuration, initialRight + deltaSec)); - setStart(initialLeft); - setEnd(newRight); - } - }; + // ── Sub Tab: open as new tab instead of modal (LOOP_EDITOR_2.md §1) ── + const openTempTab = () => { + const useLocal = selectionMode === 'local' && localSelectionTrackId; + const trackId = useLocal ? localSelectionTrackId : selectedTrackId; + const t = tracks.find(x => x.id === trackId); + if (!t || !t.buffer) { + showToast('Vui lòng chọn track có dữ liệu âm thanh.', 'warning'); + return; + } + if (selLeft === null || selRight === null || selRight <= selLeft) { + showToast('Vui lòng chọn một khoảng thời gian trên sóng âm.', 'warning'); + return; + } - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; + // Check if a subtab for this track+range already exists + const existing = subTabs.find(s => s.trackId === trackId && s.startTime === selLeft && s.endTime === selRight); + if (existing) { + setActiveTab(existing.id); + showToast(`Sub Tab already open.`, 'info'); + return; + } + const sr = t.buffer.sampleRate; + const trackStart = t.startTime || 0; + const relSelLeft = Math.max(0, selLeft - trackStart); + const relSelRight = Math.max(0, selRight - trackStart); + const startSample = Math.max(0, Math.floor(relSelLeft * sr)); + const endSample = Math.min(t.buffer.length, Math.floor(relSelRight * sr)); + const len = endSample - startSample; + if (len < 100) { + showToast('Khoảng chọn quá ngắn.', 'warning'); + return; + } + const ctx = getAudioContext(); + const numChannels = t.buffer.numberOfChannels || 1; + const subBuffer = ctx.createBuffer(numChannels, len, sr); + for (let c = 0; c < numChannels; c++) { + subBuffer.copyToChannel(t.buffer.getChannelData(c).subarray(startSample, endSample), c); + } + const subChannelInfo = window.SonicAudio.analyzeAudioBufferChannels(subBuffer); + const tabId = 'subtab_' + Date.now(); + const tabLabel = `Edit_${t.name.replace('.wav', '').slice(0, 10)}_${selLeft.toFixed(1)}s`; + setSubTabs(prev => [...prev, { + id: tabId, + label: tabLabel, + trackId: trackId, + startTime: selLeft, + endTime: selRight, + buffer: subBuffer, + channelInfo: subChannelInfo, + effects: { + reverse: false, + gainDb: 0, + fadeInMs: 0, + fadeOutMs: 0, + normalizeDb: 0, + pitch: 0, + speedStretch: 100 + }, + currentTime: 0, + selectionStart: null, + selectionEnd: null, + isPlaying: false, + speed: 1.0, + volumeNodes: [], + panningNodes: [], + fadeInLen: 0, + fadeOutLen: 0, + graphMode: null, + isLooping: false, + loopCount: 0 + }]); + setActiveTab(tabId); + }; + const handleEditClipInSubTab = (trackId, clipId) => { + const track = tracks.find(t => t.id === trackId); + if (!track) return; + const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ + id: 'default_' + track.id, + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name + }] : []; + const clip = clips.find(c => c.id === clipId || clipId === 'default' && c.id === 'default_' + trackId); + if (!clip || !clip.buffer) return; + const resolvedClipId = clip.id === 'default' ? 'default_' + trackId : clip.id; + const existing = subTabs.find(s => s.clipId === resolvedClipId && s.trackId === trackId); + if (existing) { + setActiveTab(existing.id); + showToast(`Sub Tab for "${clip.name}" already open.`, 'info'); + return; + } + const sr = clip.buffer.sampleRate; + const len = clip.buffer.length; + const numChannels = clip.buffer.numberOfChannels || 1; + const ctx = getAudioContext(); + const subBuffer = ctx.createBuffer(numChannels, len, sr); + for (let c = 0; c < numChannels; c++) { + subBuffer.copyToChannel(clip.buffer.getChannelData(c), c); + } + const subChannelInfo = window.SonicAudio.analyzeAudioBufferChannels(subBuffer); + const tabId = 'subtab_' + Date.now(); + const tabLabel = `Edit_${clip.name.replace('.wav', '').slice(0, 10)}`; + setSubTabs(prev => [...prev, { + id: tabId, + label: tabLabel, + trackId: trackId, + clipId: resolvedClipId, + startTime: clip.startTime, + endTime: clip.startTime + clip.buffer.duration, + buffer: subBuffer, + channelInfo: subChannelInfo, + effects: { + reverse: false, + gainDb: 0, + fadeInMs: 0, + fadeOutMs: 0, + normalizeDb: 0, + pitch: 0, + speedStretch: 100 + }, + currentTime: 0, + selectionStart: null, + selectionEnd: null, + isPlaying: false, + speed: 1.0, + volumeNodes: [], + panningNodes: [], + fadeInLen: 0, + fadeOutLen: 0, + graphMode: null, + isLooping: false, + loopCount: 0 + }]); + setActiveTab(tabId); + }; - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); + // ── Sub Tab: apply effects & merge back (LOOP_EDITOR_2.md §1.2) ── + const applySubTab = tabId => { + const subTab = subTabs.find(s => s.id === tabId); + if (!subTab || !subTab.buffer) return; + const track = tracks.find(t => t.id === subTab.trackId); + if (!track || !track.buffer) return; + const beforeSnap = captureTrackSnapshot(subTab.trackId); + + // Clone buffer and apply effects + const ctx = getAudioContext(); + const sr = subTab.buffer.sampleRate; + const eff = subTab.buffer.getChannelData(0); + let resultBuffer = ctx.createBuffer(1, eff.length, sr); + let resultData = resultBuffer.getChannelData(0); + resultData.set(eff); + + // Apply effects inline + const fx = subTab.effects || {}; + const applyResample = (data, ratio) => { + const newLen = Math.round(data.length * ratio); + const out = new Float32Array(newLen); + for (let i = 0; i < newLen; i++) { + const srcIdx = i / ratio; + const idx0 = Math.floor(srcIdx); + const idx1 = Math.min(idx0 + 1, data.length - 1); + const frac = srcIdx - idx0; + out[i] = data[idx0] * (1 - frac) + data[idx1] * frac; + } + return out; + }; + if (fx.reverse) { + const rev = new Float32Array(resultData); + for (let i = 0; i < resultData.length; i++) rev[i] = resultData[resultData.length - 1 - i]; + resultData.set(rev); + } + if (fx.gainDb !== 0) { + const gain = Math.pow(10, fx.gainDb / 20); + for (let i = 0; i < resultData.length; i++) resultData[i] = Math.max(-1, Math.min(1, resultData[i] * gain)); + } + if (fx.fadeInMs > 0) { + const fadeSamples = Math.min(resultData.length, Math.floor(fx.fadeInMs / 1000 * sr)); + for (let i = 0; i < fadeSamples; i++) resultData[i] *= i / fadeSamples; + } + // Graph Editor fade curves (trigonometric, 15_GRAPH_EDIT.md §2.3) + const gFadeIn = subTab.fadeInLen || 0; + const gFadeOut = subTab.fadeOutLen || 0; + if (gFadeIn > 0) { + const fadeSamples = Math.min(resultData.length, Math.floor(gFadeIn * sr)); + for (let i = 0; i < fadeSamples; i++) { + resultData[i] *= (1 - Math.cos(Math.PI * i / fadeSamples)) / 2; + } + } + if (gFadeOut > 0) { + const fadeSamples = Math.min(resultData.length, Math.floor(gFadeOut * sr)); + for (let i = resultData.length - fadeSamples; i < resultData.length; i++) { + const t = i - (resultData.length - fadeSamples); + resultData[i] *= (1 + Math.cos(Math.PI * t / fadeSamples)) / 2; + } + } + + // Graph Editor volume automation spline (Monotone Cubic Hermite Spline, 16_FIX_GRAPH.md §2.1) + const volNodes = subTab.volumeNodes || []; + if (volNodes.length > 0) { + const sortedNodes = [...volNodes].sort((a, b) => a.time - b.time); + const computeHermiteTangents = pts => { + const n = pts.length; + if (n < 2) return []; + const m = new Array(n); + for (let i = 1; i < n - 1; i++) { + const hP = pts[i].time - pts[i - 1].time; + const hN = pts[i + 1].time - pts[i].time; + const sP = (pts[i].db - pts[i - 1].db) / hP; + const sN = (pts[i + 1].db - pts[i].db) / hN; + m[i] = (sP + sN) / 2; + } + m[0] = n > 1 ? (pts[1].db - pts[0].db) / (pts[1].time - pts[0].time) : 0; + m[n - 1] = n > 1 ? (pts[n - 1].db - pts[n - 2].db) / (pts[n - 1].time - pts[n - 2].time) : 0; + return m; + }; + const tangents = computeHermiteTangents(sortedNodes); + const getVolumeGainAtTime = t => { + if (sortedNodes.length === 1) { + return Math.pow(10, sortedNodes[0].db / 20); + } + if (t <= sortedNodes[0].time) { + return Math.pow(10, sortedNodes[0].db / 20); + } + if (t >= sortedNodes[sortedNodes.length - 1].time) { + return Math.pow(10, sortedNodes[sortedNodes.length - 1].db / 20); + } + for (let i = 0; i < sortedNodes.length - 1; i++) { + const n1 = sortedNodes[i]; + const n2 = sortedNodes[i + 1]; + if (t >= n1.time && t <= n2.time) { + const h = n2.time - n1.time; + if (h <= 0) return Math.pow(10, n1.db / 20); + const frac = (t - n1.time) / h; + const frac2 = frac * frac, + frac3 = frac2 * frac; + const db = (2 * frac3 - 3 * frac2 + 1) * n1.db + (frac3 - 2 * frac2 + frac) * h * tangents[i] + (-2 * frac3 + 3 * frac2) * n2.db + (frac3 - frac2) * h * tangents[i + 1]; + return Math.pow(10, db / 20); + } + } + return 1.0; + }; + for (let i = 0; i < resultData.length; i++) { + const t = i / sr; + resultData[i] *= getVolumeGainAtTime(t); + } + } + if (fx.normalizeDb !== 0) { + let maxVal = 0; + for (let i = 0; i < resultData.length; i++) { + const abs = Math.abs(resultData[i]); + if (abs > maxVal) maxVal = abs; + } + if (maxVal > 0) { + const targetAmp = Math.pow(10, fx.normalizeDb / 20); + const scale = targetAmp / maxVal; + for (let i = 0; i < resultData.length; i++) resultData[i] = Math.max(-1, Math.min(1, resultData[i] * scale)); + } + } + if (fx.pitch !== 0) { + const ratio = Math.pow(2, fx.pitch / 12); + const newData = applyResample(resultData, 1 / ratio); + resultBuffer = ctx.createBuffer(1, newData.length, sr); + resultData = resultBuffer.getChannelData(0); + resultData.set(newData); + } + const stretchSpeed = subTab.speed || 1.0; + if (stretchSpeed !== 1.0) { + const ratio = 1.0 / stretchSpeed; + const newData = applyResample(resultData, ratio); + resultBuffer = ctx.createBuffer(1, newData.length, sr); + resultData = resultBuffer.getChannelData(0); + resultData.set(newData); + } + if (fx.speedStretch !== 100) { + const ratio = fx.speedStretch / 100; + const newData = applyResample(resultData, ratio); + resultBuffer = ctx.createBuffer(1, newData.length, sr); + resultData = resultBuffer.getChannelData(0); + resultData.set(newData); + } + if (subTab.clipId) { + // Clip-based merge + setTracks(prev => prev.map(t => { + if (t.id !== subTab.trackId) return t; + const updatedClips = (t.clips || []).map(c => { + if (c.id === subTab.clipId) { + return { + ...c, + buffer: resultBuffer, + name: c.name.endsWith('(edited)') ? c.name : c.name + ' (edited)' }; - - const handleSelectionBodyDragStart = (e) => { - e.preventDefault(); - e.stopPropagation(); - const startX = e.clientX; - const useLocal = selectionMode === 'local'; - const currentStart = useLocal ? localSelectionStart : selectionStart; - const currentEnd = useLocal ? localSelectionEnd : selectionEnd; - const initialLeft = Math.min(currentStart, currentEnd); - const initialRight = Math.max(currentStart, currentEnd); - const widthSec = initialRight - initialLeft; - const setStart = useLocal ? setLocalSelectionStart : setSelectionStart; - const setEnd = useLocal ? setLocalSelectionEnd : setSelectionEnd; - - const handleMouseMove = (moveEvent) => { - const deltaX = moveEvent.clientX - startX; - const deltaSec = deltaX / zoom; - let newLeft = initialLeft + deltaSec; - let newRight = initialRight + deltaSec; - - if (newLeft < 0) { - newLeft = 0; - newRight = widthSec; - } - if (newRight > maxDuration) { - newRight = maxDuration; - newLeft = maxDuration - widthSec; - } - - setStart(newLeft); - setEnd(newRight); - }; - - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - }; - - // ── Track Controls ── - const toggleTrackSoloEvaluate = (trackId) => { - const wasPlaying = isPlaying; - stopAllPlayback(); - - setTracks(prev => prev.map(t => { - if (t.id === trackId) { - return { ...t, solo: !t.solo, muted: false }; - } - return { ...t, solo: false }; - })); - - setSoloedTrackId(prev => prev === trackId ? null : trackId); - - if (wasPlaying) { - setTimeout(() => { - startOffsetTimeRef.current = currentTime; - startAudioTimeRef.current = getAudioContext().currentTime; - setIsPlaying(true); - }, 50); - } - }; - - const toggleTrackMute = (trackId) => { - const beforeSnap = captureTrackSnapshot(trackId); - setTracks(prev => prev.map(t => t.id === trackId ? { ...t, muted: !t.muted } : t)); - setUndoStack(prev => { - const next = [...prev, { - action_type: 'MUTE', - track_id: trackId, - timestamp: Date.now(), - before_state: beforeSnap, - after_state: captureTrackSnapshot(trackId), - }]; - if (next.length > MAX_UNDO) next.shift(); - return next; - }); - }; - - const updateTrackVolumeDb = (trackId, val) => { - const beforeSnap = captureTrackSnapshot(trackId); - setTracks(prev => prev.map(t => t.id === trackId ? { ...t, volumeDb: val } : t)); - setUndoStack(prev => { - const next = [...prev, { - action_type: 'VOLUME_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) { - 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) ── - const loadFileOnTrack = async (trackId, file) => { - if (!file) return; - showToast(`Đang nạp file ${file.name}...`, 'info'); - - try { - // Upload to server - uploadToServer(file, trackId); - - // Decode locally for playback + analyze channels (stereo/mono) - const { audioBuffer: decodedBuffer, channelInfo } = await window.SonicAudio.decodeAudioFile(file); - setTracks(prev => prev.map(t => t.id === trackId ? { - ...t, - name: file.name, - buffer: decodedBuffer, - channelInfo: channelInfo - } : t)); - showToast(`Nạp file thành công: ${file.name} (${channelInfo.label})`, 'success'); - } catch (err) { - showToast("Lỗi giải mã âm thanh. Định dạng file không tương thích.", 'error'); - } - }; - - // ── Synth Generators ── - const generateSynthToTrack = (trackId, type) => { - const sampleRate = 44100; - const duration = 12.0; - const context = getAudioContext(); - const frameCount = sampleRate * duration; - const newBuffer = context.createBuffer(1, frameCount, sampleRate); - const channelData = newBuffer.getChannelData(0); - - if (type === 'kick') { - for (let i = 0; i < frameCount; i++) { - const t = i / sampleRate; - const beatTime = t % 0.5; - const freq = 120 * Math.exp(-35 * beatTime); - channelData[i] = Math.sin(2 * Math.PI * freq * beatTime) * Math.exp(-6 * beatTime); - } - } else { - const notes = [220.00, 261.63, 293.66, 329.63, 392.00]; - for (let i = 0; i < frameCount; i++) { - const t = i / sampleRate; - const noteIdx = Math.floor(t * 2) % notes.length; - const freq = notes[noteIdx]; - channelData[i] = Math.sin(2 * Math.PI * freq * t) * 0.25 * (1.0 - (t % 0.5) / 0.5); - } - } - - setTracks(prev => prev.map(t => t.id === trackId ? { - ...t, name: `Demo_${type.toUpperCase()}.wav`, buffer: newBuffer - } : t)); - showToast(`Đã nạp sóng âm tổng hợp: ${type.toUpperCase()}`, 'success'); - }; - - // ── Add Track ── - const addNewTrack = () => { - const newId = (tracks.length + 1).toString(); - const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; - const selectColor = colors[tracks.length % colors.length]; - - setTracks(prev => [...prev, { - id: newId, name: `Track ${newId}`, buffer: null, startTime: 0, height: 128, - 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; - }; - - // ── Server-side Export ── - const triggerWavExport = async () => { - const activeTracks = tracks.filter(t => t.buffer && !t.muted); - if (activeTracks.length === 0) { - showToast("Không tìm thấy dữ liệu âm thanh hợp lệ để xuất.", "warning"); - return; - } - - // Check if all active tracks have server file IDs - const allOnServer = activeTracks.every(t => serverFileIdMap[t.id]); - - if (allOnServer && serverStatus === 'connected') { - // Use server-side export - setIsExporting(true); - showToast("Đang gửi yêu cầu xuất âm thanh đến máy chủ...", "info"); - - try { - const sessionId = `session_${Date.now()}`; - const tracksMeta = activeTracks.map(t => ({ - track_id: t.id, - file_id: serverFileIdMap[t.id], - volume_db: t.volumeDb, - muted: false, - clips: [{ - clip_id: `clip_${t.id}`, - start_time_seconds: t.startTime || 0, - end_time_seconds: (t.startTime || 0) + t.buffer.duration, - loop_count: 1, - apply_zero_crossing: true, - fade_in_ms: 0, - fade_out_ms: 0, - }] - })); - - const resp = await fetch(`${API_MULTITRACK}/mix`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - session_id: sessionId, - export_settings: { - sample_rate: parseInt(exportSettings.sampleRate), - bit_depth: parseInt(exportSettings.bitDepth), - format: exportSettings.format, - }, - tracks: tracksMeta, - }), - }); - - if (!resp.ok) throw new Error(`Server export failed: ${resp.status}`); - const data = await resp.json(); - - showToast("Đang xử lý trên máy chủ...", "info"); - - // Poll for result - const result = await pollTaskResult(data.task_id, 20); - - if (result.success) { - // Get the uploaded file from server - const fileId = result.output_file_id || result.output_path?.split('/').pop(); - if (fileId) { - const downloadUrl = `${API_AUDIO}/download/${fileId}`; - const a = document.createElement('a'); - a.href = downloadUrl; - a.download = fileId; - a.click(); - showToast("Xuất bản âm thanh từ máy chủ hoàn tất!", "success"); - } - } else { - throw new Error(result.error || 'Server processing failed'); - } - } catch (err) { - showToast("Lỗi xuất máy chủ: " + err.message + ". Chuyển sang xuất client.", "warning"); - // Fall back to client-side export - clientSideExport(activeTracks); - } finally { - setIsExporting(false); - } - } else { - // Client-side export (existing working code) - clientSideExport(activeTracks); - } - }; - - const handleSaveCloud = async () => { - if (!currentUser) { setIsMandatoryLogin(false); setAuthMode('login'); setAuthModalOpen(true); return; } - const name = prompt("Nhập tên dự án để lưu lên Cloud:", "Dự án SonicForge"); - if (!name) return; - const serializeSafe = (arr) => (arr || []).map(t => ({ - id: t.id, name: t.name, startTime: t.startTime, height: t.height, - volumeDb: t.volumeDb, pan: t.pan, muted: t.muted, solo: t.solo, - color: t.color, markers: t.markers || [], serverFileId: t.serverFileId || null, - channelInfo: t.channelInfo ? { channels: t.channelInfo.channels, isStereo: t.channelInfo.isStereo, label: t.channelInfo.label } : null - })); - try { - const dataJson = JSON.stringify({ id: 'cloud_project', name, tracks: serializeSafe(tracks) }); - await window.SonicAPI.saveCloudProject(name, dataJson); - showToast("Đã lưu dự án lên Cloud thành công!", "success"); - } catch (err) { showToast(err.message || "Lỗi lưu Cloud", "error"); } - }; - - const handleExportSFS = () => { - const serializeSafe = (arr) => (arr || []).map(t => ({ - id: t.id, name: t.name, startTime: t.startTime, height: t.height, - volumeDb: t.volumeDb, pan: t.pan, muted: t.muted, solo: t.solo, - color: t.color, markers: t.markers || [], serverFileId: t.serverFileId || null, - channelInfo: t.channelInfo ? { channels: t.channelInfo.channels, isStereo: t.channelInfo.isStereo, label: t.channelInfo.label } : null - })); - window.SonicStorage.exportProjectToSFS({ id: 'proj_' + Date.now(), name: 'Dự án SonicForge', tracks: serializeSafe(tracks) }); - showToast("Đã xuất dự án (.sfs) thành công!", "success"); - }; - - const handleImportSFS = () => { - const input = document.createElement('input'); - input.type = 'file'; input.accept = '.sfs,application/json'; - input.onchange = async (e) => { - if (!e.target.files[0]) return; - try { - const proj = await window.SonicStorage.importProjectFromSFSFile(e.target.files[0]); - const restored = (proj.tracks || []).map(t => ({ ...t, buffer: null, channelInfo: t.channelInfo || null, clips: t.clips || [], serverFileId: t.serverFileId || null })); - if (restored.length > 0) { setTracks(restored); showToast(`Đã nạp dự án "${proj.name}" từ tệp .sfs thành công!`, "success"); } - } catch (err) { showToast(err.message || "Lỗi mở tệp .sfs", "error"); } - }; - input.click(); - }; - - const clientSideExport = async (activeTracks) => { - setIsExporting(true); - showToast("Đang trộn âm thanh đa kênh (Offline Mixdown)...", "info"); - - try { - const targetRate = parseInt(exportSettings.sampleRate); - const bitDepth = parseInt(exportSettings.bitDepth); - - const durationLimit = Math.max(...activeTracks.map(t => { - const clips = t.clips && t.clips.length > 0 ? t.clips : (t.buffer ? [{ - id: 'default', - buffer: t.buffer, - startTime: t.startTime || 0, - name: t.name, - speed: t.speed || 1.0 - }] : []); - if (clips.length === 0) return 0; - return Math.max(...clips.map(c => (c.startTime || 0) + c.buffer.duration / (c.speed || 1.0))); - })); - - const offlineCtx = new OfflineAudioContext(1, Math.ceil(targetRate * Math.max(0.1, durationLimit)), targetRate); - - activeTracks.forEach(t => { - const clips = t.clips && t.clips.length > 0 ? t.clips : (t.buffer ? [{ - id: 'default', - buffer: t.buffer, - startTime: t.startTime || 0, - name: t.name, - speed: t.speed || 1.0 - }] : []); - - clips.forEach(clip => { - if (!clip.buffer) return; - const source = offlineCtx.createBufferSource(); - source.buffer = clip.buffer; - source.playbackRate.value = clip.speed || 1.0; - - const gain = offlineCtx.createGain(); - 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(panner); - panner.connect(offlineCtx.destination); - - const clipStart = clip.startTime || 0; - source.start(clipStart); - }); - }); - - const renderedBuffer = await offlineCtx.startRendering(); - const monoData = renderedBuffer.getChannelData(0); - const bufferLength = monoData.length; - const bytesPerSample = bitDepth / 8; - const headerSize = 44; - const fileSizeBytes = headerSize + (bufferLength * bytesPerSample); - const fileBuffer = new ArrayBuffer(fileSizeBytes); - const view = new DataView(fileBuffer); - - const writeString = (offset, string) => { - for (let i = 0; i < string.length; i++) { - view.setUint8(offset + i, string.charCodeAt(i)); - } - }; - - writeString(0, 'RIFF'); - view.setUint32(4, fileSizeBytes - 8, true); - writeString(8, 'WAVE'); - writeString(12, 'fmt '); - view.setUint32(16, 16, true); - view.setUint16(20, 1, true); - view.setUint16(22, 1, true); - view.setUint32(24, targetRate, true); - view.setUint32(28, targetRate * bytesPerSample, true); - view.setUint16(32, bytesPerSample, true); - view.setUint16(34, bitDepth, true); - writeString(36, 'data'); - view.setUint32(40, bufferLength * bytesPerSample, true); - - let offset = 44; - for (let i = 0; i < bufferLength; i++) { - const sample = Math.max(-1, Math.min(1, monoData[i])); - if (bitDepth === 8) { - view.setUint8(offset, Math.floor((sample + 1.0) * 127.5)); - } else if (bitDepth === 16) { - view.setInt16(offset, Math.floor(sample < 0 ? sample * 0x8000 : sample * 0x7FFF), true); - } else if (bitDepth === 24) { - const val24 = Math.floor(sample < 0 ? sample * 0x800000 : sample * 0x7FFFFF); - view.setUint8(offset, val24 & 0xFF); - view.setUint8(offset + 1, (val24 >> 8) & 0xFF); - view.setUint8(offset + 2, (val24 >> 16) & 0xFF); - } - offset += bytesPerSample; - } - - const blob = new Blob([view], { type: 'audio/wav' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `sonicforge-session-${targetRate}hz-${bitDepth}bit.wav`; - a.click(); - URL.revokeObjectURL(url); - showToast("Xuất bản âm thanh hoàn tất!", "success"); - } catch (err) { - showToast("Lỗi xuất âm thanh: " + err.message, "error"); - } finally { - setIsExporting(false); - } - }; - - // ── AI Analysis (server-side with fallback) ── - const triggerAIAnalysis = async () => { - setAnalysisState({ status: 'Connecting to AI Engine...', data: null, isRunning: true }); - - // Check if we have a file on the server to analyze - const activeTrack = tracks.find(t => t.id === selectedTrackId); - const serverFileId = serverFileIdMap[selectedTrackId]; - - if (serverFileId && serverStatus === 'connected') { - try { - const resp = await fetch(`${API_AUDIO}/analyze-ai`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - file_id: serverFileId, - api_base_url: aiConfig.baseUrl !== API_BASE_URL ? aiConfig.baseUrl : null, - model: aiConfig.model, - }), - }); - if (resp.ok) { - const data = await resp.json(); - showToast("Đang phân tích cấu trúc trên máy chủ...", "info"); - - try { - const result = await pollTaskResult(data.task_id, 15); - if (result.bpm) { - if (selLeft !== null && selRight !== null && activeTrack && activeTrack.buffer) { - const snapStart = findZeroCrossing(activeTrack.buffer, selLeft); - const snapEnd = findZeroCrossing(activeTrack.buffer, selRight); - if (selectionMode === 'local') { - setLocalSelectionStart(snapStart); - setLocalSelectionEnd(snapEnd); - } else { - setSelectionStart(snapStart); - setSelectionEnd(snapEnd); - } - } - setAnalysisState({ - status: 'Hoàn thành phân tích (Server)', - data: result, - isRunning: false, - }); - showToast(`AI Server: ${result.bpm} BPM | ${result.beats?.length || 0} beats detected (Zero-Crossing Aligned)`, 'success'); - return; - } - } catch (e) { - console.warn('Server AI poll failed, using fallback:', e); - } - } - } catch (err) { - console.warn('Server AI analysis failed, using fallback:', err); - } - } - - // Fallback: client-side analysis with simulated BPM - setAnalysisState({ status: 'Processing structural detection...', data: null, isRunning: true }); - - setTimeout(() => { - let bpm = 120; - // Try to detect BPM from buffer - if (activeTrack && activeTrack.buffer) { - const data = activeTrack.buffer.getChannelData(0); - const sr = activeTrack.buffer.sampleRate; - // Simple autocorrelation for BPM estimation - const windowSize = Math.min(sr * 3, data.length); - if (windowSize > sr) { - let maxCorr = 0; - let bestLag = Math.floor(sr * 0.25); // ~240 BPM max - for (let lag = Math.floor(sr * 0.25); lag < Math.floor(sr * 2); lag++) { - let corr = 0; - const n = Math.floor(windowSize / 2); - for (let i = 0; i < n; i++) { - corr += data[i] * data[i + lag]; - } - corr /= n; - if (corr > maxCorr) { - maxCorr = corr; - bestLag = lag; - } - } - if (bestLag > 0) { - bpm = Math.round(60 * sr / bestLag); - bpm = Math.max(60, Math.min(200, bpm)); - } - } - } - - if (selLeft !== null && selRight !== null && activeTrack && activeTrack.buffer) { - const snapStart = findZeroCrossing(activeTrack.buffer, selLeft); - const snapEnd = findZeroCrossing(activeTrack.buffer, selRight); - if (selectionMode === 'local') { - setLocalSelectionStart(snapStart); - setLocalSelectionEnd(snapEnd); - } else { - setSelectionStart(snapStart); - setSelectionEnd(snapEnd); - } - } - - setAnalysisState({ - status: 'Hoàn thành phân tích (Client)', - data: { bpm, bars: Math.max(4, Math.round(bpm / 30)), timeSig: '4/4', detectedKey: 'Am' }, - isRunning: false, - }); - showToast(`Phân tích: ${bpm} BPM (Zero-Crossing Aligned)`, 'success'); - }, 1500); - }; - - // ── AI Analysic Loop: scan track, detect beats, place markers for loop selection ── - const handleAIAnalysicLoop = async () => { - const forcedTrackId = subTabAiTrackIdRef.current; - subTabAiTrackIdRef.current = null; - const activeTrackId = forcedTrackId || selectedTrackId; - const activeTrack = tracks.find(t => t.id === activeTrackId); - if (!activeTrack || !activeTrack.buffer) { - showToast("Vui lòng chọn một Track có âm thanh để AI phân tích.", "warning"); - return; - } - setAnalysisState({ status: 'AI Analysic Loop: đang phát hiện nhịp...', data: null, isRunning: true }); - showToast("AI Analysic Loop: đang quét cấu trúc nhịp điệu...", "info"); - - try { - const buffer = activeTrack.buffer; - const data = buffer.getChannelData(0); - const sr = buffer.sampleRate; - const windowSize = Math.min(sr * 3, data.length); - - // Client-side BPM detection via autocorrelation - let detectedBPM = 120; - if (windowSize > sr) { - let maxCorr = 0; - for (let lag = Math.floor(sr * 0.3); lag <= Math.floor(sr * 2.0); lag++) { - let corr = 0; - const step = 4; - for (let i = 0; i < windowSize && i + lag < data.length; i += step) { - corr += data[i] * data[i + lag]; - } - corr /= (windowSize / step); - if (corr > maxCorr) { maxCorr = corr; detectedBPM = 60 / (lag / sr); } - } - } - detectedBPM = Math.round(Math.min(240, Math.max(60, detectedBPM))); - - const beatDuration = 60 / detectedBPM; - const barDuration = beatDuration * 4; - const totalDuration = buffer.duration; - - // Place markers at each bar (strong beat) position - const barMarkers = []; - for (let t = 0; t < totalDuration; t += barDuration) { - const zcTime = findZeroCrossing(buffer, t); - barMarkers.push({ - id: 'ai_bar_' + barMarkers.length + '_' + Date.now(), - time: zcTime, - label: `Bar ${barMarkers.length + 1}`, - color: '#06b6d4' - }); - // Add beat markers within each bar - for (let b = 1; b < 4; b++) { - const bt = t + b * beatDuration; - if (bt < totalDuration) { - const zcBt = findZeroCrossing(buffer, bt); - barMarkers.push({ - id: 'ai_beat_' + barMarkers.length + '_' + Date.now(), - time: zcBt, - label: `Beat ${b + 1}`, - color: '#a855f7' - }); - } - } - } - - setTracks(prev => prev.map(t => { - if (t.id !== activeTrack.id) return t; - const existingMarkers = t.markers || []; - return { ...t, markers: [...existingMarkers, ...barMarkers] }; - })); - - // Find the first strong beat to set as selection start - const firstBeat = barMarkers.length > 0 ? barMarkers[0].time : 0; - const secondBar = barMarkers.length > 4 ? barMarkers[Math.min(4, barMarkers.length - 1)].time : Math.min(totalDuration, firstBeat + barDuration); - - setSelectionStart(firstBeat); - setSelectionEnd(secondBar); - setAnalysisState({ - status: `AI Analysic Loop: ${detectedBPM} BPM, ${barMarkers.length} markers (Bar/Beat)`, - data: { bpm: detectedBPM, bars: Math.floor(totalDuration / barDuration) }, - isRunning: false - }); - showToast(`AI Analysic Loop: ${detectedBPM} BPM - ${Math.floor(totalDuration / barDuration)} bars detected`, "success"); - } catch (err) { - setAnalysisState({ status: 'Lỗi AI Analysic Loop', data: null, isRunning: false }); - showToast(err.message || 'Lỗi khi phân tích nhịp', 'error'); - } - }; - // ── Mark Selection ── - // ── Helper: set selection range from buffer (used by sub-tab AI) ── - const setSelectionRangeOnBuffer = (buffer, startTime, endTime) => { - setSelectionStart(startTime); - setSelectionEnd(endTime); - }; - - // Ref for forced trackId (used by sub-tab AI buttons, overrides selectedTrackId) - const subTabAiTrackIdRef = useRef(null); - - const handleAIScan = async () => { - // Resolve track: prefer sub-tab override, then selectedTrackId - const forcedTrackId = subTabAiTrackIdRef.current; - subTabAiTrackIdRef.current = null; - const activeTrackId = forcedTrackId || selectedTrackId; - const activeTrack = tracks.find(t => t.id === activeTrackId); - if (!activeTrack || !activeTrack.buffer) { - showToast("Vui lòng chọn một Track có âm thanh để AI quét Loop.", "warning"); - return; - } - setAnalysisState({ status: 'AI Loop Scan đang quét nhịp điệu và phách mạnh...', data: null, isRunning: true }); - showToast("AI Scan đang phân tích tempo và phách mạnh...", "info"); - - try { - const buffer = activeTrack.buffer; - const data = buffer.getChannelData(0); - const sr = buffer.sampleRate; - const windowSize = Math.min(sr * 3, data.length); - - // Detect BPM via autocorrelation - let detectedBPM = 120; - if (windowSize > sr) { - let maxCorr = 0; - for (let lag = Math.floor(sr * 0.3); lag <= Math.floor(sr * 2.0); lag++) { - let corr = 0; - const step = 4; - for (let i = 0; i < windowSize && i + lag < data.length; i += step) { - corr += data[i] * data[i + lag]; - } - corr /= (windowSize / step); - if (corr > maxCorr) { maxCorr = corr; detectedBPM = 60 / (lag / sr); } - } - } - detectedBPM = Math.round(Math.min(240, Math.max(60, detectedBPM))); - - // Calculate bar grid - const beatDuration = 60 / detectedBPM; - const barDuration = beatDuration * 4; - const totalDuration = buffer.duration; - - // Place markers at bar starts (strong beats / downbeats) - const barMarkers = []; - for (let t = 0; t < totalDuration; t += barDuration) { - const zcTime = findZeroCrossing(buffer, t); - barMarkers.push({ - id: 'ai_bar_' + barMarkers.length + '_' + Date.now(), - time: zcTime, - label: `Downbeat ${barMarkers.length + 1}`, - color: '#06b6d4' - }); - } - - setTracks(prev => prev.map(t => { - if (t.id !== activeTrack.id) return t; - const existingMarkers = t.markers || []; - return { ...t, markers: [...existingMarkers, ...barMarkers] }; - })); - - // Set selection to the first downbeat - const firstBarStart = barMarkers.length > 0 ? barMarkers[0].time : 0; - const secondBarStart = barMarkers.length > 1 ? barMarkers[1].time : Math.min(totalDuration, firstBarStart + barDuration); - - setSelectionStart(firstBarStart); - setSelectionEnd(secondBarStart); - setAnalysisState({ - status: `AI Scan: ${detectedBPM} BPM, ${barMarkers.length} downbeats (đã snap zero-crossing)`, - data: { bpm: detectedBPM }, - isRunning: false - }); - showToast(`AI Scan: ${detectedBPM} BPM - ${barMarkers.length} downbeats detected`, "success"); - } catch (err) { - setAnalysisState({ status: 'Lỗi khi AI Scan', data: null, isRunning: false }); - showToast(err.message || 'Lỗi khi quét AI Loop', 'error'); - } - }; - - const runPythonTool = async (toolType) => { - const activeTrack = tracks.find(t => t.id === selectedTrackId); - if (!activeTrack || !activeTrack.buffer) { - showToast("Vui lòng chọn một Track để xử lý công cụ Python.", "warning"); - return; - } - try { - if (toolType === 'normalize') { - const channelData = activeTrack.buffer.getChannelData(0); - let maxVal = 0; - for (let i = 0; i < channelData.length; i++) { - maxVal = Math.max(maxVal, Math.abs(channelData[i])); - } - if (maxVal > 0) { - const gain = 1.0 / maxVal; - for (let i = 0; i < channelData.length; i++) { - channelData[i] *= gain; - } - } - showToast("Đã Chuẩn Hóa Peak âm thanh về 0 dB!", "success"); - } else if (toolType === 'invert_phase') { - const channelData = activeTrack.buffer.getChannelData(0); - for (let i = 0; i < channelData.length; i++) { - channelData[i] *= -1; - } - showToast("Đã Đảo Pha (180°) âm thanh thành công!", "success"); - } else if (toolType === 'swap_channels') { - if (activeTrack.buffer.numberOfChannels >= 2) { - const left = activeTrack.buffer.getChannelData(0); - const right = activeTrack.buffer.getChannelData(1); - for (let i = 0; i < left.length; i++) { - const temp = left[i]; - left[i] = right[i]; - right[i] = temp; - } - showToast("Đã Đổi Kênh Left / Right thành công!", "success"); - } else { - showToast("Track hiện tại là Mono. Chỉ áp dụng Đổi Kênh cho Stereo.", "info"); - } - } else if (toolType === 'synth_wave') { - generateSynthToTrack(activeTrack.id, 'synth'); - showToast("Đã tạo Tín Hiệu Sóng Tổng Hợp bằng công cụ Python!", "success"); - } - } catch (err) { - showToast(err.message || "Lỗi khi chạy công cụ Python", "error"); - } - }; - - const handleMarkSelection = () => { - if (selLeft === null || selRight === null || selectionStats.length === 0) { - showToast("Vui lòng chọn một khoảng thời gian trên sóng âm trước.", "warning"); - return; - } - - const targetTrack = tracks.find(t => t.id === selectedTrackId); - if (!targetTrack || !targetTrack.buffer) { - showToast("Vui lòng nhấp chọn một Track có sóng âm để gán Marker.", "warning"); - return; - } - - // For local selection, force mark on the local-selected track - const markTrackId = selectionMode === 'local' && localSelectionTrackId - ? localSelectionTrackId : selectedTrackId; - - setTracks(prev => prev.map(t => { - if (t.id !== markTrackId) return t; - const snapStart = findZeroCrossing(t.buffer, selLeft); - const snapEnd = findZeroCrossing(t.buffer, selRight); - - const newMarkers = [ - ...t.markers, - { id: Date.now() + '_s', time: snapStart }, - { id: Date.now() + '_e', time: snapEnd } - ].sort((a,b) => a.time - b.time); - - return { ...t, markers: newMarkers }; - })); - - showToast(`Đã tạo 2 Markers tại đầu và cuối dải chọn (Snap Zero-Crossing)`, "success"); - }; - - // ── AI Cut to New Track (Music Theory Loop Detection) ── - const handleAICutToNewTrack = () => { - const forcedTrackId = subTabAiTrackIdRef.current; - subTabAiTrackIdRef.current = null; - const activeTrackId = forcedTrackId || selectedTrackId; - const activeTrack = tracks.find(t => t.id === activeTrackId); - if (!activeTrack || !activeTrack.buffer) { - showToast("Vui lòng chọn một Track có dữ liệu âm thanh trước.", "warning"); - return; - } - if (selectionStart === null || selectionEnd === null || selectionStats.length === 0) { - showToast("Vui lòng kéo chọn một khoảng thời gian trên sóng âm.", "warning"); - return; - } - - setAnalysisState({ status: 'AI Cut: đang phân tích nhịp và tìm loop point...', data: null, isRunning: true }); - showToast("AI Cut: đang phân tích nhịp điệu và tìm điểm loop chính xác...", "info"); - - setTimeout(() => { - try { - const buffer = activeTrack.buffer; - const sampleRate = buffer.sampleRate; - const channelData = buffer.getChannelData(0); - const dataLen = channelData.length; - const windowSize = Math.min(sampleRate * 3, dataLen); - - // Detect BPM via autocorrelation - let detectedBPM = 120; - if (windowSize > sampleRate) { - let maxCorr = 0; - for (let lag = Math.floor(sampleRate * 0.3); lag <= Math.floor(sampleRate * 2.0); lag++) { - let corr = 0; - const step = 4; - for (let i = 0; i < windowSize && i + lag < dataLen; i += step) { - corr += channelData[i] * channelData[i + lag]; - } - corr /= (windowSize / step); - if (corr > maxCorr) { maxCorr = corr; detectedBPM = 60 / (lag / sampleRate); } - } - } - detectedBPM = Math.round(Math.min(240, Math.max(60, detectedBPM))); - const beatDuration = 60 / detectedBPM; - const barDuration = beatDuration * 4; - - // Use the selection range - const rawStart = selectionStats.start; - const rawEnd = selectionStats.end; - const selDuration = rawEnd - rawStart; - - // Find the nearest bar start (downbeat) for loop start - const barsFromZero = rawStart / barDuration; - const nearestBarStart = Math.round(barsFromZero) * barDuration; - const loopStart = Math.max(0, Math.min(rawStart + barDuration, nearestBarStart)); - - // Find the nearest beat 4 (bar end) for loop end - // In 4/4 time: beat 4 = barStart + 3*beatDuration = barEnd - const barsFromStart = rawEnd / barDuration; - const nearestBarEnd = Math.round(barsFromStart) * barDuration; - // Ensure minimum 1 bar loop - let loopEnd = Math.max(loopStart + barDuration, nearestBarEnd); - if (loopEnd > rawEnd + beatDuration) loopEnd = loopStart + Math.ceil(selDuration / barDuration) * barDuration; - - // Snap to zero-crossing for click-free loop - const snapLoopStart = findZeroCrossing(buffer, loopStart); - const snapLoopEnd = findZeroCrossing(buffer, loopEnd); - - // Place markers for the loop points - setTracks(prev => prev.map(t => { - if (t.id !== activeTrack.id) return t; - const existingMarkers = t.markers || []; - const filtered = existingMarkers.filter(m => !m.id.startsWith('ai_loop_')); - return { ...t, markers: [...filtered, - { id: 'ai_loop_start_' + Date.now(), time: snapLoopStart, label: 'Loop Start (Bar ' + (Math.floor(snapLoopStart / barDuration) + 1) + ')', color: '#06b6d4' }, - { id: 'ai_loop_end_' + Date.now(), time: snapLoopEnd, label: 'Loop End (Beat 4)', color: '#a855f7' } - ]}; - })); - - setSelectionStart(snapLoopStart); - setSelectionEnd(snapLoopEnd); - - const startSample = Math.max(0, Math.min(dataLen - 1, Math.floor(snapLoopStart * sampleRate))); - const endSample = Math.max(0, Math.min(dataLen, Math.floor(snapLoopEnd * sampleRate))); - const sliceLength = endSample - startSample; - - if (sliceLength <= 0) { - showToast("Dải cắt không hợp lệ hoặc khoảng thời gian quá ngắn.", "error"); - setAnalysisState({ status: 'Thất bại', data: null, isRunning: false }); - return; - } - - const context = getAudioContext(); - const numChannels = buffer.numberOfChannels || 1; - const slicedBuffer = context.createBuffer(numChannels, sliceLength, sampleRate); - for (let c = 0; c < numChannels; c++) { - const srcData = buffer.getChannelData(c); - const dstData = slicedBuffer.getChannelData(c); - dstData.set(srcData.subarray(startSample, endSample)); - } - - const newId = 'track_ai_cut_' + Date.now(); - const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; - const selectColor = colors[tracks.length % colors.length]; - const barNum = Math.floor(snapLoopStart / barDuration) + 1; - const barsCount = Math.max(1, Math.round((snapLoopEnd - snapLoopStart) / barDuration)); - - const newTrack = { - id: newId, - name: `Loop_${barNum}bar_${activeTrack.name.replace('.wav', '').slice(0, 10)}_${snapLoopStart.toFixed(1)}s.wav`, - buffer: slicedBuffer, - channelInfo: activeTrack.channelInfo ? { ...activeTrack.channelInfo } : null, - startTime: snapLoopStart, - clips: [{ - id: 'clip_' + newId, - buffer: slicedBuffer, - startTime: snapLoopStart, - name: `Loop_${barNum}bar_${snapLoopStart.toFixed(1)}s` - }], - volumeDb: 0, pan: 0, muted: false, solo: false, - color: selectColor, - markers: [ - { id: Date.now() + '_s', time: 0 }, - { id: Date.now() + '_e', time: snapLoopEnd - snapLoopStart } - ], - serverFileId: null, - }; - - setTracks(prev => { - const idx = prev.findIndex(t => t.id === selectedTrackId); - const updated = [...prev]; - if (idx !== -1) { updated.splice(idx + 1, 0, newTrack); } - else { updated.push(newTrack); } - return updated; - }); - - setSelectedTrackId(newId); - setAnalysisState({ - status: `AI Cut: ${detectedBPM} BPM, ${barsCount} bars loop (Zero-Crossing aligned)`, - data: { bpm: detectedBPM, bars: barsCount, timeSig: '4/4' }, - isRunning: false - }); - showToast(`AI Cut: ${barsCount} bars loop at ${snapLoopStart.toFixed(3)}s - ${snapLoopEnd.toFixed(3)}s [${detectedBPM} BPM]`, "success"); - setTimeout(() => lucide.createIcons(), 200); - } catch (err) { - showToast("Lỗi khi AI Cut: " + err.message, "error"); - setAnalysisState({ status: 'Lỗi AI Cut', data: null, isRunning: false }); - } - }, 800); - }; - - // ── Split Track at Playhead ── - const handleSplitTrackAtTime = (trackId, clipId, time) => { - const track = tracks.find(t => t.id === trackId); - if (!track) return; - - const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ - id: 'default', - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name - }] : []); - - const targetClipId = clipId || (clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration)?.id); - if (!targetClipId) return; - - const clip = clips.find(c => c.id === targetClipId); - if (!clip || !clip.buffer) return; - - const relTime = Math.max(0, time - clip.startTime); - const sr = clip.buffer.sampleRate; - const cutSample = Math.floor(relTime * sr); - const originalData = clip.buffer.getChannelData(0); - - if (cutSample <= 0 || cutSample >= originalData.length) { - showToast("Vị trí cắt nằm ngoài dải âm thanh của clip.", "warning"); - return; - } - - const beforeSnap = captureTrackSnapshot(trackId); - - const ctx = getAudioContext(); - const b1 = ctx.createBuffer(1, cutSample, sr); - b1.copyToChannel(originalData.subarray(0, cutSample), 0); - - const b2 = ctx.createBuffer(1, originalData.length - cutSample, sr); - b2.copyToChannel(originalData.subarray(cutSample), 0); - - const clip1 = { - id: 'clip_' + Date.now() + '_p1', - name: `${clip.name.replace(' (Part 1)', '').replace(' (Part 2)', '')} (Part 1)`, - buffer: b1, - startTime: clip.startTime - }; - - const clip2 = { - id: 'clip_' + Date.now() + '_p2', - name: `${clip.name.replace(' (Part 1)', '').replace(' (Part 2)', '')} (Part 2)`, - buffer: b2, - startTime: clip.startTime + (cutSample / sr) - }; - - setTracks(prev => prev.map(t => { - if (t.id === trackId) { - const remainingClips = clips.filter(c => c.id !== targetClipId); - const updatedClips = [...remainingClips, clip1, clip2]; - return { - ...t, - clips: updatedClips, - buffer: updatedClips[0]?.buffer || null, - startTime: updatedClips[0]?.startTime || 0, - name: updatedClips[0]?.name || t.name - }; - } - return t; - })); - - setTimeout(() => { - const afterSnap = captureTrackSnapshot(trackId); - pushAction('SPLIT_CLIP', trackId, beforeSnap, afterSnap); - }, 50); - - showToast(`Đã chia nhỏ clip tại ${formatTime(time)}.`, "info"); - }; - - const handleSplitTrack = (trackId) => { - handleSplitTrackAtTime(trackId, null, currentTime); - }; - - // ── Glue (Merge) Clips on Selected Track ── - const handleGlueTracks = () => { - const track = tracks.find(t => t.id === selectedTrackId); - if (!track) { - showToast('Vui lòng chọn một track để thực hiện gộp (glue).', 'warning'); - return; - } - - const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ - id: 'default', - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name - }] : []); - - if (clips.length < 2) { - showToast('Cần ít nhất 2 clip trên track này để gộp (glue).', 'warning'); - return; - } - - const beforeSnap = captureTrackSnapshot(track.id); - - const ctx = getAudioContext(); - const sr = clips[0].buffer.sampleRate; - - let minStart = Infinity; - let maxEnd = -Infinity; - clips.forEach(c => { - const start = c.startTime || 0; - const end = start + c.buffer.duration; - minStart = Math.min(minStart, start); - maxEnd = Math.max(maxEnd, end); - }); - - const newDur = maxEnd - minStart; - const newBuffer = ctx.createBuffer(1, Math.ceil(newDur * sr), sr); - const newData = newBuffer.getChannelData(0); - - clips.forEach(c => { - const data = c.buffer.getChannelData(0); - const offset = Math.floor(((c.startTime || 0) - minStart) * sr); - for (let i = 0; i < data.length; i++) { - if (offset + i < newData.length) { - newData[offset + i] += data[i]; - } - } - }); - - let maxPeak = 0; - for (let i = 0; i < newData.length; i++) { - const abs = Math.abs(newData[i]); - if (abs > maxPeak) maxPeak = abs; - } - if (maxPeak > 1.0) { - for (let i = 0; i < newData.length; i++) newData[i] /= maxPeak; - } - - const mergedClip = { - id: 'clip_merged_' + Date.now(), - name: `${track.name.replace(' (Part 1)', '').replace(' (Part 2)', '')} (Glued)`, - buffer: newBuffer, - startTime: minStart - }; - - setTracks(prev => prev.map(t => { - if (t.id === track.id) { - return { - ...t, - clips: [mergedClip], - buffer: newBuffer, - startTime: minStart, - name: mergedClip.name - }; - } - return t; - })); - - setTimeout(() => { - const afterSnap = captureTrackSnapshot(track.id); - pushAction('GLUE', track.id, beforeSnap, afterSnap); - }, 50); - - showToast(`Đã gộp ${clips.length} clips thành công.`, 'success'); - }; - - // ── Save AI config to localStorage ── - useEffect(() => { - localStorage.setItem('ai_base_url', aiConfig.baseUrl); - localStorage.setItem('ai_api_key', aiConfig.apiKey); - localStorage.setItem('ai_model', aiConfig.model); - }, [aiConfig]); - - return ( -
- {/* ── Header ── */} - {/* ── Menu Bar ── */} -
- {[ - { label: 'File', items: [ - { label: 'New Project', icon: 'file-plus', shortcut: 'Ctrl+N', action: () => { setTracks([{ id:'1', name:'Track 01', buffer:null, startTime:0, height:128, volumeDb:0, pan:0, muted:false, solo:false, color:'#0f766e', markers:[], serverFileId:null }, { id:'2', name:'Track 02', buffer:null, startTime:0, height:128, 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: () => handleImportSFS() }, - { label: 'Save Project', icon: 'save', shortcut: 'Ctrl+S', action: () => handleExportSFS() }, - { label: 'Save As...', icon: 'save', shortcut: 'Ctrl+Alt+S', action: () => handleExportSFS() }, - { label: 'Save to Cloud', icon: 'upload-cloud', action: () => handleSaveCloud() }, - { sep: true }, - { label: 'Config AI Providers...', icon: 'settings', action: () => setAiConfigModalOpen(true) }, - { label: 'Import Audio...', icon: 'file-input', shortcut: 'Ctrl+Alt+I', action: () => { const input = document.createElement('input'); input.type='file'; input.accept='audio/*'; input.onchange=async (e)=>{ if(e.target.files[0]){ addNewTrack(); const newId=(tracks.length+1).toString(); setTimeout(()=>loadFileOnTrack(newId,e.target.files[0]),100); } }; input.click(); showToast('Import audio','info'); } }, - { label: 'Export Mix...', icon: 'file-output', action: () => triggerWavExport() }, - { sep: true }, - ...(currentUser ? [{ label: 'Profile', icon: 'user', action: () => setProfileModalOpen(true) }] : []), - ...(currentUser && currentUser.role === 'admin' ? [{ label: 'System Manager', icon: 'settings', action: () => setSystemManagerModalOpen(true) }] : []), - { label: 'Logout', icon: 'log-out', action: () => handleLogout() }, - ]}, - { label: 'Edit', items: [ - { label: 'Insert New Track', icon: 'plus', shortcut: 'Ctrl+I', action: addNewTrack }, - { label: 'Insert Music to Track', icon: 'music', shortcut: 'Ctrl+Alt+I', action: () => showToast('Select music file to insert','info') }, - { sep: true }, - { label: 'Edit in New Tab', icon: 'file-edit', shortcut: 'Ctrl+E', action: () => openTempTab() }, - { label: 'Split at Playhead', icon: 'scissors', shortcut: 'S', action: () => handleSplitTrack(selectedTrackId) }, - { label: 'Merge Tracks', icon: 'combine', shortcut: 'Ctrl+M', action: () => { handleMergeTracks(); } }, - { sep: true }, - { label: 'Undo', icon: 'undo', shortcut: 'Ctrl+Z', action: () => { handleUndo(); } }, - { label: 'Redo', icon: 'redo', shortcut: 'Ctrl+Y', action: () => { handleRedo(); } }, - { sep: true }, - { label: 'Copy', icon: 'copy', shortcut: 'Ctrl+C', action: () => { handleCopyTrack(); } }, - { label: 'Cut', icon: 'scissors', shortcut: 'Ctrl+X', action: () => { handleCutTrack(); } }, - { label: 'Paste', icon: 'clipboard', shortcut: 'Ctrl+V', action: handlePasteTrack }, - { sep: true }, - { label: 'Delete Track', icon: 'trash-2', shortcut: 'Del', action: () => { handleDeleteTrack(); } }, - ]}, - { label: 'View', items: [ - { label: 'Master Track', icon: 'disc', action: () => showToast('Master track view','info') }, - { label: 'Maker View', icon: 'layout', action: () => showToast('Maker view','info') }, - { label: 'Mixer', icon: 'sliders', action: () => showToast('Mixer panel','info') }, - { label: 'Tempo Track', icon: 'timer', action: () => showToast('Tempo track','info') }, - { label: 'Video', icon: 'film', action: () => showToast('Video panel','info') }, - { label: 'Media Explorer', icon: 'folder-search', action: () => showToast('Media explorer','info') }, - ]}, - { label: 'Tools', items: [ - { label: 'Config AI Providers...', icon: 'settings', action: () => setAiConfigModalOpen(true) }, - { label: 'Python DSP Tools Panel', icon: 'wrench', action: () => openPanel('python_tools') }, - ]}, - { label: 'Help', items: [ - { label: 'About SonicForge', icon: 'info', action: () => showToast('SonicForge Studio v1.0 - Professional DAW','info') }, - ]}, - ].map(menu => ( -
- - {menuOpen === menu.label && ( -
setMenuOpen(null)}> - {menu.items.map((item, i) => item.sep ? ( -
- ) : ( - - ))} -
- )} -
- ))} -
-
- Server: {serverStatus} - -
-
- {/* Close menu on outside click */} - {menuOpen &&
setMenuOpen(null)}>
} - - {/* ── Tab Bar (LOOP_EDITOR_2.md §1) ── */} -
- - {subTabs.map(st => ( -
- - -
- ))} -
- - {/* ── AI Config Drawer ── */} - {showAIConfig && ( -
-
- Cấu hình cổng kết nối API -
-
-
- Endpoint Base URL - setAiConfig(prev => ({ ...prev, baseUrl: e.target.value }))} - className="bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-[11px]" - placeholder="https://api.openai.com/v1" - /> -
-
- API Token Key - setAiConfig(prev => ({ ...prev, apiKey: e.target.value }))} - className="bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-[11px]" - placeholder="sk-..." - /> -
-
- Model Name - setAiConfig(prev => ({ ...prev, model: e.target.value }))} - className="bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-[11px]" - placeholder="gpt-4o-mini" - /> -
-
-
- )} - - {/* ── Transport Bar + Toolbar Overlay ── */} -
- {/* Toolbar Overlay Panel */} -
-
- -
- - -
- - -
- -
- - - -
- -
- - -
- -
- - - - - - - -
- - Snap - -
- Bars: - { - const b = parseInt(e.target.value) || 1; - setBeginBar(b); - const beatDuration = 60 / parseInt(bpm || 120); - const t = (b - 1) * beatDuration * 4; - clearLocalSelection(); - setSelectionMode('global'); - setSelectionStart(t); - setSelectionEnd(t + beatDuration * 4); - }} className="w-14 bg-black text-white text-[14px] 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 + beatDuration * 4); - setNumberBar(b - beginBar + 1); - }} className="w-14 bg-black text-white text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" /> - # - -
- Start: - { - const parts = e.target.value.split(/[:.]/); - if (parts.length === 3) { - const secs = parseInt(parts[0]) * 60 + parseInt(parts[1]) + parseFloat('0.' + (parts[2] || '0')); - clearLocalSelection(); - setSelectionMode('global'); - setSelectionStart(secs); - } - }} - className="w-20 bg-black text-zinc-200 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" /> - End: - { - const parts = e.target.value.split(/[:.]/); - if (parts.length === 3) { - const secs = parseInt(parts[0]) * 60 + parseInt(parts[1]) + parseFloat('0.' + (parts[2] || '0')); - setSelectionEnd(secs); - } - }} - className="w-20 bg-black text-zinc-200 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" /> - Len: - { - const parts = e.target.value.split(/[:.]/); - if (parts.length === 3 && selLeft !== null) { - const secs = parseInt(parts[0]) * 60 + parseInt(parts[1]) + parseFloat('0.' + (parts[2] || '0')); - setSelectionEnd(selLeft + secs); - } - }} - className="w-20 bg-black text-amber-300 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" /> -
-
{formatTime(currentTime)}
-
- - {/* ── Dock-Aware Workspace ── */} - {(() => { - const dockPanels = { top: [], right: [], bottom: [], left: [] }; - const addPanel = (id, pos, visible) => { if (visible) dockPanels[pos].push(id); }; - addPanel('export', panelPositions.export, showExportPanel); - addPanel('ai', panelPositions.ai, showAIPanel); - addPanel('python_tools', panelPositions.python_tools || 'bottom', showPythonToolsPanel); - addPanel('selection', panelPositions.selection, showSelectionPanel); - - const closePanel = (id) => { - if (id === 'export') setShowExportPanel(false); - else if (id === 'ai') setShowAIPanel(false); - else if (id === 'python_tools') setShowPythonToolsPanel(false); - else if (id === 'selection') setShowSelectionPanel(false); - }; - - const renderPanelContent = (panelId) => { - const h = (id) => (e) => { startPanelDrag(id, e); }; - if (panelId === 'export') return ( -
-
startPanelDrag('export', e)}> -

- - Export -

- -
-
-
- - -
-
- - -
-
- - -
-
- -
- ); - if (panelId === 'ai') return ( -
-
startPanelDrag('ai', e)}> -

- - AI -

- -
-
-
// {analysisState.status}
- {analysisState.data &&
BPM: {analysisState.data.bpm}
} -
-
- - - -
-
- ); - if (panelId === 'python_tools') return ( -
-
startPanelDrag('python_tools', e)}> -

- - Python DSP Tools -

- -
-
- // Non-AI Audio Processing Tools -
-
- - - - -
-
- ); - if (panelId === 'selection') return ( -
-
startPanelDrag('selection', e)}> - - Selection - - -
-
-
- Start - handleSelectionInputChange('start', e.target.value)} - className="w-full bg-[#242424] text-amber-400 text-center font-mono text-[9px] rounded py-0.5 border border-zinc-700 focus:outline-none" /> -
-
- End - handleSelectionInputChange('end', e.target.value)} - className="w-full bg-[#242424] text-amber-400 text-center font-mono text-[9px] rounded py-0.5 border border-zinc-700 focus:outline-none" /> -
-
- Len - {selectionStats.length}s -
-
-
-
- Begin Bar - { - const b = parseInt(e.target.value) || 1; - setBeginBar(b); - const beatDuration = 60 / parseInt(bpm || 120); - const t = (b - 1) * beatDuration * 4; - clearLocalSelection(); - setSelectionMode('global'); - setSelectionStart(t); - setSelectionEnd(t + beatDuration * 4); - }} className="w-full bg-[#242424] text-white text-center font-mono text-[9px] rounded py-0.5 border border-zinc-700 focus:outline-none" /> -
-
- End Bar - { - const b = parseInt(e.target.value) || 1; - setEndBar(b); - const beatDuration = 60 / parseInt(bpm || 120); - const t = (b - 1) * beatDuration * 4; - setSelectionEnd(t + beatDuration * 4); - setNumberBar(b - beginBar + 1); - }} className="w-full bg-[#242424] text-white text-center font-mono text-[9px] rounded py-0.5 border border-zinc-700 focus:outline-none" /> -
-
- # Bars - {numberBar} -
-
-
- ); - return null; - }; - - const renderDock = (pos, title) => { - const panels = dockPanels[pos]; - if (panels.length === 0) return null; - const isSide = pos === 'left' || pos === 'right'; - const sideClass = isSide ? 'w-56 shrink-0 overflow-y-auto border-zinc-800' : 'shrink-0'; - const borderClass = pos === 'left' ? 'border-r' : pos === 'right' ? 'border-l' : pos === 'top' ? 'border-b' : 'border-t'; - const bgClass = 'bg-[#1e1e1e]'; - const highlight = panelDragRef.current && panelDropZone === pos; - return ( -
- {panels.map(p => ( -
- {renderPanelContent(p)} -
- ))} -
- ); - }; - - return ( -
- {panelDragRef.current && panelDropZone && ( -
- {panelDropZone === 'top' &&
} - {panelDropZone === 'bottom' &&
} - {panelDropZone === 'left' &&
} - {panelDropZone === 'right' &&
} -
- )} - {/* Drag Ghost */} - {dragGhostPanel && dragGhostPos && ( -
-
- - {dragGhostPanel === 'export' ? 'Export Panel' : dragGhostPanel === 'ai' ? 'AI Panel' : dragGhostPanel === 'python_tools' ? 'Audio Processing Panel' : 'Selection Panel'} -
-
Drop at edge to dock
-
- )} - {renderDock('top', 'Top')} -
- {renderDock('left', 'Left')} -
-
- {activeTab === 'main' ? ( - <> - {/* ══ LEFT COLUMN: TCP PANEL (main session, all tracks) ══ */} -
-
- - - TRACKS ({tracks.length}) - - -
-
-
-
- TM - Tempo -
-
- setBpm(e.target.value)} - onBlur={() => localStorage.setItem('studio_bpm', bpm)} - className="w-12 bg-zinc-800 border border-zinc-700 rounded text-[10px] text-zinc-300 text-center font-mono focus:outline-none focus:border-purple-500" min="40" max="300" /> - BPM -
-
-
-
- {tracks.length === 0 ? ( -
- -

Chưa có Track nào trong dự án.

- -
- ) : ( - tracks.map((track, idx) => { - const isSelected = selectedTrackId === track.id; - return ( -
setSelectedTrackId(track.id)}> -
-
- {(idx+1).toString().padStart(2, '0')} -
-
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()}> - 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()} /> -
- ); - }) - )} -
-
-
- {/* ══ RIGHT COLUMN: TIMELINE (main session, all tracks) ══ */} -
-
-
-
- {Array.from({ length: Math.ceil(maxDuration) }).map((_, i) => { - const sec = i; const x = sec * zoom; - return (
{formatTimeSimple(sec)}
); - })} -
-
- {/* Global selection overlay for main session */} - {selectionMode === 'global' && selLeft !== null && selRight !== null && selRight > selLeft && ( -
-
-
- )} -
-
- -
-
-
- {tracks.map((track, idx) => { - const isSelected = selectedTrackId === track.id; - return ( -
e.preventDefault()} - onDrop={e => { e.preventDefault(); if (e.dataTransfer.files[0]) loadFileOnTrack(track.id, e.dataTransfer.files[0]); }} - onMouseEnter={() => setHoveredTrackId(track.id)}> - localSelectionAnchorRef.current} - onClearLocalSelection={clearLocalSelection} - onSetSelectionMode={setSelectionMode} - onSetSelectionStart={setSelectionStart} - onSetSelectionEnd={setSelectionEnd} - onSetCurrentTime={setCurrentTime} - onSetLocalSelectionTrackId={setLocalSelectionTrackId} - onSetLocalSelectionStart={setLocalSelectionStart} - onSetLocalSelectionEnd={setLocalSelectionEnd} - localSelLeft={localSelectionStart !== null && localSelectionEnd !== null ? Math.min(localSelectionStart, localSelectionEnd) : null} - localSelRight={localSelectionStart !== null && localSelectionEnd !== null ? Math.max(localSelectionStart, localSelectionEnd) : null} /> - {selectionMode === 'local' && localSelectionTrackId === track.id && - localSelectionStart !== null && localSelectionEnd !== null && Math.abs(localSelectionEnd - localSelectionStart) > 0 && ( -
-
handleHandleDragStart(e, 'left')}>
-
handleHandleDragStart(e, 'right')}>
-
- )} - {track.buffer && ( -
- -
- )} -
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()} /> -
- ); - })} -
{ if (draggedClipRef.current) { setHoveredTrackId(addNewTrack()); } }} - onClick={addNewTrack}> - Kéo clip xuống hoặc Click tạo Track -
- {selectionMode === 'global' && selLeft !== null && selRight !== null && selRight > selLeft && ( -
-
handleHandleDragStart(e, 'left')}>
-
handleHandleDragStart(e, 'right')}>
-
- )} -
-
-
-
-
-
- - ) : (() => { - 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) ══ */} -
-
- Sub-Tab - -
- {vTrack ? ( -
-
-
-
-
-
- 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'} -
-
- - - Loop: - setSubTabs(prev => prev.map(s => s.id === st.id ? {...s, loopCount: Math.max(0, parseInt(e.target.value) || 0)} : s))} className="w-12 bg-black text-amber-300 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" title="Loop count" /> -
-
-
- {/* Normalize Column */} -
- Norm - setSubTabNormVal(parseFloat(e.target.value))} style={{ writingMode: 'vertical-lr', direction: 'rtl', height: '120px', width: '20px', accentColor: '#a1a1aa' }} className="my-2 cursor-pointer" /> - {subTabNormVal}dB - -
- {/* Pitch Shift Column */} -
- Pitch - setSubTabPitchVal(parseFloat(e.target.value))} style={{ writingMode: 'vertical-lr', direction: 'rtl', height: '120px', width: '20px', accentColor: '#a1a1aa' }} className="my-2 cursor-pointer" /> - {subTabPitchVal > 0 ? '+' : ''}{subTabPitchVal}st - -
- {/* Gain Column */} -
- Gain - setSubTabGainVal(parseInt(e.target.value))} style={{ writingMode: 'vertical-lr', direction: 'rtl', height: '120px', width: '20px', accentColor: '#a1a1aa' }} className="my-2 cursor-pointer" /> - {subTabGainVal}% - -
-
-
-
-
- Duration: - {st.buffer ? formatTime(st.buffer.duration) : '0s'} -
-
- SR: - {st.buffer ? st.buffer.sampleRate : 0} Hz -
-
- - - -
- - -
-
- ) : ( -
Track not found
- )} -
- {/* ══ TIMELINE (sub-tab, single track) ══ */} -
-
-
-
- {Array.from({ length: Math.ceil(st.buffer ? st.buffer.duration : 0) }).map((_, i) => { - const sec = i; const x = sec * zoom; - return (
{formatTimeSimple(sec)}
); - })} -
-
-
-
- -
-
-
- {vTrack && ( -
- 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} - color={vTrack.color} - name={vTrack.name} - speed={st.speed || 1.0} - volumeNodes={st.volumeNodes || []} - panningNodes={st.panningNodes || []} - fadeInLen={st.fadeInLen || 0} - fadeOutLen={st.fadeOutLen || 0} - graphMode={st.graphMode} - channelInfo={st.channelInfo} - selectedNodeTime={subTabSelectedNodeTime} - setSelectedNodeTime={setSubTabSelectedNodeTime} - onUpdateNodes={(nodes) => setSubTabs(prev => prev.map(s => s.id === st.id ? {...s, [s.graphMode === 'pan' ? 'panningNodes' : 'volumeNodes']: nodes} : s))} - onUpdateFade={(fade) => setSubTabs(prev => prev.map(s => s.id === st.id ? {...s, fadeInLen: fade.fadeInLen ?? s.fadeInLen, fadeOutLen: fade.fadeOutLen ?? s.fadeOutLen} : s))} - onModeToggle={() => setSubTabs(prev => prev.map(s => s.id === st.id ? {...s, graphMode: s.graphMode === 'pan' ? null : 'pan'} : s))} - onSpeedChange={(newSpeed) => { - setSubTabs(prev => prev.map(s => { - if (s.id !== st.id) return s; - const oldSpeed = s.speed || 1.0; - const ratio = oldSpeed / newSpeed; - const newVolumeNodes = (s.volumeNodes || []).map(n => ({ - ...n, - time: n.time * ratio - })); - const newPanningNodes = (s.panningNodes || []).map(n => ({ - ...n, - time: n.time * ratio - })); - return { - ...s, - speed: newSpeed, - volumeNodes: newVolumeNodes, - panningNodes: newPanningNodes, - fadeInLen: (s.fadeInLen || 0) * ratio, - fadeOutLen: (s.fadeOutLen || 0) * ratio, - currentTime: (s.currentTime || 0) * ratio, - label: s.label.replace(/\s\(\d+%\)$/, '') + ` (${Math.round(newSpeed * 100)}%)` - }; - })); - const n = activeTrackNodesRef.current[st.trackId]; - if (n && n.source) n.source.playbackRate.value = newSpeed; - // Reset time refs to prevent playhead jump when speed changes mid-playback - const ctx = getAudioContext(); - const elapsed = ctx.currentTime - startAudioTimeRef.current; - const oldSpeed = activePlaybackSpeedRef.current; - const ratio = oldSpeed / newSpeed; - startBufferOffsetRef.current = startBufferOffsetRef.current + elapsed * oldSpeed; - startOffsetTimeRef.current = (startOffsetTimeRef.current + elapsed) * ratio; - startAudioTimeRef.current = ctx.currentTime; - activePlaybackSpeedRef.current = newSpeed;}} - /> -
-
- )} - {st.selectionStart !== null && st.selectionEnd !== null && st.selectionEnd > st.selectionStart && ( -
- )} -
-
-
- - ); - })()} -
-
- {renderDock('right', 'Right')} -
- {renderDock('bottom', 'Bottom')} -
- ); - })()} - - {/* ── Status Bar ── */} -
-
- Status: {isPlaying ? 'Playing' : 'Stopped'} - {activeTab !== 'main' && Sub-Tab} - {activeTab === 'main' && Track: ID {selectedTrackId}} - {selectionMode === 'local' && Local Sel} - {selectionMode === 'global' && Global Sel} - {soloedTrackId && Solo: ID {soloedTrackId}} - {isLoopingSelection && selectionMode === 'local' && Solo Loop} - {isLoopingSelection && selectionMode !== 'local' && Master Loop} -
-
- - - - - - Scroll: Zoom - - | - - Ctrl+Scroll: Playhead - -
-
- - {/* ── Context Menu ── */} - {contextMenu && ( - contextMenu.isSubTab ? ( -
e.stopPropagation()}> -
- Selection: {formatTime(subTabs.find(s => s.id === contextMenu.subTabId)?.selectionStart || 0)} - {formatTime(subTabs.find(s => s.id === contextMenu.subTabId)?.selectionEnd || 0)} -
-
- - - - -
- -
- ) : ( -
e.stopPropagation()}> - - - -
- - - -
- -
- ) - )} - - {/* ── Toast ── */} - {toastMessage && ( -
- - {toastMessage.text} -
- )} - {/* ── Auth & User Modals ── */} - setAuthModalOpen(false)} - onSuccess={handleAuthSuccess} - /> - setProfileModalOpen(false)} - /> - setAiConfigModalOpen(false)} /> - setSystemManagerModalOpen(false)} - /> -
- ); + } + return c; + }); + return { + ...t, + clips: updatedClips, + buffer: updatedClips[0]?.buffer, + startTime: updatedClips[0]?.startTime || 0, + name: updatedClips[0]?.name || t.name }; + })); + } else { + // Crossfade merge into original track (§2.2) + const sr = track.buffer.sampleRate; + const origData = track.buffer.getChannelData(0); + const trackStart = track.startTime || 0; + const startSample = Math.floor((subTab.startTime - trackStart) * sr); + const endSample = Math.floor((subTab.endTime - trackStart) * sr); + const crossfadeLen = Math.min(100, Math.floor(0.01 * sr)); // 10ms - const root = ReactDOM.createRoot(document.getElementById('root')); - root.render(); - setTimeout(() => lucide.createIcons(), 300); - \ No newline at end of file + const mergedBuffer = ctx.createBuffer(1, track.buffer.length, sr); + const mergedData = mergedBuffer.getChannelData(0); + for (let i = 0; i < startSample; i++) mergedData[i] = origData[i]; + for (let i = endSample; i < track.buffer.length; i++) mergedData[i] = origData[i]; + for (let i = 0; i < resultData.length; i++) { + const globalIdx = startSample + i; + let val = resultData[i]; + if (i < crossfadeLen) { + const alpha = i / crossfadeLen; + val = (1 - alpha) * (origData[globalIdx] || 0) + alpha * resultData[i]; + } else if (i > resultData.length - crossfadeLen) { + const distFromEnd = resultData.length - 1 - i; + const alpha = distFromEnd / crossfadeLen; + const origEndIdx = endSample - (resultData.length - i); + val = alpha * (origEndIdx >= 0 ? origData[origEndIdx] : 0) + (1 - alpha) * resultData[i]; + } + mergedData[globalIdx] = val; + } + setTracks(prev => prev.map(t => { + if (t.id !== subTab.trackId) return t; + return { + ...t, + buffer: mergedBuffer, + name: t.name + ' (edited)' + }; + })); + } + const afterSnap = captureTrackSnapshot(subTab.trackId); + pushAction('EDIT_TAB', subTab.trackId, beforeSnap, afterSnap); + + // Tab Lifetime: Engaging the Apply trigger propagates data back to the primary environment but does not close down the active sub-tab view. + // closeSubTab(tabId); + showToast('Đã áp dụng chỉnh sửa vào track chính.', 'success'); + }; + const applySubTabEffect = (tabId, effectType, value) => { + const subTab = subTabs.find(s => s.id === tabId); + if (!subTab || !subTab.buffer) return; + const ctx = getAudioContext(); + const sr = subTab.buffer.sampleRate; + const eff = subTab.buffer.getChannelData(0); + + // Clone buffer + let resultBuffer = ctx.createBuffer(1, eff.length, sr); + let resultData = resultBuffer.getChannelData(0); + resultData.set(eff); + + // Calculate selection start/end in unstretched sample indices + const speed = subTab.speed || 1.0; + const hasSelection = subTab.selectionStart !== null && subTab.selectionEnd !== null && subTab.selectionStart !== subTab.selectionEnd; + const tStart = hasSelection ? Math.min(subTab.selectionStart, subTab.selectionEnd) * speed : 0; + const tEnd = hasSelection ? Math.max(subTab.selectionStart, subTab.selectionEnd) * speed : subTab.buffer.duration; + const startSample = Math.max(0, Math.min(eff.length, Math.floor(tStart * sr))); + const endSample = Math.max(0, Math.min(eff.length, Math.floor(tEnd * sr))); + if (effectType === 'normalize') { + let maxVal = 0; + for (let i = startSample; i < endSample; i++) { + const abs = Math.abs(resultData[i]); + if (abs > maxVal) maxVal = abs; + } + if (maxVal > 0) { + const targetAmp = Math.pow(10, value / 20); + const scale = targetAmp / maxVal; + for (let i = startSample; i < endSample; i++) { + resultData[i] = Math.max(-1, Math.min(1, resultData[i] * scale)); + } + } + } else if (effectType === 'gain') { + const gainScale = value / 100; + for (let i = startSample; i < endSample; i++) { + resultData[i] = Math.max(-1, Math.min(1, resultData[i] * gainScale)); + } + } else if (effectType === 'pitch') { + const ratio = Math.pow(2, value / 12); + const applyResample = (data, r) => { + const newLen = Math.round(data.length * r); + const out = new Float32Array(newLen); + for (let i = 0; i < newLen; i++) { + const srcIdx = i / r; + const idx0 = Math.floor(srcIdx); + const idx1 = Math.min(idx0 + 1, data.length - 1); + const frac = srcIdx - idx0; + out[i] = data[idx0] * (1 - frac) + data[idx1] * frac; + } + return out; + }; + const sub = resultData.slice(startSample, endSample); + const subResampled = applyResample(sub, 1 / ratio); + for (let i = 0; i < endSample - startSample; i++) { + resultData[startSample + i] = i < subResampled.length ? subResampled[i] : 0.0; + } + } + + // Update subTab buffer state + setSubTabs(prev => prev.map(s => { + if (s.id !== tabId) return s; + return { + ...s, + buffer: resultBuffer, + selectionStart: null, + selectionEnd: null + }; + })); + showToast(`Đã áp dụng ${effectType === 'normalize' ? 'Normalize' : effectType === 'gain' ? 'Gain' : 'Pitch'} cho ${hasSelection ? 'vùng chọn' : 'toàn bộ clip'}.`, 'success'); + }; + const exportSubTabBuffer = async tabId => { + const subTab = subTabs.find(s => s.id === tabId); + if (!subTab || !subTab.buffer) return; + try { + const buffer = subTab.buffer; + const sr = buffer.sampleRate; + const monoData = buffer.getChannelData(0); + const bufferLength = monoData.length; + const bitDepth = 16; + const bytesPerSample = 2; + const headerSize = 44; + const fileSizeBytes = headerSize + bufferLength * bytesPerSample; + const fileBuffer = new ArrayBuffer(fileSizeBytes); + const view = new DataView(fileBuffer); + const writeString = (offset, string) => { + for (let i = 0; i < string.length; i++) { + view.setUint8(offset + i, string.charCodeAt(i)); + } + }; + writeString(0, 'RIFF'); + view.setUint32(4, fileSizeBytes - 8, true); + writeString(8, 'WAVE'); + writeString(12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, 1, true); + view.setUint32(24, sr, true); + view.setUint32(28, sr * bytesPerSample, true); + view.setUint16(32, bytesPerSample, true); + view.setUint16(34, bitDepth, true); + writeString(36, 'data'); + view.setUint32(40, bufferLength * bytesPerSample, true); + let offset = 44; + for (let i = 0; i < bufferLength; i++) { + const sample = Math.max(-1, Math.min(1, monoData[i])); + view.setInt16(offset, Math.floor(sample < 0 ? sample * 0x8000 : sample * 0x7FFF), true); + offset += bytesPerSample; + } + const blob = new Blob([view], { + type: 'audio/wav' + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${subTab.label || 'subtab-export'}.wav`; + a.click(); + URL.revokeObjectURL(url); + showToast("Xuất bản sub-tab hoàn tất!", "success"); + } catch (err) { + showToast("Lỗi xuất âm thanh: " + err.message, "error"); + } + }; + const closeSubTab = tabId => { + setSubTabs(prev => prev.filter(s => s.id !== tabId)); + if (activeTab === tabId) setActiveTab('main'); + }; + const updateSubTabEffects = (tabId, effects) => { + setSubTabs(prev => prev.map(s => s.id === tabId ? { + ...s, + effects: { + ...s.effects, + ...effects + } + } : s)); + }; + const handleSubTabNormalizeWithValue = (tabId, normalizeDb) => { + updateSubTabEffects(tabId, { + normalizeDb + }); + }; + const handleSubTabGainWithValue = (tabId, gainDb) => { + updateSubTabEffects(tabId, { + gainDb + }); + }; + const handleSubTabPitch = (tabId, pitch) => { + updateSubTabEffects(tabId, { + pitch + }); + }; + const handleSubTabStretch = (tabId, speedStretch) => { + updateSubTabEffects(tabId, { + speedStretch + }); + }; + const handleSubTabFadeState = (tabId, type) => { + const st = subTabs.find(s => s.id === tabId); + if (!st) return; + const fx = st.effects || {}; + if (type === 'in') { + updateSubTabEffects(tabId, { + fadeInMs: (fx.fadeInMs || 0) + 10 + }); + } else if (type === 'out') { + updateSubTabEffects(tabId, { + fadeOutMs: (fx.fadeOutMs || 0) + 10 + }); + } + }; + + // ── Context Menu Handlers ── + const handleContextMenu = (e, trackId, clickTime) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ + x: e.clientX, + y: e.clientY, + trackId, + time: clickTime || currentTime + }); + }; + const closeContextMenu = () => setContextMenu(null); + + // Close context menu on any click outside + useEffect(() => { + const handler = () => { + if (contextMenu) closeContextMenu(); + }; + if (contextMenu) { + window.addEventListener('click', handler); + return () => window.removeEventListener('click', handler); + } + }, [contextMenu]); + const contextMenuEdit = () => { + const track = tracks.find(t => t.id === contextMenu.trackId); + if (track) setSelectedTrackId(contextMenu.trackId); + closeContextMenu(); + const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ + id: 'default', + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name, + speed: track.speed || 1.0 + }] : []; + const clickedClip = clips.find(c => contextMenu.time >= c.startTime && contextMenu.time < c.startTime + c.buffer.duration / (c.speed || 1.0)); + if (clickedClip) { + handleEditClipInSubTab(contextMenu.trackId, clickedClip.id); + } else { + openTempTab(); + } + }; + const contextMenuSplit = () => { + closeContextMenu(); + handleSplitTrack(contextMenu.trackId); + }; + const contextMenuDelete = () => { + const tid = contextMenu.trackId; + const beforeSnap = captureTrackSnapshot(tid); + setTracks(prev => prev.filter(t => t.id !== tid)); + const afterSnap = captureTrackSnapshot(tid); + pushAction('DELETE', tid, beforeSnap, afterSnap); + if (selectedTrackId === tid) { + setSelectedTrackId(tracks.filter(t => t.id !== tid)[0]?.id || '1'); + } + closeContextMenu(); + showToast('Đã xoá track.', 'info'); + }; + const contextMenuCopy = () => { + const track = tracks.find(t => t.id === contextMenu.trackId); + if (!track || !track.buffer) return; + const sr = track.buffer.sampleRate; + const data = track.buffer.getChannelData(0); + + // Copy selected region if selection exists + if (selLeft !== null && selRight !== null && selRight > selLeft) { + const trackStart = track.startTime || 0; + const relSelLeft = Math.max(0, selLeft - trackStart); + const relSelRight = Math.max(0, selRight - trackStart); + const startSample = Math.floor(relSelLeft * sr); + const endSample = Math.min(data.length, Math.floor(relSelRight * sr)); + const len = endSample - startSample; + if (len > 0) { + const ctx = getAudioContext(); + const clipBuffer = ctx.createBuffer(1, len, sr); + clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); + 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; + } + } + + // No selection: copy entire track + clipboardRef.current = { + buffer: track.buffer, + name: track.name, + volumeDb: track.volumeDb, + pan: track.pan, + color: track.color + }; + closeContextMenu(); + showToast('Đã sao chép toàn bộ track.', 'info'); + }; + const contextMenuCut = () => { + const t = tracks.find(x => x.id === contextMenu.trackId); + if (!t || !t.buffer) { + contextMenuDelete(); + return; + } + const beforeSnap = captureTrackSnapshot(contextMenu.trackId); + const sr = t.buffer.sampleRate; + const data = t.buffer.getChannelData(0); + if (selLeft !== null && selRight !== null && selRight > selLeft) { + const trackStart = t.startTime || 0; + const relSelLeft = Math.max(0, selLeft - trackStart); + const relSelRight = Math.max(0, selRight - trackStart); + const startSample = Math.floor(relSelLeft * sr); + const endSample = Math.min(data.length, Math.floor(relSelRight * sr)); + const len = endSample - startSample; + if (len > 0) { + const ctx = getAudioContext(); + const clipBuffer = ctx.createBuffer(1, len, sr); + clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); + 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); + let idx = 0; + for (let i = 0; i < startSample; i++) newData[idx++] = data[i]; + for (let i = endSample; i < data.length; i++) newData[idx++] = data[i]; + setTracks(p => p.map(tr => tr.id === contextMenu.trackId ? { + ...tr, + buffer: newBuffer + } : tr)); + const afterSnap = captureTrackSnapshot(contextMenu.trackId); + pushAction('CUT', contextMenu.trackId, beforeSnap, afterSnap); + closeContextMenu(); + showToast('Đã cắt vùng chọn vào clipboard.', 'info'); + return; + } + } + contextMenuCopy(); + contextMenuDelete(); + }; + const doPaste = (targetTrackId, pasteTime) => { + if (!clipboardRef.current || !clipboardRef.current.buffer) { + showToast('Clipboard trống.', 'warning'); + return null; + } + const { + buffer: clipBuffer, + name, + volumeDb, + pan, + color + } = clipboardRef.current; + const ctx = getAudioContext(); + const targetTrack = tracks.find(t => t.id === targetTrackId); + const newClip = { + id: 'clip_' + Date.now() + '_' + Math.floor(Math.random() * 1000), + startTime: pasteTime, + buffer: clipBuffer, + name: name || 'Pasted Clip' + }; + if (targetTrack) { + setTracks(p => p.map(t => { + if (t.id === targetTrackId) { + const existingClips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ + id: 'default_' + t.id, + buffer: t.buffer, + startTime: t.startTime || 0, + name: t.name + }] : []; + const updatedClips = [...existingClips, newClip]; + return { + ...t, + clips: updatedClips, + buffer: updatedClips[0].buffer, + startTime: updatedClips[0].startTime, + name: name || t.name, + volumeDb: volumeDb ?? t.volumeDb, + pan: pan ?? t.pan, + color: color || t.color + }; + } + return t; + })); + setCurrentTime(pasteTime); + showToast('Đã dán clip vào track.', 'success'); + return targetTrackId; + } + + // No matching track — create a new one + const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; + const newId = 'track_pasted_' + Date.now(); + setTracks(prev => [...prev, { + id: newId, + name: `Pasted_${name || 'track'}`, + buffer: clipBuffer, + startTime: pasteTime, + clips: [newClip], + volumeDb: volumeDb ?? 0, + pan: pan ?? 0, + muted: false, + solo: false, + color: color || colors[prev.length % colors.length], + markers: [], + serverFileId: null + }]); + setSelectedTrackId(newId); + setCurrentTime(pasteTime); + showToast('Đã dán track mới từ clipboard.', 'success'); + return newId; + }; + const handlePasteTrack = () => doPaste(selectedTrackId, currentTime); + const contextMenuPaste = () => { + const result = doPaste(contextMenu.trackId, contextMenu.time || currentTime); + closeContextMenu(); + }; + const contextMenuMerge = () => { + const activeTracks = tracks.filter(t => t.buffer && !t.muted); + if (activeTracks.length < 2) { + showToast('Cần ít nhất 2 track có dữ liệu để merge.', 'warning'); + closeContextMenu(); + return; + } + const ctx = getAudioContext(); + const maxDur = Math.max(...activeTracks.map(t => (t.startTime || 0) + t.buffer.duration)); + const sr = activeTracks[0].buffer.sampleRate; + const merged = ctx.createBuffer(1, Math.ceil(maxDur * sr), sr); + const mergedData = merged.getChannelData(0); + activeTracks.forEach(t => { + const data = t.buffer.getChannelData(0); + const startSample = Math.floor((t.startTime || 0) * sr); + 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] * volLinear; + } + } + }); + let maxPeak = 0; + for (let i = 0; i < mergedData.length; i++) { + const abs = Math.abs(mergedData[i]); + if (abs > maxPeak) maxPeak = abs; + } + if (maxPeak > 1.0) { + for (let i = 0; i < mergedData.length; i++) mergedData[i] /= maxPeak; + } + const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; + const newId = 'track_merged_' + Date.now(); + const names = activeTracks.map(t => t.name).join('+').slice(0, 30); + setTracks(prev => [...prev, { + id: newId, + name: `Merged_${names}.wav`, + buffer: merged, + startTime: 0, + volumeDb: 0, + pan: 0, + muted: false, + solo: false, + color: colors[prev.length % colors.length], + markers: [], + serverFileId: null + }]); + setSelectedTrackId(newId); + closeContextMenu(); + showToast(`Đã merge ${activeTracks.length} tracks.`, 'success'); + }; + + // Menu bar direct handlers (don't rely on contextMenu state) + const handleMergeTracks = () => { + const at = tracks.filter(t => t.buffer && !t.muted); + if (at.length < 2) { + showToast('Cần 2+ tracks để merge.', 'warning'); + return; + } + const actx = getAudioContext(); + const maxDur = Math.max(...at.map(t => (t.startTime || 0) + t.buffer.duration)); + const sr = at[0].buffer.sampleRate; + const mb = actx.createBuffer(1, Math.ceil(maxDur * sr), sr); + const mdata = mb.getChannelData(0); + at.forEach(t => { + const d = t.buffer.getChannelData(0); + 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] * volLinear; + } + } + }); + let mp = 0; + for (let i = 0; i < mdata.length; i++) { + const a = Math.abs(mdata[i]); + if (a > mp) mp = a; + } + if (mp > 1.0) for (let i = 0; i < mdata.length; i++) mdata[i] /= mp; + setTracks(p => [...p, { + id: 'merged_' + Date.now(), + name: 'Merged_mix.wav', + buffer: mb, + startTime: 0, + height: 128, + 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 = () => { + const t = tracks.find(x => x.id === selectedTrackId); + if (!t || !t.buffer) return; + const sr = t.buffer.sampleRate; + const data = t.buffer.getChannelData(0); + // If selection exists, copy only the selected region + if (selLeft !== null && selRight !== null && selRight > selLeft) { + const trackStart = t.startTime || 0; + const relSelLeft = Math.max(0, selLeft - trackStart); + const relSelRight = Math.max(0, selRight - trackStart); + const startSample = Math.floor(relSelLeft * sr); + const endSample = Math.min(data.length, Math.floor(relSelRight * sr)); + const len = endSample - startSample; + if (len > 0) { + const ctx = getAudioContext(); + const clipBuffer = ctx.createBuffer(1, len, sr); + clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); + 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, + volumeDb: t.volumeDb, + color: t.color + }; + showToast('Copied track to clipboard.', 'info'); + }; + const handleCutTrack = () => { + const t = tracks.find(x => x.id === selectedTrackId); + if (!t || !t.buffer) return; + const beforeSnap = captureTrackSnapshot(selectedTrackId); + const sr = t.buffer.sampleRate; + const data = t.buffer.getChannelData(0); + // If selection exists, cut only the selected region + if (selLeft !== null && selRight !== null && selRight > selLeft) { + const trackStart = t.startTime || 0; + const relSelLeft = Math.max(0, selLeft - trackStart); + const relSelRight = Math.max(0, selRight - trackStart); + const startSample = Math.floor(relSelLeft * sr); + const endSample = Math.min(data.length, Math.floor(relSelRight * sr)); + const len = endSample - startSample; + if (len > 0) { + // Copy selection to clipboard + const ctx = getAudioContext(); + const clipBuffer = ctx.createBuffer(1, len, sr); + clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); + 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); + const newData = newBuffer.getChannelData(0); + let idx = 0; + for (let i = 0; i < startSample; i++) newData[idx++] = data[i]; + for (let i = endSample; i < data.length; i++) newData[idx++] = data[i]; + setTracks(p => p.map(tr => tr.id === selectedTrackId ? { + ...tr, + buffer: newBuffer + } : tr)); + const afterSnap = captureTrackSnapshot(selectedTrackId); + pushAction('CUT', selectedTrackId, beforeSnap, afterSnap); + showToast('Cut selection to clipboard.', 'info'); + return; + } + } + // No selection: cut entire track (copy + delete) + handleCopyTrack(); + handleDeleteTrack(); + }; + const handleDeleteTrack = () => { + const tid = selectedTrackId; + setTracks(p => p.filter(t => t.id !== tid)); + setSelectedTrackId(tracks.filter(t => t.id !== tid)[0]?.id || '1'); + showToast('Deleted track.', 'info'); + }; + + // ── Server Health Check ── + const [viewportWidth, setViewportWidth] = useState(1200); + useEffect(() => { + const wrapper = timelineWrapperRef.current; + if (!wrapper) return; + const observer = new ResizeObserver(entries => { + for (let entry of entries) { + setViewportWidth(entry.contentRect.width); + } + }); + observer.observe(wrapper); + return () => observer.disconnect(); + }, []); + useEffect(() => { + fetch(API_BASE_URL).then(r => { + if (r.ok) setServerStatus('connected');else setServerStatus('error'); + }).catch(() => setServerStatus('offline')); + }, []); + + // ── Computed Values ── + const maxDuration = useMemo(() => { + let max = 10; + tracks.forEach(t => { + const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ + id: 'default', + buffer: t.buffer, + startTime: t.startTime || 0, + name: t.name, + speed: t.speed || 1.0 + }] : []; + clips.forEach(c => { + if (c.buffer) { + const cStart = c.startTime || 0; + const cDur = c.buffer.duration / (c.speed || 1.0); + max = Math.max(max, cStart + cDur); + } + }); + }); + return max; + }, [tracks]); + const maxDurationRef = useRef(maxDuration); + maxDurationRef.current = maxDuration; + const minZoom = useMemo(() => { + return viewportWidth / maxDuration; + }, [viewportWidth, maxDuration]); + const timelineWidth = useMemo(() => { + return Math.max(zoom * maxDuration, viewportWidth); + }, [zoom, maxDuration, viewportWidth]); + useEffect(() => { + if (zoom < minZoom) { + setZoom(minZoom); + } + }, [minZoom]); + const playheadLeftPos = useMemo(() => currentTime * zoom, [currentTime, zoom]); + const selLeft = useMemo(() => { + if (selectionMode === 'local' && localSelectionStart !== null && localSelectionEnd !== null) { + return Math.min(localSelectionStart, localSelectionEnd); + } + if (selectionStart === null || selectionEnd === null) return null; + return Math.min(selectionStart, selectionEnd); + }, [selectionStart, selectionEnd, selectionMode, localSelectionStart, localSelectionEnd]); + const selRight = useMemo(() => { + if (selectionMode === 'local' && localSelectionStart !== null && localSelectionEnd !== null) { + return Math.max(localSelectionStart, localSelectionEnd); + } + if (selectionStart === null || selectionEnd === null) return null; + return Math.max(selectionStart, selectionEnd); + }, [selectionStart, selectionEnd, selectionMode, localSelectionStart, localSelectionEnd]); + + // ── Toast helper ── + const showToast = (text, type = 'info') => { + if (toastTimeoutRef.current) clearTimeout(toastTimeoutRef.current); + setToastMessage({ + text, + type + }); + toastTimeoutRef.current = setTimeout(() => setToastMessage(null), 3500); + }; + + // ── Server-side upload ── + const uploadToServer = async (file, trackId) => { + const formData = new FormData(); + formData.append('file', file); + try { + const resp = await fetch(`${API_AUDIO}/upload`, { + method: 'POST', + body: formData + }); + if (!resp.ok) throw new Error(`Upload failed: ${resp.status}`); + const data = await resp.json(); + serverFileIdMap[trackId] = data.file_id; + return data; + } catch (err) { + console.warn('Server upload failed, using client-side only:', err.message); + return null; + } + }; + + // ── Server-side waveform loading ── + const loadServerWaveform = async fileId => { + try { + const resp = await fetch(`${API_AUDIO}/waveform/${fileId}?num_peaks=800`); + if (!resp.ok) return null; + return await resp.json(); + } catch { + return null; + } + }; + + // ── Check Celery task result ── + const pollTaskResult = async (taskId, maxPoll = 10) => { + for (let i = 0; i < maxPoll; i++) { + await new Promise(r => setTimeout(r, 1500)); + try { + const resp = await fetch(`${API_TASKS}/${taskId}`); + if (!resp.ok) continue; + const data = await resp.json(); + if (data.status === 'SUCCESS') return data.result; + if (data.status === 'FAILURE') throw new Error(data.error || 'Task failed'); + } catch (err) { + throw err; + } + } + throw new Error('Task polling timeout'); + }; + + // ── Wheel Zoom ── + useEffect(() => { + const timeline = timelineWrapperRef.current; + if (!timeline) return; + const handleWheel = e => { + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + const rect = timeline.getBoundingClientRect(); + const mouseXInViewport = e.clientX - rect.left; + const mouseXInCanvas = mouseXInViewport + timeline.scrollLeft; + const anchorTime = mouseXInCanvas / zoom; + const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; + setZoom(prevZoom => { + let newZoom = prevZoom * zoomFactor; + if (newZoom < minZoom) newZoom = minZoom; + if (newZoom > 50000) newZoom = 50000; + const newMouseXInCanvas = anchorTime * newZoom; + requestAnimationFrame(() => { + timeline.scrollLeft = newMouseXInCanvas - mouseXInViewport; + }); + return newZoom; + }); + } else if (e.shiftKey) { + e.preventDefault(); + timeline.scrollLeft += e.deltaY; + } + }; + timeline.addEventListener('wheel', handleWheel, { + passive: false + }); + return () => timeline.removeEventListener('wheel', handleWheel); + }, [zoom, minZoom, maxDuration, activeTab]); + + // ── Update Playhead ── + const startSubTabPlayback = (st, offsetWallTime) => { + const context = getAudioContext(); + const speed = st.speed || 1.0; + const offsetBuffer = offsetWallTime * speed; + 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.playbackRate.value = speed; + activePlaybackSpeedRef.current = speed; + + // Graph Editor Automation Node Chain (15_GRAPH_EDIT.md §3) + // source → volumeGainNode → pannerNode → fadeGainNode → destination + + const volumeGainNode = context.createGain(); + volumeGainNode.gain.setValueAtTime(1.0, context.currentTime); + const pannerNode = context.createStereoPanner(); + pannerNode.pan.setValueAtTime(0.0, context.currentTime); + const fadeGainNode = context.createGain(); + fadeGainNode.gain.setValueAtTime(1.0, context.currentTime); + + // Schedule volume automation nodes + const volNodes = st.volumeNodes || []; + if (volNodes.length > 0) { + volumeGainNode.gain.cancelScheduledValues(context.currentTime); + volNodes.forEach((n, i) => { + const t = context.currentTime + n.time / speed; + const linearGain = Math.pow(10, n.db / 20); + if (i === 0) volumeGainNode.gain.setValueAtTime(linearGain, t);else volumeGainNode.gain.linearRampToValueAtTime(linearGain, t); + }); + } + + // Schedule panning automation nodes + const panNodes = st.panningNodes || []; + if (panNodes.length > 0) { + pannerNode.pan.cancelScheduledValues(context.currentTime); + panNodes.forEach((n, i) => { + const t = context.currentTime + n.time / speed; + const clamped = Math.max(-1, Math.min(1, n.pan)); + if (i === 0) pannerNode.pan.setValueAtTime(clamped, t);else pannerNode.pan.linearRampToValueAtTime(clamped, t); + }); + } + + // Schedule fade curves + const duration = st.buffer.duration; + const fIn = st.fadeInLen || 0; + const fOut = st.fadeOutLen || 0; + if (fIn > 0) { + fadeGainNode.gain.setValueAtTime(0.0, context.currentTime); + fadeGainNode.gain.linearRampToValueAtTime(1.0, context.currentTime + fIn / speed); + } + if (fOut > 0) { + const fadeOutStart = (duration - fOut) / speed; + fadeGainNode.gain.setValueAtTime(1.0, context.currentTime + Math.max(0, fadeOutStart)); + fadeGainNode.gain.linearRampToValueAtTime(0.0, context.currentTime + duration / speed); + } + source.connect(volumeGainNode); + volumeGainNode.connect(pannerNode); + pannerNode.connect(fadeGainNode); + fadeGainNode.connect(context.destination); + source.start(context.currentTime, offsetBuffer); + activeSourcesRef.current = [source]; + activeTrackNodesRef.current[st.trackId] = { + gainNode: volumeGainNode, + pannerNode, + source + }; + startOffsetTimeRef.current = offsetWallTime; + startBufferOffsetRef.current = offsetBuffer; + startAudioTimeRef.current = context.currentTime; + }; + const updatePlayhead = () => { + if (activeTabRef.current !== 'main') { + const st = subTabsRef.current.find(s => s.id === activeTabRef.current); + if (!st || !st.isPlaying || !st.buffer) return; + const context = getAudioContext(); + const speedFactor = st.speed || 1.0; + const elapsed = context.currentTime - startAudioTimeRef.current; + const wallTime = startOffsetTimeRef.current + elapsed; + const bufferPos = startBufferOffsetRef.current + elapsed * speedFactor; + + // Loop sub-tab selection (bufferPos is buffer-time) + if (st.selectionStart !== null && st.selectionEnd !== null && st.selectionStart !== st.selectionEnd) { + const start = Math.min(st.selectionStart, st.selectionEnd); + const end = Math.max(st.selectionStart, st.selectionEnd); + if (bufferPos >= end) { + stopAllPlayback(); + setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? { + ...s, + currentTime: wallTime, + isPlaying: true + } : s)); + startSubTabPlayback(st, wallTime); + animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); + return; + } + } + if (bufferPos >= st.buffer.duration) { + stopAllPlayback(); + if (isLoopingSelection) { + setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? { + ...s, + currentTime: 0, + isPlaying: true + } : s)); + startSubTabPlayback(st, 0); + animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); + } else { + setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? { + ...s, + currentTime: 0, + isPlaying: false + } : s)); + } + return; + } + setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? { + ...s, + currentTime: wallTime + } : s)); + animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); + return; + } + if (!isPlaying) return; + const context = getAudioContext(); + const elapsed = context.currentTime - startAudioTimeRef.current; + const updatedTime = startOffsetTimeRef.current + elapsed; + + // Selection Loop - LOOP_MAKER.md + LOOP_EDITOR_2.md §4.2 + // If selection cleared by user, play linearly (don't loop) + if (!selectionCleared && isLoopingSelection && selLeft !== null && selRight !== null) { + if (selRight > selLeft && updatedTime >= selRight) { + if (selectionMode === 'local') { + // Local Solo Loop: only restart the selected track + stopAllPlayback(); + startOffsetTimeRef.current = selLeft; + startAudioTimeRef.current = context.currentTime; + startLocalTrackPlayback(localSelectionTrackId, selLeft); + setCurrentTime(selLeft); + setIsPlaying(true); + } else { + // Global Master Loop: restart all tracks + stopAllPlayback(); + startOffsetTimeRef.current = selLeft; + startAudioTimeRef.current = context.currentTime; + startTrackPlayback(selLeft); + setCurrentTime(selLeft); + setIsPlaying(true); + } + animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); + return; + } + } + if (updatedTime >= maxDurationRef.current) { + stopAllPlayback(); + setCurrentTime(0); + return; + } + setCurrentTime(updatedTime); + animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); + }; + useEffect(() => { + if (isPlaying || subTabs.some(s => s.isPlaying)) { + animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); + } else { + cancelAnimationFrame(animationFrameIdRef.current); + } + return () => cancelAnimationFrame(animationFrameIdRef.current); + }, [isPlaying, subTabs, isLoopingSelection, selLeft, selRight, selectionMode, localSelectionTrackId, selectionCleared, activeTab]); + + // ── Playback ── + const startTrackPlayback = offsetTime => { + const context = getAudioContext(); + const hasSolo = tracks.some(t => t.solo) || soloedTrackId !== null; + tracks.forEach(track => { + const isPlayable = hasSolo ? track.id === soloedTrackId || track.solo : !track.muted; + 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, + startTime: track.startTime || 0, + name: track.name, + speed: track.speed || 1.0 + }] : []; + clips.forEach(clip => { + if (!clip.buffer) return; + const source = context.createBufferSource(); + source.buffer = clip.buffer; + source.playbackRate.value = clip.speed || 1.0; + source.connect(gainNode); + gainNode.connect(pannerNode); + const clipStart = clip.startTime || 0; + const clipDuration = clip.buffer.duration / (clip.speed || 1.0); + const clipEnd = clipStart + clipDuration; + if (offsetTime < clipStart) { + const delay = clipStart - offsetTime; + source.start(context.currentTime + delay, 0); + activeSourcesRef.current.push(source); + } else if (offsetTime < clipEnd) { + const playOffset = offsetTime - clipStart; + source.start(context.currentTime, playOffset * (clip.speed || 1.0)); + activeSourcesRef.current.push(source); + } + }); + }); + }; + + // Solo playback for Local Selection Loop (LOOP_MAKER.md §2.2) + const startLocalTrackPlayback = (trackId, offsetTime) => { + const context = getAudioContext(); + const track = tracks.find(t => t.id === trackId); + if (!track) return; + const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ + id: 'default', + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name, + 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; + const source = context.createBufferSource(); + source.buffer = clip.buffer; + source.playbackRate.value = clip.speed || 1.0; + source.connect(gainNode); + gainNode.connect(pannerNode); + const clipStart = clip.startTime || 0; + const clipDuration = clip.buffer.duration / (clip.speed || 1.0); + const clipEnd = clipStart + clipDuration; + if (offsetTime < clipStart) { + const delay = clipStart - offsetTime; + source.start(context.currentTime + delay, 0); + activeSourcesRef.current.push(source); + } else if (offsetTime < clipEnd) { + const playOffset = offsetTime - clipStart; + source.start(context.currentTime, playOffset * (clip.speed || 1.0)); + activeSourcesRef.current.push(source); + } + }); + }; + const handlePlayPause = () => { + if (activeTab !== 'main') { + // Sub-tab playback transport + const st = subTabs.find(s => s.id === activeTab); + if (!st || !st.buffer) return; + if (st.isPlaying) { + stopAllPlayback(); + setSubTabs(prev => prev.map(s => s.id === activeTab ? { + ...s, + isPlaying: false + } : s)); + } else { + stopAllPlayback(); + const startOffset = st.currentTime || 0; + startSubTabPlayback(st, startOffset); + setSubTabs(prev => prev.map(s => s.id === activeTab ? { + ...s, + isPlaying: true, + currentTime: startOffset + } : s)); + } + return; + } + const context = getAudioContext(); + if (isPlaying) { + stopAllPlayback(); + } else { + startOffsetTimeRef.current = currentTime; + startAudioTimeRef.current = context.currentTime; + startTrackPlayback(currentTime); + setIsPlaying(true); + } + }; + handlePlayPauseRef.current = handlePlayPause; + const handlePause = () => { + if (isPlaying || subTabs.some(s => s.isPlaying)) stopAllPlayback(); + }; + const stopAllPlayback = () => { + activeSourcesRef.current.forEach(src => { + try { + src.stop(); + } catch (e) {} + }); + activeSourcesRef.current = []; + activeTrackNodesRef.current = {}; + setIsPlaying(false); + setSubTabs(prev => prev.map(s => ({ + ...s, + isPlaying: false + }))); + }; + const handleStop = () => { + stopAllPlayback(); + if (activeTab !== 'main') { + setSubTabs(prev => prev.map(s => s.id === activeTab ? { + ...s, + currentTime: 0 + } : s)); + } else { + setCurrentTime(0); + } + }; + const handleSubTabResizeMouseDown = e => { + e.preventDefault(); + e.stopPropagation(); + const startY = e.clientY; + const startHeight = subTabHeight; + const handleMouseMove = moveEvent => { + const deltaY = moveEvent.clientY - startY; + const newHeight = Math.max(48, Math.min(400, startHeight + deltaY)); + setSubTabHeight(newHeight); + }; + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + + // ── Playhead set with seek+play ── + const handlePlayheadSet = (time, shiftKey) => { + localSelectionAnchorRef.current = time; + if (isPlaying) { + // Click during playback: seek to position and continue playing + setCurrentTime(time); + stopAllPlayback(); + setTimeout(() => { + startOffsetTimeRef.current = time; + startAudioTimeRef.current = getAudioContext().currentTime; + startTrackPlayback(time); + setIsPlaying(true); + }, 50); + } else { + // Normal click: just set playhead + setCurrentTime(time); + } + }; + const clearLocalSelection = () => { + setSelectionMode(null); + setLocalSelectionTrackId(null); + setLocalSelectionStart(null); + setLocalSelectionEnd(null); + }; + const handleRulerMouseDown = e => { + if (e.ctrlKey) { + e.preventDefault(); + e.stopPropagation(); + clearLocalSelection(); + setSelectionMode(null); + setSelectionStart(null); + setSelectionEnd(null); + return; + } + const wrapper = timelineWrapperRef.current; + if (!wrapper) return; + const rect = wrapper.getBoundingClientRect(); + const scrollLeft = wrapper.scrollLeft; + const mouseX = e.clientX - rect.left + scrollLeft; + const rawTime = mouseX / zoom; + const time = snapValue !== 'free' ? snapTime(rawTime, snapValue, bpm) : rawTime; + clearLocalSelection(); + setSelectionMode('global'); + rulerDragStartRef.current = time; + isDraggingRulerRef.current = true; + if (e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + // Shift+click on ruler: lock existing anchor (or currentTime fallback) and extend global selection + const anchor = rulerAnchorRef.current !== null && rulerAnchorRef.current !== undefined ? rulerAnchorRef.current : selectionStart !== null && selectionStart !== undefined ? selectionStart : currentTime; + const selS = Math.min(anchor, time); + const selE = Math.max(anchor, time); + setSelectionStart(selS); + setSelectionEnd(selE); + } else { + rulerAnchorRef.current = time; + setSelectionStart(time); + setSelectionEnd(time); + } + handlePlayheadSet(time); + }; + + // Global Ruler mousemove is tracked via document listener set up in useEffect + useEffect(() => { + const handleMouseMove = e => { + if (!isDraggingRulerRef.current) return; + const wrapper = timelineWrapperRef.current; + if (!wrapper) return; + const rect = wrapper.getBoundingClientRect(); + const scrollLeft = wrapper.scrollLeft; + const mouseX = e.clientX - rect.left + scrollLeft; + const rawTime = Math.max(0, Math.min(maxDuration, mouseX / zoom)); + const time = snapValueRef.current !== 'free' ? snapTime(rawTime, snapValueRef.current, bpmRef.current) : rawTime; + const anchor = rulerAnchorRef.current ?? rulerDragStartRef.current ?? time; + setSelectionStart(Math.min(anchor, time)); + setSelectionEnd(Math.max(anchor, time)); + }; + const handleMouseUp = () => { + if (isDraggingRulerRef.current) { + isDraggingRulerRef.current = false; + rulerDragStartRef.current = null; + } + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [zoom, maxDuration]); + + // ── Track Lane Local Selection Drag ── + const localDragInProgressRef = useRef(false); + const localDragTrackRef = useRef(null); + const localDragStartTimeRef = useRef(0); + const localSelectionAnchorRef = useRef(null); + const handleTrackLaneMouseDown = (trackId, time) => { + setSelectedTrackId(trackId); + clearLocalSelection(); + localSelectionAnchorRef.current = time; + setSelectionMode('local'); + setLocalSelectionTrackId(trackId); + setLocalSelectionStart(time); + setLocalSelectionEnd(time); + setSelectionStart(time); + setSelectionEnd(time); + localDragInProgressRef.current = true; + localDragTrackRef.current = trackId; + localDragStartTimeRef.current = time; + }; + + // Document-level mousemove/mouseup for local selection drag + useEffect(() => { + const handleMouseMove = e => { + if (!localDragInProgressRef.current) return; + const wrapper = timelineWrapperRef.current; + if (!wrapper) return; + const rect = wrapper.getBoundingClientRect(); + const scrollLeft = wrapper.scrollLeft; + const mouseX = e.clientX - rect.left + scrollLeft; + const time = Math.max(0, Math.min(maxDuration, mouseX / zoom)); + const anchor = localSelectionAnchorRef.current ?? localDragStartTimeRef.current; + const selS = Math.min(anchor, time); + const selE = Math.max(anchor, time); + setLocalSelectionStart(selS); + setLocalSelectionEnd(selE); + setSelectionStart(selS); + setSelectionEnd(selE); + }; + const handleMouseUp = () => { + if (localDragInProgressRef.current) { + localDragInProgressRef.current = false; + localDragTrackRef.current = null; + localDragStartTimeRef.current = 0; + } + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [zoom, maxDuration]); + const draggedClipRef = useRef(null); + draggedClipRef.current = draggedClip; + const hoveredTrackIdRef = useRef(null); + hoveredTrackIdRef.current = hoveredTrackId; + const captureTrackSnapshotRef = useRef(null); + captureTrackSnapshotRef.current = captureTrackSnapshot; + const handleClipDragStart = (trackId, clipId, clickOffset, isDuplicate = false) => { + const track = tracks.find(t => t.id === trackId); + if (!track) return; + const existingClips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ + id: 'default_' + track.id, + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name + }] : []; + const clip = existingClips.find(c => c.id === clipId || clipId === 'default' && c.id === 'default_' + track.id); + if (!clip) return; + const beforeSnap = captureTrackSnapshot(trackId); + let targetClipId = clip.id; + if (isDuplicate) { + const cloneId = clip.id + '_copy_' + Date.now(); + const clone = { + ...clip, + id: cloneId, + name: clip.name + ' (Copy)' + }; + setTracks(prev => prev.map(t => { + if (t.id === trackId) { + const newClips = [...existingClips, clone]; + return { + ...t, + clips: newClips, + buffer: newClips[0].buffer, + startTime: newClips[0].startTime, + name: newClips[0].name + }; + } + return t; + })); + targetClipId = cloneId; + } else { + if (!track.clips || track.clips.length === 0) { + setTracks(prev => prev.map(t => { + if (t.id === trackId) { + return { + ...t, + clips: existingClips + }; + } + return t; + })); + } + } + setDraggedClip({ + trackId: trackId, + clipId: targetClipId, + clickOffset: clickOffset, + buffer: clip.buffer, + name: clip.name, + beforeSnap: beforeSnap + }); + }; + const stretchedClipRef = useRef(null); + stretchedClipRef.current = stretchedClip; + const handleClipStretchStart = (trackId, clipId, clickTime) => { + const track = tracks.find(t => t.id === trackId); + if (!track) return; + const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ + id: 'default_' + track.id, + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name, + speed: track.speed || 1.0 + }] : []; + const clip = clips.find(c => c.id === clipId || clipId === 'default' && c.id === 'default_' + trackId); + if (!clip || !clip.buffer) return; + const beforeSnap = captureTrackSnapshot(trackId); + if (!track.clips || track.clips.length === 0) { + setTracks(prev => prev.map(t => t.id === trackId ? { + ...t, + clips + } : t)); + } + setStretchedClip({ + trackId, + clipId: clip.id === 'default' ? 'default_' + trackId : clip.id, + originalDuration: clip.buffer.duration, + startTime: clip.startTime, + originalSpeed: clip.speed || 1.0, + beforeSnap + }); + }; + const handleSelectionEdgeDragStart = (e, trackId, side) => { + const startX = e.clientX; + const initialLeft = Math.min(localSelectionStart, localSelectionEnd); + const initialRight = Math.max(localSelectionStart, localSelectionEnd); + const handleMouseMove = moveEvent => { + const deltaX = moveEvent.clientX - startX; + const deltaSec = deltaX / zoom; + if (side === 'left') { + const newLeft = Math.max(0, Math.min(initialRight - 0.05, initialLeft + deltaSec)); + setLocalSelectionStart(newLeft); + setLocalSelectionEnd(initialRight); + } else { + const newRight = Math.max(initialLeft + 0.05, Math.min(maxDuration, initialRight + deltaSec)); + setLocalSelectionStart(initialLeft); + setLocalSelectionEnd(newRight); + } + }; + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + const handleTrackResizeMouseDown = (e, trackId) => { + e.preventDefault(); + e.stopPropagation(); + const startY = e.clientY; + const track = tracks.find(t => t.id === trackId); + const startHeight = track ? track.height || 96 : 96; + const handleMouseMove = moveEvent => { + const deltaY = moveEvent.clientY - startY; + const newHeight = Math.max(110, Math.min(300, startHeight + deltaY)); + setTracks(prev => prev.map(t => t.id === trackId ? { + ...t, + height: newHeight + } : t)); + }; + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + const deleteTrack = trackId => { + const beforeSnap = captureTrackSnapshot(trackId); + setTracks(prev => { + const filtered = prev.filter(t => t.id !== trackId); + if (filtered.length > 0) { + setSelectedTrackId(filtered[0].id); + } + return filtered; + }); + showToast('Đã xóa track.', 'info'); + }; + useEffect(() => { + const handleMouseMove = e => { + const drag = draggedClipRef.current; + if (!drag) return; + const wrapper = timelineWrapperRef.current; + if (!wrapper) return; + const rect = wrapper.getBoundingClientRect(); + const scrollLeft = wrapper.scrollLeft; + const mouseX = e.clientX - rect.left + scrollLeft; + const time = mouseX / zoom; + const rawStart = Math.max(0, time - drag.clickOffset); + const newStart = snapTime(rawStart, snapValueRef.current, bpmRef.current); + const targetTrackId = hoveredTrackIdRef.current || drag.trackId; + setTracks(prev => prev.map(t => { + // Clear the clip from its previous track if it moved to a new track + if (t.id === drag.trackId && drag.trackId !== targetTrackId) { + const updatedClips = (t.clips || []).filter(c => c.id !== drag.clipId); + return { + ...t, + clips: updatedClips, + buffer: updatedClips.length > 0 ? updatedClips[0].buffer : null, + startTime: updatedClips.length > 0 ? updatedClips[0].startTime : 0, + name: updatedClips.length > 0 ? updatedClips[0].name : `Track ${t.id}` + }; + } + // Update/set clip on target track + if (t.id === targetTrackId) { + const existingClips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ + id: 'default_' + t.id, + buffer: t.buffer, + startTime: t.startTime || 0, + name: t.name + }] : []; + const hasClip = existingClips.some(c => c.id === drag.clipId); + let updatedClips; + if (hasClip) { + updatedClips = existingClips.map(c => c.id === drag.clipId ? { + ...c, + startTime: newStart + } : c); + } else { + updatedClips = [...existingClips, { + id: drag.clipId, + buffer: drag.buffer, + startTime: newStart, + name: drag.name + }]; + } + return { + ...t, + clips: updatedClips, + buffer: updatedClips[0].buffer, + startTime: updatedClips[0].startTime, + name: updatedClips[0].name + }; + } + return t; + })); + if (drag.trackId !== targetTrackId) { + setDraggedClip(prev => ({ + ...prev, + trackId: targetTrackId + })); + } + }; + const handleMouseUp = () => { + const drag = draggedClipRef.current; + if (!drag) return; + const afterSnap = captureTrackSnapshotRef.current(drag.trackId); + pushAction('MOVE_CLIP', drag.trackId, drag.beforeSnap, afterSnap); + setDraggedClip(null); + showToast('Đã di chuyển clip.', 'success'); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [zoom]); + + // Document-level mousemove/mouseup for clip stretching + useEffect(() => { + const handleMouseMove = e => { + const stretch = stretchedClipRef.current; + if (!stretch) return; + const wrapper = timelineWrapperRef.current; + if (!wrapper) return; + const rect = wrapper.getBoundingClientRect(); + const scrollLeft = wrapper.scrollLeft; + const mouseX = e.clientX - rect.left + scrollLeft; + const time = mouseX / zoom; + const newDuration = Math.max(0.1, time - stretch.startTime); + const speedRatio = stretch.originalDuration / newDuration; + setTracks(prev => prev.map(t => { + if (t.id === stretch.trackId) { + const updatedClips = (t.clips || []).map(c => { + if (c.id === stretch.clipId) { + return { + ...c, + speed: speedRatio + }; + } + return c; + }); + return { + ...t, + clips: updatedClips, + buffer: updatedClips[0]?.buffer, + startTime: updatedClips[0]?.startTime || 0, + speed: updatedClips[0]?.speed || 1.0 + }; + } + return t; + })); + }; + const handleMouseUp = () => { + const stretch = stretchedClipRef.current; + if (!stretch) return; + const afterSnap = captureTrackSnapshotRef.current(stretch.trackId); + pushAction('STRETCH_CLIP', stretch.trackId, stretch.beforeSnap, afterSnap); + setStretchedClip(null); + showToast('Đã giãn thời gian clip.', 'success'); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [zoom]); + const handleSelectRange = (start, end, reset) => { + const maxLen = maxDuration; + const cleanStart = Math.max(0, Math.min(maxLen, start)); + const cleanEnd = Math.max(0, Math.min(maxLen, end)); + if (reset) { + setSelectionStart(cleanStart); + setSelectionEnd(cleanEnd); + } else { + setSelectionEnd(cleanEnd); + } + // LOOP_EDITOR_2.md §4.2: new selection = enable looping + setSelectionCleared(false); + }; + const handleSelectionInputChange = (field, val) => { + const numericVal = Math.max(0, parseFloat(val) || 0); + if (selectionMode === 'local') { + // Editing local selection directly + if (field === 'start') { + setLocalSelectionStart(numericVal); + } else { + setLocalSelectionEnd(numericVal); + } + } else { + if (field === 'start') { + setSelectionStart(numericVal); + } else { + setSelectionEnd(numericVal); + } + } + }; + const selectionStats = useMemo(() => { + if (selLeft === null || selRight === null) { + return { + start: 0, + end: 0, + length: 0 + }; + } + const s = Math.min(selLeft, selRight); + const e = Math.max(selLeft, selRight); + return { + start: parseFloat(s.toFixed(3)), + end: parseFloat(e.toFixed(3)), + length: parseFloat((e - s).toFixed(3)) + }; + }, [selLeft, selRight]); + + // ── Handle Drag (selection resize) ── + const handleHandleDragStart = (e, side) => { + e.preventDefault(); + e.stopPropagation(); + const startX = e.clientX; + const useLocal = selectionMode === 'local'; + const currentStart = useLocal ? localSelectionStart : selectionStart; + const currentEnd = useLocal ? localSelectionEnd : selectionEnd; + const initialLeft = Math.min(currentStart, currentEnd); + const initialRight = Math.max(currentStart, currentEnd); + const setStart = useLocal ? setLocalSelectionStart : setSelectionStart; + const setEnd = useLocal ? setLocalSelectionEnd : setSelectionEnd; + const handleMouseMove = moveEvent => { + const deltaX = moveEvent.clientX - startX; + const deltaSec = deltaX / zoom; + if (side === 'left') { + const newLeft = Math.max(0, Math.min(initialRight - 0.05, initialLeft + deltaSec)); + setStart(newLeft); + setEnd(initialRight); + } else { + const newRight = Math.max(initialLeft + 0.05, Math.min(maxDuration, initialRight + deltaSec)); + setStart(initialLeft); + setEnd(newRight); + } + }; + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + const handleSelectionBodyDragStart = e => { + e.preventDefault(); + e.stopPropagation(); + const startX = e.clientX; + const useLocal = selectionMode === 'local'; + const currentStart = useLocal ? localSelectionStart : selectionStart; + const currentEnd = useLocal ? localSelectionEnd : selectionEnd; + const initialLeft = Math.min(currentStart, currentEnd); + const initialRight = Math.max(currentStart, currentEnd); + const widthSec = initialRight - initialLeft; + const setStart = useLocal ? setLocalSelectionStart : setSelectionStart; + const setEnd = useLocal ? setLocalSelectionEnd : setSelectionEnd; + const handleMouseMove = moveEvent => { + const deltaX = moveEvent.clientX - startX; + const deltaSec = deltaX / zoom; + let newLeft = initialLeft + deltaSec; + let newRight = initialRight + deltaSec; + if (newLeft < 0) { + newLeft = 0; + newRight = widthSec; + } + if (newRight > maxDuration) { + newRight = maxDuration; + newLeft = maxDuration - widthSec; + } + setStart(newLeft); + setEnd(newRight); + }; + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + + // ── Track Controls ── + const toggleTrackSoloEvaluate = trackId => { + const wasPlaying = isPlaying; + stopAllPlayback(); + setTracks(prev => prev.map(t => { + if (t.id === trackId) { + return { + ...t, + solo: !t.solo, + muted: false + }; + } + return { + ...t, + solo: false + }; + })); + setSoloedTrackId(prev => prev === trackId ? null : trackId); + if (wasPlaying) { + setTimeout(() => { + startOffsetTimeRef.current = currentTime; + startAudioTimeRef.current = getAudioContext().currentTime; + setIsPlaying(true); + }, 50); + } + }; + const toggleTrackMute = trackId => { + const beforeSnap = captureTrackSnapshot(trackId); + setTracks(prev => prev.map(t => t.id === trackId ? { + ...t, + muted: !t.muted + } : t)); + setUndoStack(prev => { + const next = [...prev, { + action_type: 'MUTE', + track_id: trackId, + timestamp: Date.now(), + before_state: beforeSnap, + after_state: captureTrackSnapshot(trackId) + }]; + if (next.length > MAX_UNDO) next.shift(); + return next; + }); + }; + const updateTrackVolumeDb = (trackId, val) => { + const beforeSnap = captureTrackSnapshot(trackId); + setTracks(prev => prev.map(t => t.id === trackId ? { + ...t, + volumeDb: val + } : t)); + setUndoStack(prev => { + const next = [...prev, { + action_type: 'VOLUME_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) { + 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) ── + const loadFileOnTrack = async (trackId, file) => { + if (!file) return; + showToast(`Đang nạp file ${file.name}...`, 'info'); + try { + // Upload to server + uploadToServer(file, trackId); + + // Decode locally for playback + analyze channels (stereo/mono) + const { + audioBuffer: decodedBuffer, + channelInfo + } = await window.SonicAudio.decodeAudioFile(file); + setTracks(prev => prev.map(t => t.id === trackId ? { + ...t, + name: file.name, + buffer: decodedBuffer, + channelInfo: channelInfo + } : t)); + showToast(`Nạp file thành công: ${file.name} (${channelInfo.label})`, 'success'); + } catch (err) { + showToast("Lỗi giải mã âm thanh. Định dạng file không tương thích.", 'error'); + } + }; + + // ── Synth Generators ── + const generateSynthToTrack = (trackId, type) => { + const sampleRate = 44100; + const duration = 12.0; + const context = getAudioContext(); + const frameCount = sampleRate * duration; + const newBuffer = context.createBuffer(1, frameCount, sampleRate); + const channelData = newBuffer.getChannelData(0); + if (type === 'kick') { + for (let i = 0; i < frameCount; i++) { + const t = i / sampleRate; + const beatTime = t % 0.5; + const freq = 120 * Math.exp(-35 * beatTime); + channelData[i] = Math.sin(2 * Math.PI * freq * beatTime) * Math.exp(-6 * beatTime); + } + } else { + const notes = [220.00, 261.63, 293.66, 329.63, 392.00]; + for (let i = 0; i < frameCount; i++) { + const t = i / sampleRate; + const noteIdx = Math.floor(t * 2) % notes.length; + const freq = notes[noteIdx]; + channelData[i] = Math.sin(2 * Math.PI * freq * t) * 0.25 * (1.0 - t % 0.5 / 0.5); + } + } + setTracks(prev => prev.map(t => t.id === trackId ? { + ...t, + name: `Demo_${type.toUpperCase()}.wav`, + buffer: newBuffer + } : t)); + showToast(`Đã nạp sóng âm tổng hợp: ${type.toUpperCase()}`, 'success'); + }; + + // ── Add Track ── + const addNewTrack = () => { + const newId = (tracks.length + 1).toString(); + const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; + const selectColor = colors[tracks.length % colors.length]; + setTracks(prev => [...prev, { + id: newId, + name: `Track ${newId}`, + buffer: null, + startTime: 0, + height: 128, + 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; + }; + + // ── Server-side Export ── + const triggerWavExport = async () => { + const activeTracks = tracks.filter(t => t.buffer && !t.muted); + if (activeTracks.length === 0) { + showToast("Không tìm thấy dữ liệu âm thanh hợp lệ để xuất.", "warning"); + return; + } + + // Check if all active tracks have server file IDs + const allOnServer = activeTracks.every(t => serverFileIdMap[t.id]); + if (allOnServer && serverStatus === 'connected') { + // Use server-side export + setIsExporting(true); + showToast("Đang gửi yêu cầu xuất âm thanh đến máy chủ...", "info"); + try { + const sessionId = `session_${Date.now()}`; + const tracksMeta = activeTracks.map(t => ({ + track_id: t.id, + file_id: serverFileIdMap[t.id], + volume_db: t.volumeDb, + muted: false, + clips: [{ + clip_id: `clip_${t.id}`, + start_time_seconds: t.startTime || 0, + end_time_seconds: (t.startTime || 0) + t.buffer.duration, + loop_count: 1, + apply_zero_crossing: true, + fade_in_ms: 0, + fade_out_ms: 0 + }] + })); + const resp = await fetch(`${API_MULTITRACK}/mix`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + session_id: sessionId, + export_settings: { + sample_rate: parseInt(exportSettings.sampleRate), + bit_depth: parseInt(exportSettings.bitDepth), + format: exportSettings.format + }, + tracks: tracksMeta + }) + }); + if (!resp.ok) throw new Error(`Server export failed: ${resp.status}`); + const data = await resp.json(); + showToast("Đang xử lý trên máy chủ...", "info"); + + // Poll for result + const result = await pollTaskResult(data.task_id, 20); + if (result.success) { + // Get the uploaded file from server + const fileId = result.output_file_id || result.output_path?.split('/').pop(); + if (fileId) { + const downloadUrl = `${API_AUDIO}/download/${fileId}`; + const a = document.createElement('a'); + a.href = downloadUrl; + a.download = fileId; + a.click(); + showToast("Xuất bản âm thanh từ máy chủ hoàn tất!", "success"); + } + } else { + throw new Error(result.error || 'Server processing failed'); + } + } catch (err) { + showToast("Lỗi xuất máy chủ: " + err.message + ". Chuyển sang xuất client.", "warning"); + // Fall back to client-side export + clientSideExport(activeTracks); + } finally { + setIsExporting(false); + } + } else { + // Client-side export (existing working code) + clientSideExport(activeTracks); + } + }; + const handleSaveCloud = async () => { + if (!currentUser) { + setIsMandatoryLogin(false); + setAuthMode('login'); + setAuthModalOpen(true); + return; + } + const name = prompt("Nhập tên dự án để lưu lên Cloud:", "Dự án SonicForge"); + if (!name) return; + const serializeSafe = arr => (arr || []).map(t => ({ + id: t.id, + name: t.name, + startTime: t.startTime, + height: t.height, + volumeDb: t.volumeDb, + pan: t.pan, + muted: t.muted, + solo: t.solo, + color: t.color, + markers: t.markers || [], + serverFileId: t.serverFileId || null, + channelInfo: t.channelInfo ? { + channels: t.channelInfo.channels, + isStereo: t.channelInfo.isStereo, + label: t.channelInfo.label + } : null + })); + try { + const dataJson = JSON.stringify({ + id: 'cloud_project', + name, + tracks: serializeSafe(tracks) + }); + await window.SonicAPI.saveCloudProject(name, dataJson); + showToast("Đã lưu dự án lên Cloud thành công!", "success"); + } catch (err) { + showToast(err.message || "Lỗi lưu Cloud", "error"); + } + }; + const handleExportSFS = () => { + const serializeSafe = arr => (arr || []).map(t => ({ + id: t.id, + name: t.name, + startTime: t.startTime, + height: t.height, + volumeDb: t.volumeDb, + pan: t.pan, + muted: t.muted, + solo: t.solo, + color: t.color, + markers: t.markers || [], + serverFileId: t.serverFileId || null, + channelInfo: t.channelInfo ? { + channels: t.channelInfo.channels, + isStereo: t.channelInfo.isStereo, + label: t.channelInfo.label + } : null + })); + window.SonicStorage.exportProjectToSFS({ + id: 'proj_' + Date.now(), + name: 'Dự án SonicForge', + tracks: serializeSafe(tracks) + }); + showToast("Đã xuất dự án (.sfs) thành công!", "success"); + }; + const handleImportSFS = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.sfs,application/json'; + input.onchange = async e => { + if (!e.target.files[0]) return; + try { + const proj = await window.SonicStorage.importProjectFromSFSFile(e.target.files[0]); + const restored = (proj.tracks || []).map(t => ({ + ...t, + buffer: null, + channelInfo: t.channelInfo || null, + clips: t.clips || [], + serverFileId: t.serverFileId || null + })); + if (restored.length > 0) { + setTracks(restored); + showToast(`Đã nạp dự án "${proj.name}" từ tệp .sfs thành công!`, "success"); + } + } catch (err) { + showToast(err.message || "Lỗi mở tệp .sfs", "error"); + } + }; + input.click(); + }; + const clientSideExport = async activeTracks => { + setIsExporting(true); + showToast("Đang trộn âm thanh đa kênh (Offline Mixdown)...", "info"); + try { + const targetRate = parseInt(exportSettings.sampleRate); + const bitDepth = parseInt(exportSettings.bitDepth); + const durationLimit = Math.max(...activeTracks.map(t => { + const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ + id: 'default', + buffer: t.buffer, + startTime: t.startTime || 0, + name: t.name, + speed: t.speed || 1.0 + }] : []; + if (clips.length === 0) return 0; + return Math.max(...clips.map(c => (c.startTime || 0) + c.buffer.duration / (c.speed || 1.0))); + })); + const offlineCtx = new OfflineAudioContext(1, Math.ceil(targetRate * Math.max(0.1, durationLimit)), targetRate); + activeTracks.forEach(t => { + const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ + id: 'default', + buffer: t.buffer, + startTime: t.startTime || 0, + name: t.name, + speed: t.speed || 1.0 + }] : []; + clips.forEach(clip => { + if (!clip.buffer) return; + const source = offlineCtx.createBufferSource(); + source.buffer = clip.buffer; + source.playbackRate.value = clip.speed || 1.0; + const gain = offlineCtx.createGain(); + 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(panner); + panner.connect(offlineCtx.destination); + const clipStart = clip.startTime || 0; + source.start(clipStart); + }); + }); + const renderedBuffer = await offlineCtx.startRendering(); + const monoData = renderedBuffer.getChannelData(0); + const bufferLength = monoData.length; + const bytesPerSample = bitDepth / 8; + const headerSize = 44; + const fileSizeBytes = headerSize + bufferLength * bytesPerSample; + const fileBuffer = new ArrayBuffer(fileSizeBytes); + const view = new DataView(fileBuffer); + const writeString = (offset, string) => { + for (let i = 0; i < string.length; i++) { + view.setUint8(offset + i, string.charCodeAt(i)); + } + }; + writeString(0, 'RIFF'); + view.setUint32(4, fileSizeBytes - 8, true); + writeString(8, 'WAVE'); + writeString(12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, 1, true); + view.setUint32(24, targetRate, true); + view.setUint32(28, targetRate * bytesPerSample, true); + view.setUint16(32, bytesPerSample, true); + view.setUint16(34, bitDepth, true); + writeString(36, 'data'); + view.setUint32(40, bufferLength * bytesPerSample, true); + let offset = 44; + for (let i = 0; i < bufferLength; i++) { + const sample = Math.max(-1, Math.min(1, monoData[i])); + if (bitDepth === 8) { + view.setUint8(offset, Math.floor((sample + 1.0) * 127.5)); + } else if (bitDepth === 16) { + view.setInt16(offset, Math.floor(sample < 0 ? sample * 0x8000 : sample * 0x7FFF), true); + } else if (bitDepth === 24) { + const val24 = Math.floor(sample < 0 ? sample * 0x800000 : sample * 0x7FFFFF); + view.setUint8(offset, val24 & 0xFF); + view.setUint8(offset + 1, val24 >> 8 & 0xFF); + view.setUint8(offset + 2, val24 >> 16 & 0xFF); + } + offset += bytesPerSample; + } + const blob = new Blob([view], { + type: 'audio/wav' + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `sonicforge-session-${targetRate}hz-${bitDepth}bit.wav`; + a.click(); + URL.revokeObjectURL(url); + showToast("Xuất bản âm thanh hoàn tất!", "success"); + } catch (err) { + showToast("Lỗi xuất âm thanh: " + err.message, "error"); + } finally { + setIsExporting(false); + } + }; + + // ── AI Analysis (server-side with fallback) ── + const triggerAIAnalysis = async () => { + setAnalysisState({ + status: 'Connecting to AI Engine...', + data: null, + isRunning: true + }); + + // Check if we have a file on the server to analyze + const activeTrack = tracks.find(t => t.id === selectedTrackId); + const serverFileId = serverFileIdMap[selectedTrackId]; + if (serverFileId && serverStatus === 'connected') { + try { + const resp = await fetch(`${API_AUDIO}/analyze-ai`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + file_id: serverFileId, + api_base_url: aiConfig.baseUrl !== API_BASE_URL ? aiConfig.baseUrl : null, + model: aiConfig.model + }) + }); + if (resp.ok) { + const data = await resp.json(); + showToast("Đang phân tích cấu trúc trên máy chủ...", "info"); + try { + const result = await pollTaskResult(data.task_id, 15); + if (result.bpm) { + if (selLeft !== null && selRight !== null && activeTrack && activeTrack.buffer) { + const snapStart = findZeroCrossing(activeTrack.buffer, selLeft); + const snapEnd = findZeroCrossing(activeTrack.buffer, selRight); + if (selectionMode === 'local') { + setLocalSelectionStart(snapStart); + setLocalSelectionEnd(snapEnd); + } else { + setSelectionStart(snapStart); + setSelectionEnd(snapEnd); + } + } + setAnalysisState({ + status: 'Hoàn thành phân tích (Server)', + data: result, + isRunning: false + }); + showToast(`AI Server: ${result.bpm} BPM | ${result.beats?.length || 0} beats detected (Zero-Crossing Aligned)`, 'success'); + return; + } + } catch (e) { + console.warn('Server AI poll failed, using fallback:', e); + } + } + } catch (err) { + console.warn('Server AI analysis failed, using fallback:', err); + } + } + + // Fallback: client-side analysis with simulated BPM + setAnalysisState({ + status: 'Processing structural detection...', + data: null, + isRunning: true + }); + setTimeout(() => { + let bpm = 120; + // Try to detect BPM from buffer + if (activeTrack && activeTrack.buffer) { + const data = activeTrack.buffer.getChannelData(0); + const sr = activeTrack.buffer.sampleRate; + // Simple autocorrelation for BPM estimation + const windowSize = Math.min(sr * 3, data.length); + if (windowSize > sr) { + let maxCorr = 0; + let bestLag = Math.floor(sr * 0.25); // ~240 BPM max + for (let lag = Math.floor(sr * 0.25); lag < Math.floor(sr * 2); lag++) { + let corr = 0; + const n = Math.floor(windowSize / 2); + for (let i = 0; i < n; i++) { + corr += data[i] * data[i + lag]; + } + corr /= n; + if (corr > maxCorr) { + maxCorr = corr; + bestLag = lag; + } + } + if (bestLag > 0) { + bpm = Math.round(60 * sr / bestLag); + bpm = Math.max(60, Math.min(200, bpm)); + } + } + } + if (selLeft !== null && selRight !== null && activeTrack && activeTrack.buffer) { + const snapStart = findZeroCrossing(activeTrack.buffer, selLeft); + const snapEnd = findZeroCrossing(activeTrack.buffer, selRight); + if (selectionMode === 'local') { + setLocalSelectionStart(snapStart); + setLocalSelectionEnd(snapEnd); + } else { + setSelectionStart(snapStart); + setSelectionEnd(snapEnd); + } + } + setAnalysisState({ + status: 'Hoàn thành phân tích (Client)', + data: { + bpm, + bars: Math.max(4, Math.round(bpm / 30)), + timeSig: '4/4', + detectedKey: 'Am' + }, + isRunning: false + }); + showToast(`Phân tích: ${bpm} BPM (Zero-Crossing Aligned)`, 'success'); + }, 1500); + }; + + // ── AI Analysic Loop: scan track, detect beats, place markers for loop selection ── + const handleAIAnalysicLoop = async () => { + const forcedTrackId = subTabAiTrackIdRef.current; + subTabAiTrackIdRef.current = null; + const activeTrackId = forcedTrackId || selectedTrackId; + const activeTrack = tracks.find(t => t.id === activeTrackId); + if (!activeTrack || !activeTrack.buffer) { + showToast("Vui lòng chọn một Track có âm thanh để AI phân tích.", "warning"); + return; + } + setAnalysisState({ + status: 'AI Analysic Loop: đang phát hiện nhịp...', + data: null, + isRunning: true + }); + showToast("AI Analysic Loop: đang quét cấu trúc nhịp điệu...", "info"); + try { + const buffer = activeTrack.buffer; + const data = buffer.getChannelData(0); + const sr = buffer.sampleRate; + const windowSize = Math.min(sr * 3, data.length); + + // Client-side BPM detection via autocorrelation + let detectedBPM = 120; + if (windowSize > sr) { + let maxCorr = 0; + for (let lag = Math.floor(sr * 0.3); lag <= Math.floor(sr * 2.0); lag++) { + let corr = 0; + const step = 4; + for (let i = 0; i < windowSize && i + lag < data.length; i += step) { + corr += data[i] * data[i + lag]; + } + corr /= windowSize / step; + if (corr > maxCorr) { + maxCorr = corr; + detectedBPM = 60 / (lag / sr); + } + } + } + detectedBPM = Math.round(Math.min(240, Math.max(60, detectedBPM))); + const beatDuration = 60 / detectedBPM; + const barDuration = beatDuration * 4; + const totalDuration = buffer.duration; + + // Place markers at each bar (strong beat) position + const barMarkers = []; + for (let t = 0; t < totalDuration; t += barDuration) { + const zcTime = findZeroCrossing(buffer, t); + barMarkers.push({ + id: 'ai_bar_' + barMarkers.length + '_' + Date.now(), + time: zcTime, + label: `Bar ${barMarkers.length + 1}`, + color: '#06b6d4' + }); + // Add beat markers within each bar + for (let b = 1; b < 4; b++) { + const bt = t + b * beatDuration; + if (bt < totalDuration) { + const zcBt = findZeroCrossing(buffer, bt); + barMarkers.push({ + id: 'ai_beat_' + barMarkers.length + '_' + Date.now(), + time: zcBt, + label: `Beat ${b + 1}`, + color: '#a855f7' + }); + } + } + } + setTracks(prev => prev.map(t => { + if (t.id !== activeTrack.id) return t; + const existingMarkers = t.markers || []; + return { + ...t, + markers: [...existingMarkers, ...barMarkers] + }; + })); + + // Find the first strong beat to set as selection start + const firstBeat = barMarkers.length > 0 ? barMarkers[0].time : 0; + const secondBar = barMarkers.length > 4 ? barMarkers[Math.min(4, barMarkers.length - 1)].time : Math.min(totalDuration, firstBeat + barDuration); + setSelectionStart(firstBeat); + setSelectionEnd(secondBar); + setAnalysisState({ + status: `AI Analysic Loop: ${detectedBPM} BPM, ${barMarkers.length} markers (Bar/Beat)`, + data: { + bpm: detectedBPM, + bars: Math.floor(totalDuration / barDuration) + }, + isRunning: false + }); + showToast(`AI Analysic Loop: ${detectedBPM} BPM - ${Math.floor(totalDuration / barDuration)} bars detected`, "success"); + } catch (err) { + setAnalysisState({ + status: 'Lỗi AI Analysic Loop', + data: null, + isRunning: false + }); + showToast(err.message || 'Lỗi khi phân tích nhịp', 'error'); + } + }; + // ── Mark Selection ── + // ── Helper: set selection range from buffer (used by sub-tab AI) ── + const setSelectionRangeOnBuffer = (buffer, startTime, endTime) => { + setSelectionStart(startTime); + setSelectionEnd(endTime); + }; + + // Ref for forced trackId (used by sub-tab AI buttons, overrides selectedTrackId) + const subTabAiTrackIdRef = useRef(null); + const handleAIScan = async () => { + // Resolve track: prefer sub-tab override, then selectedTrackId + const forcedTrackId = subTabAiTrackIdRef.current; + subTabAiTrackIdRef.current = null; + const activeTrackId = forcedTrackId || selectedTrackId; + const activeTrack = tracks.find(t => t.id === activeTrackId); + if (!activeTrack || !activeTrack.buffer) { + showToast("Vui lòng chọn một Track có âm thanh để AI quét Loop.", "warning"); + return; + } + setAnalysisState({ + status: 'AI Loop Scan đang quét nhịp điệu và phách mạnh...', + data: null, + isRunning: true + }); + showToast("AI Scan đang phân tích tempo và phách mạnh...", "info"); + try { + const buffer = activeTrack.buffer; + const data = buffer.getChannelData(0); + const sr = buffer.sampleRate; + const windowSize = Math.min(sr * 3, data.length); + + // Detect BPM via autocorrelation + let detectedBPM = 120; + if (windowSize > sr) { + let maxCorr = 0; + for (let lag = Math.floor(sr * 0.3); lag <= Math.floor(sr * 2.0); lag++) { + let corr = 0; + const step = 4; + for (let i = 0; i < windowSize && i + lag < data.length; i += step) { + corr += data[i] * data[i + lag]; + } + corr /= windowSize / step; + if (corr > maxCorr) { + maxCorr = corr; + detectedBPM = 60 / (lag / sr); + } + } + } + detectedBPM = Math.round(Math.min(240, Math.max(60, detectedBPM))); + + // Calculate bar grid + const beatDuration = 60 / detectedBPM; + const barDuration = beatDuration * 4; + const totalDuration = buffer.duration; + + // Place markers at bar starts (strong beats / downbeats) + const barMarkers = []; + for (let t = 0; t < totalDuration; t += barDuration) { + const zcTime = findZeroCrossing(buffer, t); + barMarkers.push({ + id: 'ai_bar_' + barMarkers.length + '_' + Date.now(), + time: zcTime, + label: `Downbeat ${barMarkers.length + 1}`, + color: '#06b6d4' + }); + } + setTracks(prev => prev.map(t => { + if (t.id !== activeTrack.id) return t; + const existingMarkers = t.markers || []; + return { + ...t, + markers: [...existingMarkers, ...barMarkers] + }; + })); + + // Set selection to the first downbeat + const firstBarStart = barMarkers.length > 0 ? barMarkers[0].time : 0; + const secondBarStart = barMarkers.length > 1 ? barMarkers[1].time : Math.min(totalDuration, firstBarStart + barDuration); + setSelectionStart(firstBarStart); + setSelectionEnd(secondBarStart); + setAnalysisState({ + status: `AI Scan: ${detectedBPM} BPM, ${barMarkers.length} downbeats (đã snap zero-crossing)`, + data: { + bpm: detectedBPM + }, + isRunning: false + }); + showToast(`AI Scan: ${detectedBPM} BPM - ${barMarkers.length} downbeats detected`, "success"); + } catch (err) { + setAnalysisState({ + status: 'Lỗi khi AI Scan', + data: null, + isRunning: false + }); + showToast(err.message || 'Lỗi khi quét AI Loop', 'error'); + } + }; + const runPythonTool = async toolType => { + const activeTrack = tracks.find(t => t.id === selectedTrackId); + if (!activeTrack || !activeTrack.buffer) { + showToast("Vui lòng chọn một Track để xử lý công cụ Python.", "warning"); + return; + } + try { + if (toolType === 'normalize') { + const channelData = activeTrack.buffer.getChannelData(0); + let maxVal = 0; + for (let i = 0; i < channelData.length; i++) { + maxVal = Math.max(maxVal, Math.abs(channelData[i])); + } + if (maxVal > 0) { + const gain = 1.0 / maxVal; + for (let i = 0; i < channelData.length; i++) { + channelData[i] *= gain; + } + } + showToast("Đã Chuẩn Hóa Peak âm thanh về 0 dB!", "success"); + } else if (toolType === 'invert_phase') { + const channelData = activeTrack.buffer.getChannelData(0); + for (let i = 0; i < channelData.length; i++) { + channelData[i] *= -1; + } + showToast("Đã Đảo Pha (180°) âm thanh thành công!", "success"); + } else if (toolType === 'swap_channels') { + if (activeTrack.buffer.numberOfChannels >= 2) { + const left = activeTrack.buffer.getChannelData(0); + const right = activeTrack.buffer.getChannelData(1); + for (let i = 0; i < left.length; i++) { + const temp = left[i]; + left[i] = right[i]; + right[i] = temp; + } + showToast("Đã Đổi Kênh Left / Right thành công!", "success"); + } else { + showToast("Track hiện tại là Mono. Chỉ áp dụng Đổi Kênh cho Stereo.", "info"); + } + } else if (toolType === 'synth_wave') { + generateSynthToTrack(activeTrack.id, 'synth'); + showToast("Đã tạo Tín Hiệu Sóng Tổng Hợp bằng công cụ Python!", "success"); + } + } catch (err) { + showToast(err.message || "Lỗi khi chạy công cụ Python", "error"); + } + }; + const handleMarkSelection = () => { + if (selLeft === null || selRight === null || selectionStats.length === 0) { + showToast("Vui lòng chọn một khoảng thời gian trên sóng âm trước.", "warning"); + return; + } + const targetTrack = tracks.find(t => t.id === selectedTrackId); + if (!targetTrack || !targetTrack.buffer) { + showToast("Vui lòng nhấp chọn một Track có sóng âm để gán Marker.", "warning"); + return; + } + + // For local selection, force mark on the local-selected track + const markTrackId = selectionMode === 'local' && localSelectionTrackId ? localSelectionTrackId : selectedTrackId; + setTracks(prev => prev.map(t => { + if (t.id !== markTrackId) return t; + const snapStart = findZeroCrossing(t.buffer, selLeft); + const snapEnd = findZeroCrossing(t.buffer, selRight); + const newMarkers = [...t.markers, { + id: Date.now() + '_s', + time: snapStart + }, { + id: Date.now() + '_e', + time: snapEnd + }].sort((a, b) => a.time - b.time); + return { + ...t, + markers: newMarkers + }; + })); + showToast(`Đã tạo 2 Markers tại đầu và cuối dải chọn (Snap Zero-Crossing)`, "success"); + }; + + // ── AI Cut to New Track (Music Theory Loop Detection) ── + const handleAICutToNewTrack = () => { + const forcedTrackId = subTabAiTrackIdRef.current; + subTabAiTrackIdRef.current = null; + const activeTrackId = forcedTrackId || selectedTrackId; + const activeTrack = tracks.find(t => t.id === activeTrackId); + if (!activeTrack || !activeTrack.buffer) { + showToast("Vui lòng chọn một Track có dữ liệu âm thanh trước.", "warning"); + return; + } + if (selectionStart === null || selectionEnd === null || selectionStats.length === 0) { + showToast("Vui lòng kéo chọn một khoảng thời gian trên sóng âm.", "warning"); + return; + } + setAnalysisState({ + status: 'AI Cut: đang phân tích nhịp và tìm loop point...', + data: null, + isRunning: true + }); + showToast("AI Cut: đang phân tích nhịp điệu và tìm điểm loop chính xác...", "info"); + setTimeout(() => { + try { + const buffer = activeTrack.buffer; + const sampleRate = buffer.sampleRate; + const channelData = buffer.getChannelData(0); + const dataLen = channelData.length; + const windowSize = Math.min(sampleRate * 3, dataLen); + + // Detect BPM via autocorrelation + let detectedBPM = 120; + if (windowSize > sampleRate) { + let maxCorr = 0; + for (let lag = Math.floor(sampleRate * 0.3); lag <= Math.floor(sampleRate * 2.0); lag++) { + let corr = 0; + const step = 4; + for (let i = 0; i < windowSize && i + lag < dataLen; i += step) { + corr += channelData[i] * channelData[i + lag]; + } + corr /= windowSize / step; + if (corr > maxCorr) { + maxCorr = corr; + detectedBPM = 60 / (lag / sampleRate); + } + } + } + detectedBPM = Math.round(Math.min(240, Math.max(60, detectedBPM))); + const beatDuration = 60 / detectedBPM; + const barDuration = beatDuration * 4; + + // Use the selection range + const rawStart = selectionStats.start; + const rawEnd = selectionStats.end; + const selDuration = rawEnd - rawStart; + + // Find the nearest bar start (downbeat) for loop start + const barsFromZero = rawStart / barDuration; + const nearestBarStart = Math.round(barsFromZero) * barDuration; + const loopStart = Math.max(0, Math.min(rawStart + barDuration, nearestBarStart)); + + // Find the nearest beat 4 (bar end) for loop end + // In 4/4 time: beat 4 = barStart + 3*beatDuration = barEnd + const barsFromStart = rawEnd / barDuration; + const nearestBarEnd = Math.round(barsFromStart) * barDuration; + // Ensure minimum 1 bar loop + let loopEnd = Math.max(loopStart + barDuration, nearestBarEnd); + if (loopEnd > rawEnd + beatDuration) loopEnd = loopStart + Math.ceil(selDuration / barDuration) * barDuration; + + // Snap to zero-crossing for click-free loop + const snapLoopStart = findZeroCrossing(buffer, loopStart); + const snapLoopEnd = findZeroCrossing(buffer, loopEnd); + + // Place markers for the loop points + setTracks(prev => prev.map(t => { + if (t.id !== activeTrack.id) return t; + const existingMarkers = t.markers || []; + const filtered = existingMarkers.filter(m => !m.id.startsWith('ai_loop_')); + return { + ...t, + markers: [...filtered, { + id: 'ai_loop_start_' + Date.now(), + time: snapLoopStart, + label: 'Loop Start (Bar ' + (Math.floor(snapLoopStart / barDuration) + 1) + ')', + color: '#06b6d4' + }, { + id: 'ai_loop_end_' + Date.now(), + time: snapLoopEnd, + label: 'Loop End (Beat 4)', + color: '#a855f7' + }] + }; + })); + setSelectionStart(snapLoopStart); + setSelectionEnd(snapLoopEnd); + const startSample = Math.max(0, Math.min(dataLen - 1, Math.floor(snapLoopStart * sampleRate))); + const endSample = Math.max(0, Math.min(dataLen, Math.floor(snapLoopEnd * sampleRate))); + const sliceLength = endSample - startSample; + if (sliceLength <= 0) { + showToast("Dải cắt không hợp lệ hoặc khoảng thời gian quá ngắn.", "error"); + setAnalysisState({ + status: 'Thất bại', + data: null, + isRunning: false + }); + return; + } + const context = getAudioContext(); + const numChannels = buffer.numberOfChannels || 1; + const slicedBuffer = context.createBuffer(numChannels, sliceLength, sampleRate); + for (let c = 0; c < numChannels; c++) { + const srcData = buffer.getChannelData(c); + const dstData = slicedBuffer.getChannelData(c); + dstData.set(srcData.subarray(startSample, endSample)); + } + const newId = 'track_ai_cut_' + Date.now(); + const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; + const selectColor = colors[tracks.length % colors.length]; + const barNum = Math.floor(snapLoopStart / barDuration) + 1; + const barsCount = Math.max(1, Math.round((snapLoopEnd - snapLoopStart) / barDuration)); + const newTrack = { + id: newId, + name: `Loop_${barNum}bar_${activeTrack.name.replace('.wav', '').slice(0, 10)}_${snapLoopStart.toFixed(1)}s.wav`, + buffer: slicedBuffer, + channelInfo: activeTrack.channelInfo ? { + ...activeTrack.channelInfo + } : null, + startTime: snapLoopStart, + clips: [{ + id: 'clip_' + newId, + buffer: slicedBuffer, + startTime: snapLoopStart, + name: `Loop_${barNum}bar_${snapLoopStart.toFixed(1)}s` + }], + volumeDb: 0, + pan: 0, + muted: false, + solo: false, + color: selectColor, + markers: [{ + id: Date.now() + '_s', + time: 0 + }, { + id: Date.now() + '_e', + time: snapLoopEnd - snapLoopStart + }], + serverFileId: null + }; + setTracks(prev => { + const idx = prev.findIndex(t => t.id === selectedTrackId); + const updated = [...prev]; + if (idx !== -1) { + updated.splice(idx + 1, 0, newTrack); + } else { + updated.push(newTrack); + } + return updated; + }); + setSelectedTrackId(newId); + setAnalysisState({ + status: `AI Cut: ${detectedBPM} BPM, ${barsCount} bars loop (Zero-Crossing aligned)`, + data: { + bpm: detectedBPM, + bars: barsCount, + timeSig: '4/4' + }, + isRunning: false + }); + showToast(`AI Cut: ${barsCount} bars loop at ${snapLoopStart.toFixed(3)}s - ${snapLoopEnd.toFixed(3)}s [${detectedBPM} BPM]`, "success"); + setTimeout(() => lucide.createIcons(), 200); + } catch (err) { + showToast("Lỗi khi AI Cut: " + err.message, "error"); + setAnalysisState({ + status: 'Lỗi AI Cut', + data: null, + isRunning: false + }); + } + }, 800); + }; + + // ── Split Track at Playhead ── + const handleSplitTrackAtTime = (trackId, clipId, time) => { + const track = tracks.find(t => t.id === trackId); + if (!track) return; + const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ + id: 'default', + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name + }] : []; + const targetClipId = clipId || clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration)?.id; + if (!targetClipId) return; + const clip = clips.find(c => c.id === targetClipId); + if (!clip || !clip.buffer) return; + const relTime = Math.max(0, time - clip.startTime); + const sr = clip.buffer.sampleRate; + const cutSample = Math.floor(relTime * sr); + const originalData = clip.buffer.getChannelData(0); + if (cutSample <= 0 || cutSample >= originalData.length) { + showToast("Vị trí cắt nằm ngoài dải âm thanh của clip.", "warning"); + return; + } + const beforeSnap = captureTrackSnapshot(trackId); + const ctx = getAudioContext(); + const b1 = ctx.createBuffer(1, cutSample, sr); + b1.copyToChannel(originalData.subarray(0, cutSample), 0); + const b2 = ctx.createBuffer(1, originalData.length - cutSample, sr); + b2.copyToChannel(originalData.subarray(cutSample), 0); + const clip1 = { + id: 'clip_' + Date.now() + '_p1', + name: `${clip.name.replace(' (Part 1)', '').replace(' (Part 2)', '')} (Part 1)`, + buffer: b1, + startTime: clip.startTime + }; + const clip2 = { + id: 'clip_' + Date.now() + '_p2', + name: `${clip.name.replace(' (Part 1)', '').replace(' (Part 2)', '')} (Part 2)`, + buffer: b2, + startTime: clip.startTime + cutSample / sr + }; + setTracks(prev => prev.map(t => { + if (t.id === trackId) { + const remainingClips = clips.filter(c => c.id !== targetClipId); + const updatedClips = [...remainingClips, clip1, clip2]; + return { + ...t, + clips: updatedClips, + buffer: updatedClips[0]?.buffer || null, + startTime: updatedClips[0]?.startTime || 0, + name: updatedClips[0]?.name || t.name + }; + } + return t; + })); + setTimeout(() => { + const afterSnap = captureTrackSnapshot(trackId); + pushAction('SPLIT_CLIP', trackId, beforeSnap, afterSnap); + }, 50); + showToast(`Đã chia nhỏ clip tại ${formatTime(time)}.`, "info"); + }; + const handleSplitTrack = trackId => { + handleSplitTrackAtTime(trackId, null, currentTime); + }; + + // ── Glue (Merge) Clips on Selected Track ── + const handleGlueTracks = () => { + const track = tracks.find(t => t.id === selectedTrackId); + if (!track) { + showToast('Vui lòng chọn một track để thực hiện gộp (glue).', 'warning'); + return; + } + const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ + id: 'default', + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name + }] : []; + if (clips.length < 2) { + showToast('Cần ít nhất 2 clip trên track này để gộp (glue).', 'warning'); + return; + } + const beforeSnap = captureTrackSnapshot(track.id); + const ctx = getAudioContext(); + const sr = clips[0].buffer.sampleRate; + let minStart = Infinity; + let maxEnd = -Infinity; + clips.forEach(c => { + const start = c.startTime || 0; + const end = start + c.buffer.duration; + minStart = Math.min(minStart, start); + maxEnd = Math.max(maxEnd, end); + }); + const newDur = maxEnd - minStart; + const newBuffer = ctx.createBuffer(1, Math.ceil(newDur * sr), sr); + const newData = newBuffer.getChannelData(0); + clips.forEach(c => { + const data = c.buffer.getChannelData(0); + const offset = Math.floor(((c.startTime || 0) - minStart) * sr); + for (let i = 0; i < data.length; i++) { + if (offset + i < newData.length) { + newData[offset + i] += data[i]; + } + } + }); + let maxPeak = 0; + for (let i = 0; i < newData.length; i++) { + const abs = Math.abs(newData[i]); + if (abs > maxPeak) maxPeak = abs; + } + if (maxPeak > 1.0) { + for (let i = 0; i < newData.length; i++) newData[i] /= maxPeak; + } + const mergedClip = { + id: 'clip_merged_' + Date.now(), + name: `${track.name.replace(' (Part 1)', '').replace(' (Part 2)', '')} (Glued)`, + buffer: newBuffer, + startTime: minStart + }; + setTracks(prev => prev.map(t => { + if (t.id === track.id) { + return { + ...t, + clips: [mergedClip], + buffer: newBuffer, + startTime: minStart, + name: mergedClip.name + }; + } + return t; + })); + setTimeout(() => { + const afterSnap = captureTrackSnapshot(track.id); + pushAction('GLUE', track.id, beforeSnap, afterSnap); + }, 50); + showToast(`Đã gộp ${clips.length} clips thành công.`, 'success'); + }; + + // ── Save AI config to localStorage ── + useEffect(() => { + localStorage.setItem('ai_base_url', aiConfig.baseUrl); + localStorage.setItem('ai_api_key', aiConfig.apiKey); + localStorage.setItem('ai_model', aiConfig.model); + }, [aiConfig]); + return /*#__PURE__*/React.createElement("div", { + className: "h-full w-full flex flex-col bg-[#1e1e1e]" + }, /*#__PURE__*/React.createElement("header", { + className: "h-9 bg-[#2e2e2e] border-b border-[#181818] flex items-center px-1 shrink-0 select-none" + }, [{ + label: 'File', + items: [{ + label: 'New Project', + icon: 'file-plus', + shortcut: 'Ctrl+N', + action: () => { + setTracks([{ + id: '1', + name: 'Track 01', + buffer: null, + startTime: 0, + height: 128, + volumeDb: 0, + pan: 0, + muted: false, + solo: false, + color: '#0f766e', + markers: [], + serverFileId: null + }, { + id: '2', + name: 'Track 02', + buffer: null, + startTime: 0, + height: 128, + 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: () => handleImportSFS() + }, { + label: 'Save Project', + icon: 'save', + shortcut: 'Ctrl+S', + action: () => handleExportSFS() + }, { + label: 'Save As...', + icon: 'save', + shortcut: 'Ctrl+Alt+S', + action: () => handleExportSFS() + }, { + label: 'Save to Cloud', + icon: 'upload-cloud', + action: () => handleSaveCloud() + }, { + sep: true + }, { + label: 'Config AI Providers...', + icon: 'settings', + action: () => setAiConfigModalOpen(true) + }, { + label: 'Import Audio...', + icon: 'file-input', + shortcut: 'Ctrl+Alt+I', + action: () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'audio/*'; + input.onchange = async e => { + if (e.target.files[0]) { + addNewTrack(); + const newId = (tracks.length + 1).toString(); + setTimeout(() => loadFileOnTrack(newId, e.target.files[0]), 100); + } + }; + input.click(); + showToast('Import audio', 'info'); + } + }, { + label: 'Export Mix...', + icon: 'file-output', + action: () => triggerWavExport() + }, { + sep: true + }, ...(currentUser ? [{ + label: 'Profile', + icon: 'user', + action: () => setProfileModalOpen(true) + }] : []), ...(currentUser && currentUser.role === 'admin' ? [{ + label: 'System Manager', + icon: 'settings', + action: () => setSystemManagerModalOpen(true) + }] : []), { + label: 'Logout', + icon: 'log-out', + action: () => handleLogout() + }] + }, { + label: 'Edit', + items: [{ + label: 'Insert New Track', + icon: 'plus', + shortcut: 'Ctrl+I', + action: addNewTrack + }, { + label: 'Insert Music to Track', + icon: 'music', + shortcut: 'Ctrl+Alt+I', + action: () => showToast('Select music file to insert', 'info') + }, { + sep: true + }, { + label: 'Edit in New Tab', + icon: 'file-edit', + shortcut: 'Ctrl+E', + action: () => openTempTab() + }, { + label: 'Split at Playhead', + icon: 'scissors', + shortcut: 'S', + action: () => handleSplitTrack(selectedTrackId) + }, { + label: 'Merge Tracks', + icon: 'combine', + shortcut: 'Ctrl+M', + action: () => { + handleMergeTracks(); + } + }, { + sep: true + }, { + label: 'Undo', + icon: 'undo', + shortcut: 'Ctrl+Z', + action: () => { + handleUndo(); + } + }, { + label: 'Redo', + icon: 'redo', + shortcut: 'Ctrl+Y', + action: () => { + handleRedo(); + } + }, { + sep: true + }, { + label: 'Copy', + icon: 'copy', + shortcut: 'Ctrl+C', + action: () => { + handleCopyTrack(); + } + }, { + label: 'Cut', + icon: 'scissors', + shortcut: 'Ctrl+X', + action: () => { + handleCutTrack(); + } + }, { + label: 'Paste', + icon: 'clipboard', + shortcut: 'Ctrl+V', + action: handlePasteTrack + }, { + sep: true + }, { + label: 'Delete Track', + icon: 'trash-2', + shortcut: 'Del', + action: () => { + handleDeleteTrack(); + } + }] + }, { + label: 'View', + items: [{ + label: 'Master Track', + icon: 'disc', + action: () => showToast('Master track view', 'info') + }, { + label: 'Maker View', + icon: 'layout', + action: () => showToast('Maker view', 'info') + }, { + label: 'Mixer', + icon: 'sliders', + action: () => showToast('Mixer panel', 'info') + }, { + label: 'Tempo Track', + icon: 'timer', + action: () => showToast('Tempo track', 'info') + }, { + label: 'Video', + icon: 'film', + action: () => showToast('Video panel', 'info') + }, { + label: 'Media Explorer', + icon: 'folder-search', + action: () => showToast('Media explorer', 'info') + }] + }, { + label: 'Tools', + items: [{ + label: 'Config AI Providers...', + icon: 'settings', + action: () => setAiConfigModalOpen(true) + }, { + label: 'Python DSP Tools Panel', + icon: 'wrench', + action: () => openPanel('python_tools') + }] + }, { + label: 'Help', + items: [{ + label: 'About SonicForge', + icon: 'info', + action: () => showToast('SonicForge Studio v1.0 - Professional DAW', 'info') + }] + }].map(menu => /*#__PURE__*/React.createElement("div", { + key: menu.label, + className: "relative" + }, /*#__PURE__*/React.createElement("button", { + onClick: () => setMenuOpen(menuOpen === menu.label ? null : menu.label), + className: `px-3 py-1 text-[11px] font-medium transition rounded ${menuOpen === menu.label ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}` + }, menu.label), menuOpen === menu.label && /*#__PURE__*/React.createElement("div", { + className: "absolute top-full left-0 mt-0.5 bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-52 z-50", + onClick: () => setMenuOpen(null) + }, menu.items.map((item, i) => item.sep ? /*#__PURE__*/React.createElement("div", { + key: i, + className: "h-px bg-zinc-700 my-1" + }) : /*#__PURE__*/React.createElement("button", { + key: item.label, + onClick: e => { + e.stopPropagation(); + item.action(); + setMenuOpen(null); + }, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": item.icon, + className: "w-3.5 h-3.5 text-zinc-500 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, item.label), item.shortcut && /*#__PURE__*/React.createElement("span", { + className: "text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8" + }, item.shortcut)))))), /*#__PURE__*/React.createElement("div", { + className: "flex-1" + }), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-2 px-2" + }, /*#__PURE__*/React.createElement("span", { + className: `text-[9px] font-bold uppercase px-1.5 py-0.5 rounded ${serverStatus === 'connected' ? 'bg-emerald-950 text-emerald-400' : serverStatus === 'checking' ? 'bg-amber-950 text-amber-400' : 'bg-red-950 text-red-400'}` + }, "Server: ", serverStatus), /*#__PURE__*/React.createElement("button", { + onClick: () => setShowAIConfig(!showAIConfig), + className: `px-1.5 py-0.5 rounded text-[10px] border transition ${showAIConfig ? 'bg-purple-900 text-purple-200 border-purple-700' : 'bg-zinc-800 text-zinc-400 border-zinc-700 hover:text-zinc-200'}` + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "cpu", + className: "w-3 h-3" + }))))), menuOpen && /*#__PURE__*/React.createElement("div", { + className: "fixed inset-0 z-40", + onClick: () => setMenuOpen(null) + }), /*#__PURE__*/React.createElement("div", { + className: "h-7 bg-[#222] border-b border-zinc-800 flex items-stretch px-2 gap-0.5 shrink-0 overflow-x-auto" + }, /*#__PURE__*/React.createElement("button", { + onClick: () => setActiveTab('main'), + className: `px-3 text-[10px] font-bold uppercase tracking-wider border-b-2 transition flex items-center gap-1 ${activeTab === 'main' ? 'text-cyan-400 border-cyan-500 bg-zinc-800/50' : 'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'}` + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "layout-dashboard", + className: "w-3 h-3" + })), " Main Session"), subTabs.map(st => /*#__PURE__*/React.createElement("div", { + key: st.id, + className: "flex items-stretch" + }, /*#__PURE__*/React.createElement("button", { + onClick: () => setActiveTab(st.id), + className: `px-2 text-[10px] font-medium border-b-2 transition flex items-center gap-1 ${activeTab === st.id ? 'text-amber-400 border-amber-500 bg-zinc-800/50' : 'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'}` + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "file-edit", + className: "w-3 h-3" + })), /*#__PURE__*/React.createElement("span", { + className: "max-w-[100px] truncate" + }, st.label)), /*#__PURE__*/React.createElement("button", { + onClick: () => closeSubTab(st.id), + className: "px-1 text-zinc-600 hover:text-red-400 transition text-[9px]", + title: "Close tab" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "x", + className: "w-3 h-3" + })))))), showAIConfig && /*#__PURE__*/React.createElement("div", { + className: "bg-zinc-900 border-b border-purple-900 p-3 flex flex-col gap-2 transition-all" + }, /*#__PURE__*/React.createElement("div", { + className: "text-xs font-bold text-purple-400 uppercase tracking-wider flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "cpu", + className: "w-4 h-4" + })), " Cấu hình cổng kết nối API"), /*#__PURE__*/React.createElement("div", { + className: "grid grid-cols-1 md:grid-cols-3 gap-2 text-xs" + }, /*#__PURE__*/React.createElement("div", { + className: "flex flex-col gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[10px] text-zinc-500 font-bold uppercase" + }, "Endpoint Base URL"), /*#__PURE__*/React.createElement("input", { + type: "text", + value: aiConfig.baseUrl, + onChange: e => setAiConfig(prev => ({ + ...prev, + baseUrl: e.target.value + })), + className: "bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-[11px]", + placeholder: "https://api.openai.com/v1" + })), /*#__PURE__*/React.createElement("div", { + className: "flex flex-col gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[10px] text-zinc-500 font-bold uppercase" + }, "API Token Key"), /*#__PURE__*/React.createElement("input", { + type: "password", + value: aiConfig.apiKey, + onChange: e => setAiConfig(prev => ({ + ...prev, + apiKey: e.target.value + })), + className: "bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-[11px]", + placeholder: "sk-..." + })), /*#__PURE__*/React.createElement("div", { + className: "flex flex-col gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[10px] text-zinc-500 font-bold uppercase" + }, "Model Name"), /*#__PURE__*/React.createElement("input", { + type: "text", + value: aiConfig.model, + onChange: e => setAiConfig(prev => ({ + ...prev, + model: e.target.value + })), + className: "bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-[11px]", + placeholder: "gpt-4o-mini" + })))), /*#__PURE__*/React.createElement("div", { + className: "h-9 bg-[#222] border-b border-zinc-800 flex items-center px-3 gap-2 shrink-0 select-none" + }, /*#__PURE__*/React.createElement("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' + } + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-0.5 mr-1 text-zinc-600" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "grip-vertical", + className: "w-3 h-3" + }))), /*#__PURE__*/React.createElement("button", { + onClick: () => { + setActiveTool('select'); + showToast('Select Tool', 'info'); + }, + 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 (V)" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "mouse-pointer", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("button", { + onClick: () => { + setActiveTool('grab'); + showToast('Grab Tool', 'info'); + }, + 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 (H)" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "hand", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-0.5 border border-zinc-700 rounded bg-zinc-850 px-0.5" + }, /*#__PURE__*/React.createElement("button", { + onClick: () => { + setActiveTool('razor'); + showToast('Razor Tool', 'info'); + }, + 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'}`, + title: "Razor Tool (C)" + }, /*#__PURE__*/React.createElement("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" + }, /*#__PURE__*/React.createElement("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" + }), /*#__PURE__*/React.createElement("path", { + d: "M4 9h16l-3 9H7z" + }), /*#__PURE__*/React.createElement("circle", { + cx: "12", + cy: "6", + r: "1" + }))), /*#__PURE__*/React.createElement("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" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "link", + className: "w-3.5 h-3.5" + })))), /*#__PURE__*/React.createElement("button", { + onClick: () => { + setActiveTool('pen'); + showToast('Pen Tool', 'info'); + }, + 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 (P)" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "pen-tool", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("div", { + className: "w-[1px] h-5 bg-zinc-700 mx-0.5" + }), /*#__PURE__*/React.createElement("button", { + onClick: handleCutTrack, + 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)" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "scissors", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("button", { + onClick: handleCopyTrack, + 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)" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "copy", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("button", { + onClick: handlePasteTrack, + 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)" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "clipboard", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("div", { + className: "w-[1px] h-5 bg-zinc-700 mx-0.5" + }), /*#__PURE__*/React.createElement("button", { + onClick: addNewTrack, + className: "px-2 py-1 bg-cyan-600 hover:bg-cyan-500 text-white font-semibold rounded text-xs flex items-center gap-1 shadow transition", + title: "Thêm Track Mới (Ctrl+I)" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "plus", + className: "w-3.5 h-3.5" + })), /*#__PURE__*/React.createElement("span", null, "Track")), /*#__PURE__*/React.createElement("div", { + className: "w-[1px] h-5 bg-zinc-700 mx-0.5" + }), /*#__PURE__*/React.createElement("button", { + onClick: handleUndo, + disabled: undoStack.length === 0, + 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)" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "undo", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("button", { + onClick: handleRedo, + disabled: redoStack.length === 0, + 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)" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "redo", + className: "w-3.5 h-3.5" + })))), /*#__PURE__*/React.createElement("div", { + className: "w-[1px] h-5 bg-zinc-800 mx-0.5" + }), /*#__PURE__*/React.createElement("button", { + onClick: () => { + if (activeTab !== 'main') { + setSubTabs(prev => prev.map(s => s.id === activeTab ? { + ...s, + currentTime: 0 + } : s)); + } else { + setCurrentTime(0); + } + }, + className: "w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", + title: "Quay lại đầu" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "skip-back", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("button", { + onClick: () => { + if (activeTab !== 'main') { + setSubTabs(prev => prev.map(s => { + if (s.id !== activeTab) return s; + const left = s.selectionStart !== null && s.selectionEnd !== null ? Math.min(s.selectionStart, s.selectionEnd) : null; + return left !== null ? { + ...s, + currentTime: left + } : s; + })); + } else { + if (selLeft !== null) setCurrentTime(selLeft); + } + }, + className: "w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", + title: "Đầu vùng chọn" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "step-back", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("button", { + onClick: handlePlayPause, + className: `w-7 h-7 flex items-center justify-center rounded border transition ${isPlaying ? 'bg-emerald-600 text-black border-emerald-500 hover:bg-emerald-500' : 'bg-cyan-600 text-white border-cyan-500 hover:bg-cyan-500'}`, + title: isPlaying ? "Tạm dừng" : "Play" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": isPlaying ? "pause" : "play", + className: "w-3.5 h-3.5 fill-current" + }))), /*#__PURE__*/React.createElement("button", { + onClick: handleStop, + className: "w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", + title: "Stop" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "square", + className: "w-3.5 h-3.5 fill-current" + }))), /*#__PURE__*/React.createElement("button", { + onClick: () => { + if (activeTab !== 'main') { + setSubTabs(prev => prev.map(s => { + if (s.id !== activeTab) return s; + const right = s.selectionStart !== null && s.selectionEnd !== null ? Math.max(s.selectionStart, s.selectionEnd) : null; + return right !== null ? { + ...s, + currentTime: right + } : s; + })); + } else { + if (selRight !== null) setCurrentTime(selRight); + } + }, + className: "w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", + title: "Cuối vùng chọn" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "step-forward", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("button", { + onClick: () => { + if (activeTab !== 'main') { + setSubTabs(prev => prev.map(s => { + if (s.id !== activeTab) return s; + const duration = s.buffer ? s.buffer.duration / (s.speed || 1.0) : 0; + return { + ...s, + currentTime: duration + }; + })); + } else { + setCurrentTime(maxDuration); + } + }, + className: "w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", + title: "Đến cuối" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "skip-forward", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("div", { + className: "w-[1px] h-5 bg-zinc-800 mx-0.5" + }), /*#__PURE__*/React.createElement("button", { + onClick: () => setIsLoopingSelection(prev => !prev), + className: `w-7 h-7 flex items-center justify-center rounded border transition ${isLoopingSelection ? 'bg-amber-600 text-black border-amber-500 hover:bg-amber-500' : 'bg-zinc-800 text-zinc-200 border-zinc-700 hover:bg-zinc-700'}`, + title: "Bật/Tắt Lặp vùng chọn" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "repeat", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("span", { + className: "text-[14px] text-zinc-500 font-bold uppercase ml-2" + }, "Snap"), /*#__PURE__*/React.createElement("select", { + value: snapValue, + onChange: e => setSnapValue(e.target.value), + className: "bg-black text-white text-[14px] px-1.5 py-0.5 rounded border border-zinc-700 focus:outline-none focus:border-cyan-500 font-mono cursor-pointer" + }, /*#__PURE__*/React.createElement("option", { + value: "free" + }, "Free"), /*#__PURE__*/React.createElement("option", { + value: "1" + }, "1"), /*#__PURE__*/React.createElement("option", { + value: "1/2" + }, "1/2"), /*#__PURE__*/React.createElement("option", { + value: "1/4" + }, "1/4"), /*#__PURE__*/React.createElement("option", { + value: "1/8" + }, "1/8"), /*#__PURE__*/React.createElement("option", { + value: "1/16" + }, "1/16"), /*#__PURE__*/React.createElement("option", { + value: "1/32" + }, "1/32")), /*#__PURE__*/React.createElement("div", { + className: "w-[1px] h-6 bg-zinc-800 mx-1.5" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[14px] text-zinc-500 font-bold" + }, "Bars:"), /*#__PURE__*/React.createElement("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; + clearLocalSelection(); + setSelectionMode('global'); + setSelectionStart(t); + setSelectionEnd(t + beatDuration * 4); + }, + className: "w-14 bg-black text-white text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[14px] text-zinc-500" + }, "-"), /*#__PURE__*/React.createElement("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 + beatDuration * 4); + setNumberBar(b - beginBar + 1); + }, + className: "w-14 bg-black text-white text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[14px] text-zinc-500" + }, "#"), /*#__PURE__*/React.createElement("input", { + type: "number", + min: "1", + value: numberBar, + readOnly: true, + className: "w-14 bg-black text-zinc-400 text-[14px] px-1 py-0.5 rounded border border-zinc-800 text-center font-mono" + }), /*#__PURE__*/React.createElement("div", { + className: "w-[1px] h-6 bg-zinc-800 mx-1.5" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[14px] text-zinc-500" + }, "Start:"), /*#__PURE__*/React.createElement("input", { + type: "text", + value: selLeft !== null ? formatTime(selLeft) : '', + onChange: e => { + const parts = e.target.value.split(/[:.]/); + if (parts.length === 3) { + const secs = parseInt(parts[0]) * 60 + parseInt(parts[1]) + parseFloat('0.' + (parts[2] || '0')); + clearLocalSelection(); + setSelectionMode('global'); + setSelectionStart(secs); + } + }, + className: "w-20 bg-black text-zinc-200 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[14px] text-zinc-500" + }, "End:"), /*#__PURE__*/React.createElement("input", { + type: "text", + value: selRight !== null ? formatTime(selRight) : '', + onChange: e => { + const parts = e.target.value.split(/[:.]/); + if (parts.length === 3) { + const secs = parseInt(parts[0]) * 60 + parseInt(parts[1]) + parseFloat('0.' + (parts[2] || '0')); + setSelectionEnd(secs); + } + }, + className: "w-20 bg-black text-zinc-200 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[14px] text-zinc-500" + }, "Len:"), /*#__PURE__*/React.createElement("input", { + type: "text", + value: selLeft !== null && selRight !== null ? formatTime(Math.abs(selRight - selLeft)) : '', + onChange: e => { + const parts = e.target.value.split(/[:.]/); + if (parts.length === 3 && selLeft !== null) { + const secs = parseInt(parts[0]) * 60 + parseInt(parts[1]) + parseFloat('0.' + (parts[2] || '0')); + setSelectionEnd(selLeft + secs); + } + }, + className: "w-20 bg-black text-amber-300 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" + }), /*#__PURE__*/React.createElement("div", { + className: "flex-1" + }), /*#__PURE__*/React.createElement("div", { + className: "text-xs font-bold font-mono text-zinc-100" + }, formatTime(currentTime))), (() => { + const dockPanels = { + top: [], + right: [], + bottom: [], + left: [] + }; + const addPanel = (id, pos, visible) => { + if (visible) dockPanels[pos].push(id); + }; + addPanel('export', panelPositions.export, showExportPanel); + addPanel('ai', panelPositions.ai, showAIPanel); + addPanel('python_tools', panelPositions.python_tools || 'bottom', showPythonToolsPanel); + addPanel('selection', panelPositions.selection, showSelectionPanel); + const closePanel = id => { + if (id === 'export') setShowExportPanel(false);else if (id === 'ai') setShowAIPanel(false);else if (id === 'python_tools') setShowPythonToolsPanel(false);else if (id === 'selection') setShowSelectionPanel(false); + }; + const renderPanelContent = panelId => { + const h = id => e => { + startPanelDrag(id, e); + }; + if (panelId === 'export') return /*#__PURE__*/React.createElement("div", { + className: "flex flex-col h-full gap-1.5" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none", + onMouseDown: e => startPanelDrag('export', e) + }, /*#__PURE__*/React.createElement("h3", { + className: "font-bold text-[10px] text-zinc-200 flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "grip-vertical", + className: "w-3 h-3 text-zinc-500" + })), /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "save", + className: "w-3.5 h-3.5 text-cyan-400" + })), " Export"), /*#__PURE__*/React.createElement("button", { + onClick: () => closePanel('export'), + className: "text-zinc-600 hover:text-zinc-300" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "x", + className: "w-3 h-3" + })))), /*#__PURE__*/React.createElement("div", { + className: "grid grid-cols-3 gap-1" + }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" + }, "SR"), /*#__PURE__*/React.createElement("select", { + value: exportSettings.sampleRate, + onChange: e => setExportSettings(p => ({ + ...p, + sampleRate: e.target.value + })), + className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-[9px] text-zinc-300 focus:outline-none" + }, /*#__PURE__*/React.createElement("option", { + value: "44100" + }, "44.1k"), /*#__PURE__*/React.createElement("option", { + value: "48000" + }, "48k"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" + }, "Bit"), /*#__PURE__*/React.createElement("select", { + value: exportSettings.bitDepth, + onChange: e => setExportSettings(p => ({ + ...p, + bitDepth: e.target.value + })), + className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-[9px] text-zinc-300 focus:outline-none" + }, /*#__PURE__*/React.createElement("option", { + value: "16" + }, "16"), /*#__PURE__*/React.createElement("option", { + value: "24" + }, "24"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" + }, "Fmt"), /*#__PURE__*/React.createElement("select", { + value: exportSettings.format, + onChange: e => setExportSettings(p => ({ + ...p, + format: e.target.value + })), + className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-[9px] text-zinc-300 focus:outline-none" + }, /*#__PURE__*/React.createElement("option", { + value: "wav" + }, "WAV")))), /*#__PURE__*/React.createElement("button", { + onClick: triggerWavExport, + disabled: isExporting, + className: "w-full py-1 bg-cyan-700 hover:bg-cyan-600 text-zinc-100 font-bold rounded text-[10px] flex items-center justify-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "download-cloud", + className: "w-3 h-3" + })), isExporting ? '...' : 'Export')); + if (panelId === 'ai') return /*#__PURE__*/React.createElement("div", { + className: "flex flex-col h-full gap-1.5" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none", + onMouseDown: e => startPanelDrag('ai', e) + }, /*#__PURE__*/React.createElement("h3", { + className: "font-bold text-[10px] text-zinc-200 flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "grip-vertical", + className: "w-3 h-3 text-zinc-500" + })), /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "cpu", + className: "w-3.5 h-3.5 text-purple-400" + })), " AI"), /*#__PURE__*/React.createElement("button", { + onClick: () => closePanel('ai'), + className: "text-zinc-600 hover:text-zinc-300" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "x", + className: "w-3 h-3" + })))), /*#__PURE__*/React.createElement("div", { + className: "p-1.5 bg-[#141414] rounded border border-zinc-800 text-[9px] font-mono min-h-[24px]" + }, /*#__PURE__*/React.createElement("div", { + className: "text-zinc-500" + }, "// ", /*#__PURE__*/React.createElement("span", { + className: "text-zinc-300" + }, analysisState.status)), analysisState.data && /*#__PURE__*/React.createElement("div", { + className: "text-emerald-500 font-semibold" + }, "BPM: ", analysisState.data.bpm)), /*#__PURE__*/React.createElement("div", { + className: "grid grid-cols-3 gap-1" + }, /*#__PURE__*/React.createElement("button", { + onClick: handleAIScan, + className: "py-1 bg-purple-900 hover:bg-purple-800 text-purple-100 font-bold rounded text-[9px] border border-purple-700 flex items-center justify-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "map-pin", + className: "w-3 h-3" + })), " AI Scan"), /*#__PURE__*/React.createElement("button", { + onClick: handleAICutToNewTrack, + disabled: analysisState.isRunning, + className: "py-1 bg-fuchsia-800 hover:bg-fuchsia-700 text-white font-bold rounded text-[9px] flex items-center justify-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "scissors", + className: "w-3 h-3" + })), " AI Cut"), /*#__PURE__*/React.createElement("button", { + onClick: handleAIAnalysicLoop, + disabled: analysisState.isRunning, + className: "py-1 bg-violet-800 hover:bg-violet-700 text-violet-100 font-bold rounded text-[9px] border border-violet-600 flex items-center justify-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "sparkles", + className: "w-3 h-3" + })), " AI Analysic Loop"))); + if (panelId === 'python_tools') return /*#__PURE__*/React.createElement("div", { + className: "flex flex-col h-full gap-1.5" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none", + onMouseDown: e => startPanelDrag('python_tools', e) + }, /*#__PURE__*/React.createElement("h3", { + className: "font-bold text-[10px] text-amber-300 flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "grip-vertical", + className: "w-3 h-3 text-zinc-500" + })), /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "wrench", + className: "w-3.5 h-3.5 text-amber-400" + })), " Python DSP Tools"), /*#__PURE__*/React.createElement("button", { + onClick: () => closePanel('python_tools'), + className: "text-zinc-600 hover:text-zinc-300" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "x", + className: "w-3 h-3" + })))), /*#__PURE__*/React.createElement("div", { + className: "p-1 bg-[#141414] rounded border border-zinc-800 text-[8px] font-mono text-zinc-400" + }, "// Non-AI Audio Processing Tools"), /*#__PURE__*/React.createElement("div", { + className: "grid grid-cols-2 gap-1 text-[9px]" + }, /*#__PURE__*/React.createElement("button", { + onClick: () => runPythonTool('normalize'), + className: "py-1 bg-amber-950/80 hover:bg-amber-900 text-amber-300 border border-amber-800/80 rounded font-bold flex items-center justify-center gap-1" + }, "⚡ Peak Norm (0dB)"), /*#__PURE__*/React.createElement("button", { + onClick: () => runPythonTool('invert_phase'), + className: "py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 border border-zinc-700 rounded font-bold flex items-center justify-center gap-1" + }, "🔄 Phase Invert"), /*#__PURE__*/React.createElement("button", { + onClick: () => runPythonTool('swap_channels'), + className: "py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 border border-zinc-700 rounded font-bold flex items-center justify-center gap-1" + }, "🔀 Swap L/R"), /*#__PURE__*/React.createElement("button", { + onClick: () => runPythonTool('synth_wave'), + className: "py-1 bg-teal-950/80 hover:bg-teal-900 text-teal-300 border border-teal-800/80 rounded font-bold flex items-center justify-center gap-1" + }, "🎹 Gen Synth Tone"))); + if (panelId === 'selection') return /*#__PURE__*/React.createElement("div", { + className: "flex flex-col h-full gap-1.5" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none", + onMouseDown: e => startPanelDrag('selection', e) + }, /*#__PURE__*/React.createElement("span", { + className: "text-[10px] text-zinc-500 font-bold uppercase flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "grip-vertical", + className: "w-3 h-3 text-zinc-500" + })), " Selection"), /*#__PURE__*/React.createElement("button", { + onClick: () => closePanel('selection'), + className: "text-zinc-600 hover:text-zinc-300" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "x", + className: "w-3 h-3" + })))), /*#__PURE__*/React.createElement("div", { + className: "grid grid-cols-3 gap-1 bg-[#141414] p-1.5 rounded border border-zinc-800" + }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", { + className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" + }, "Start"), /*#__PURE__*/React.createElement("input", { + type: "number", + step: "0.01", + value: selectionStats.start, + onChange: e => handleSelectionInputChange('start', e.target.value), + className: "w-full bg-[#242424] text-amber-400 text-center font-mono text-[9px] rounded py-0.5 border border-zinc-700 focus:outline-none" + })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", { + className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" + }, "End"), /*#__PURE__*/React.createElement("input", { + type: "number", + step: "0.01", + value: selectionStats.end, + onChange: e => handleSelectionInputChange('end', e.target.value), + className: "w-full bg-[#242424] text-amber-400 text-center font-mono text-[9px] rounded py-0.5 border border-zinc-700 focus:outline-none" + })), /*#__PURE__*/React.createElement("div", { + className: "flex flex-col justify-center" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[7px] text-zinc-500 font-bold uppercase" + }, "Len"), /*#__PURE__*/React.createElement("span", { + className: "text-zinc-200 font-mono text-[9px] font-semibold mt-0.5" + }, selectionStats.length, "s"))), /*#__PURE__*/React.createElement("div", { + className: "grid grid-cols-3 gap-1 bg-[#141414] p-1.5 rounded border border-zinc-800 mt-1" + }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", { + className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" + }, "Begin Bar"), /*#__PURE__*/React.createElement("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; + clearLocalSelection(); + setSelectionMode('global'); + setSelectionStart(t); + setSelectionEnd(t + beatDuration * 4); + }, + className: "w-full bg-[#242424] text-white text-center font-mono text-[9px] rounded py-0.5 border border-zinc-700 focus:outline-none" + })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", { + className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" + }, "End Bar"), /*#__PURE__*/React.createElement("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 + beatDuration * 4); + setNumberBar(b - beginBar + 1); + }, + className: "w-full bg-[#242424] text-white text-center font-mono text-[9px] rounded py-0.5 border border-zinc-700 focus:outline-none" + })), /*#__PURE__*/React.createElement("div", { + className: "flex flex-col justify-center" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[7px] text-zinc-500 font-bold uppercase" + }, "# Bars"), /*#__PURE__*/React.createElement("span", { + className: "text-zinc-200 font-mono text-[9px] font-semibold mt-0.5" + }, numberBar)))); + return null; + }; + const renderDock = (pos, title) => { + const panels = dockPanels[pos]; + if (panels.length === 0) return null; + const isSide = pos === 'left' || pos === 'right'; + const sideClass = isSide ? 'w-56 shrink-0 overflow-y-auto border-zinc-800' : 'shrink-0'; + const borderClass = pos === 'left' ? 'border-r' : pos === 'right' ? 'border-l' : pos === 'top' ? 'border-b' : 'border-t'; + const bgClass = 'bg-[#1e1e1e]'; + const highlight = panelDragRef.current && panelDropZone === pos; + return /*#__PURE__*/React.createElement("div", { + className: `${sideClass} ${borderClass} ${bgClass} flex ${isSide ? 'flex-col p-2 gap-2' : 'flex-row p-1.5 gap-3'} select-none ${highlight ? 'ring-2 ring-cyan-500 ring-inset' : ''}` + }, panels.map(p => /*#__PURE__*/React.createElement("div", { + key: p, + className: `${isSide ? 'w-full' : 'flex-1 min-w-0'} border border-zinc-700 rounded-lg bg-[#262626] shadow-sm p-2` + }, renderPanelContent(p)))); + }; + return /*#__PURE__*/React.createElement("div", { + ref: workspaceRef, + className: "flex-1 flex flex-col overflow-hidden select-none daw-bg relative" + }, panelDragRef.current && panelDropZone && /*#__PURE__*/React.createElement("div", { + className: "absolute inset-0 z-50 pointer-events-none" + }, panelDropZone === 'top' && /*#__PURE__*/React.createElement("div", { + className: "absolute top-0 left-0 right-0 h-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]" + }), panelDropZone === 'bottom' && /*#__PURE__*/React.createElement("div", { + className: "absolute bottom-0 left-0 right-0 h-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]" + }), panelDropZone === 'left' && /*#__PURE__*/React.createElement("div", { + className: "absolute top-0 bottom-0 left-0 w-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]" + }), panelDropZone === 'right' && /*#__PURE__*/React.createElement("div", { + className: "absolute top-0 bottom-0 right-0 w-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]" + })), dragGhostPanel && dragGhostPos && /*#__PURE__*/React.createElement("div", { + className: "fixed z-[100] pointer-events-none opacity-80 border border-cyan-500 rounded-lg bg-[#262626] shadow-2xl p-3 w-56", + style: { + left: dragGhostPos.x, + top: dragGhostPos.y + } + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-2 text-[10px] text-zinc-200 font-bold" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "move", + className: "w-3.5 h-3.5 text-cyan-400" + })), dragGhostPanel === 'export' ? 'Export Panel' : dragGhostPanel === 'ai' ? 'AI Panel' : dragGhostPanel === 'python_tools' ? 'Audio Processing Panel' : 'Selection Panel'), /*#__PURE__*/React.createElement("div", { + className: "text-[8px] text-zinc-500 mt-1" + }, "Drop at edge to dock")), renderDock('top', 'Top'), /*#__PURE__*/React.createElement("div", { + className: "flex-1 flex overflow-hidden" + }, renderDock('left', 'Left'), /*#__PURE__*/React.createElement("div", { + className: "flex-1 flex flex-col overflow-hidden min-w-0" + }, /*#__PURE__*/React.createElement("div", { + className: "flex-1 flex overflow-hidden" + }, activeTab === 'main' ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { + ref: tcpContainerRef, + onScroll: handleTCPScroll, + className: "w-[300px] shrink-0 z-20 bg-[#262626] overflow-hidden flex flex-col border-r border-zinc-900", + style: { + scrollbarWidth: 'none', + msOverflowStyle: 'none' + } + }, /*#__PURE__*/React.createElement("div", { + className: "sticky top-0 z-50 flex items-center justify-between h-10 border-b border-zinc-900 bg-[#242424] px-2 shrink-0" + }, /*#__PURE__*/React.createElement("span", { + className: "text-xs font-bold text-zinc-300 flex items-center gap-1.5" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "sliders", + className: "w-3.5 h-3.5 text-cyan-400" + })), "TRACKS (", tracks.length, ")"), /*#__PURE__*/React.createElement("button", { + onClick: addNewTrack, + className: "px-2.5 py-1 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-[11px] font-semibold flex items-center gap-1 shadow transition" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "plus", + className: "w-3 h-3" + })), " Add Track")), /*#__PURE__*/React.createElement("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" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center justify-between w-full" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[10px] font-bold text-purple-400 font-mono" + }, "TM"), /*#__PURE__*/React.createElement("span", { + className: "text-xs font-semibold text-zinc-300" + }, "Tempo")), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1" + }, /*#__PURE__*/React.createElement("input", { + type: "number", + value: bpm, + onChange: e => setBpm(e.target.value), + onBlur: () => localStorage.setItem('studio_bpm', bpm), + className: "w-12 bg-zinc-800 border border-zinc-700 rounded text-[10px] text-zinc-300 text-center font-mono focus:outline-none focus:border-purple-500", + min: "40", + max: "300" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[9px] text-zinc-500" + }, "BPM")))), /*#__PURE__*/React.createElement("div", { + className: "flex-1 flex flex-col divide-y divide-[#141414] bg-[#262626]" + }, tracks.length === 0 ? /*#__PURE__*/React.createElement("div", { + className: "p-6 text-center text-zinc-400 flex flex-col items-center justify-center space-y-3" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "plus-circle", + className: "w-8 h-8 text-cyan-400 opacity-80" + })), /*#__PURE__*/React.createElement("p", { + className: "text-xs font-medium" + }, "Chưa có Track nào trong dự án."), /*#__PURE__*/React.createElement("button", { + onClick: addNewTrack, + className: "px-3 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold flex items-center gap-1.5 shadow" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "plus", + className: "w-3.5 h-3.5" + })), " Thêm Track Mới")) : tracks.map((track, idx) => { + const isSelected = selectedTrackId === track.id; + return /*#__PURE__*/React.createElement("div", { + key: track.id, + style: { + height: `${track.height || 96}px` + }, + className: `shrink-0 relative flex flex-col justify-between p-2.5 bg-[#1e1e1e] border-r border-zinc-900 cursor-pointer border-l-4 border-b border-[#141414] overflow-hidden ${isSelected ? 'border-cyan-500 bg-[#252525]' : 'border-transparent hover:bg-zinc-800/20'}`, + onClick: () => setSelectedTrackId(track.id) + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-start justify-between" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[10px] font-bold text-zinc-500 font-mono" + }, (idx + 1).toString().padStart(2, '0')), /*#__PURE__*/React.createElement("label", { + onClick: e => { + e.stopPropagation(); + const el = e.currentTarget.querySelector('input'); + if (el) el.click(); + }, + className: "cursor-pointer" + }, /*#__PURE__*/React.createElement("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" + }), /*#__PURE__*/React.createElement("div", { + className: "w-3 h-3 rounded-full border border-zinc-600 hover:scale-110 transition-transform", + style: { + backgroundColor: track.color + } + })), editingTrackName === track.id ? /*#__PURE__*/React.createElement("input", { + type: "text", + value: editNameInput, + autoFocus: true, + 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" + }) : /*#__PURE__*/React.createElement("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)), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1" + }, /*#__PURE__*/React.createElement("button", { + onClick: e => { + e.stopPropagation(); + toggleTrackMute(track.id); + }, + className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition ${track.muted ? 'bg-red-950 text-red-400 border-red-700' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}` + }, "M"), /*#__PURE__*/React.createElement("button", { + onClick: e => { + e.stopPropagation(); + toggleTrackSoloEvaluate(track.id); + }, + className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition ${soloedTrackId === track.id || track.solo ? 'bg-amber-950 text-amber-400 border-amber-600' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}` + }, "S"), /*#__PURE__*/React.createElement("button", { + onClick: e => { + e.stopPropagation(); + deleteTrack(track.id); + }, + className: "p-0.5 rounded text-red-500 hover:bg-red-950/40 hover:text-red-400 transition" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "trash-2", + className: "w-3.5 h-3.5" + }))))), /*#__PURE__*/React.createElement("div", { + className: "flex flex-col gap-0.5 text-[10px]", + onClick: e => e.stopPropagation() + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "w-8 text-right text-zinc-500 text-[9px]" + }, "Vol:"), /*#__PURE__*/React.createElement("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' + } + }), /*#__PURE__*/React.createElement("span", { + className: "w-12 text-right font-mono text-zinc-300 text-[9px]" + }, track.volumeDb ?? 0, "dB")), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "w-8 text-right text-zinc-500 text-[9px]" + }, "Pan:"), /*#__PURE__*/React.createElement("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' + } + }), /*#__PURE__*/React.createElement("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'))), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1.5 mt-1", + onClick: e => e.stopPropagation() + }, /*#__PURE__*/React.createElement("input", { + type: "file", + id: `upload-${track.id}`, + accept: "audio/*", + className: "hidden", + onChange: e => loadFileOnTrack(track.id, e.target.files[0]) + }), /*#__PURE__*/React.createElement("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" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "upload", + className: "w-3 h-3" + })), " File"), /*#__PURE__*/React.createElement("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" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "wand-2", + className: "w-3 h-3" + })), " FX: ", /*#__PURE__*/React.createElement("span", { + className: "text-zinc-500 font-normal" + }, "None")), /*#__PURE__*/React.createElement("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" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "music", + className: "w-3 h-3" + })), " Synth: ", /*#__PURE__*/React.createElement("span", { + className: "text-zinc-500 font-normal" + }, "None"))), /*#__PURE__*/React.createElement("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() + })); + })), /*#__PURE__*/React.createElement("div", { + className: "h-[48px] border-r border-zinc-900 bg-[#1e1e1e]/50 shrink-0" + })), /*#__PURE__*/React.createElement("div", { + ref: timelineWrapperRef, + onScroll: handleTimelineScroll, + className: "flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0" + }, /*#__PURE__*/React.createElement("div", { + style: { + width: `${timelineWidth}px` + }, + className: "relative flex flex-col min-h-full" + }, /*#__PURE__*/React.createElement("div", { + className: "sticky top-0 z-45 flex h-10 border-b border-zinc-900 bg-[#242424] shrink-0" + }, /*#__PURE__*/React.createElement("div", { + ref: rulerRef, + className: "flex-1 relative h-full flex items-center select-none cursor-ew-resize overflow-hidden", + onMouseDown: handleRulerMouseDown + }, Array.from({ + length: Math.ceil(maxDuration) + }).map((_, i) => { + const sec = i; + const x = sec * zoom; + return /*#__PURE__*/React.createElement("div", { + key: i, + className: "absolute h-full border-l border-zinc-700 pl-1 pt-1 text-[14px] font-mono text-zinc-300 pointer-events-none", + style: { + left: `${x}px` + } + }, formatTimeSimple(sec)); + }))), selectionMode === 'global' && selLeft !== null && selRight !== null && selRight > selLeft && /*#__PURE__*/React.createElement("div", { + className: "absolute inset-0 pointer-events-none z-20", + style: { + left: `${selLeft * zoom}px`, + width: `${(selRight - selLeft) * zoom}px`, + top: '40px' + } + }, /*#__PURE__*/React.createElement("div", { + className: "w-full h-full bg-amber-500/10", + style: { + borderLeft: '1px solid #f59e0b', + borderRight: '1px solid #f59e0b' + } + })), /*#__PURE__*/React.createElement("div", { + className: "sticky top-10 z-35 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] shrink-0" + }, /*#__PURE__*/React.createElement("div", { + className: "flex-1 relative h-full overflow-hidden bg-[#1a1a2e]" + }, /*#__PURE__*/React.createElement(TempoTrackLane, { + bpm: parseInt(bpm) || 120, + zoom: zoom, + timelineWidth: timelineWidth, + viewportWidth: viewportWidth, + onPlayheadSet: handlePlayheadSet, + snapValue: snapValue, + onRulerMouseDown: handleRulerMouseDown + }))), /*#__PURE__*/React.createElement("div", { + className: "flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full" + }, tracks.map((track, idx) => { + const isSelected = selectedTrackId === track.id; + return /*#__PURE__*/React.createElement("div", { + key: track.id, + style: { + height: `${track.height || 96}px` + }, + className: `shrink-0 relative overflow-hidden border-b border-[#141414] hover:bg-zinc-850/5 transition-colors ${isSelected ? 'bg-zinc-800/10' : ''}`, + onDragOver: e => e.preventDefault(), + onDrop: e => { + e.preventDefault(); + if (e.dataTransfer.files[0]) loadFileOnTrack(track.id, e.dataTransfer.files[0]); + }, + onMouseEnter: () => setHoveredTrackId(track.id) + }, /*#__PURE__*/React.createElement(WaveformLane, { + track: track, + zoom: zoom, + timelineWidth: timelineWidth, + onSelectRange: handleSelectRange, + onPlayheadSet: handlePlayheadSet, + isSelected: isSelected, + onSelectTrack: setSelectedTrackId, + markers: track.markers, + onTrackLaneMouseDown: handleTrackLaneMouseDown, + onContextMenu: handleContextMenu, + onClipDragStart: handleClipDragStart, + onClipStretchStart: handleClipStretchStart, + onSelectionEdgeDragStart: handleSelectionEdgeDragStart, + setSelectedClipId: setSelectedClipId, + selectedClipId: selectedClipId, + activeTool: activeTool, + onSplitTrackAtTime: handleSplitTrackAtTime, + onEditClipInSubTab: handleEditClipInSubTab, + snapValue: snapValue, + bpm: bpm, + selectionMode: selectionMode, + localSelectionTrackId: localSelectionTrackId, + localSelectionStart: localSelectionStart, + currentTime: currentTime, + getLocalAnchor: () => localSelectionAnchorRef.current, + onClearLocalSelection: clearLocalSelection, + onSetSelectionMode: setSelectionMode, + onSetSelectionStart: setSelectionStart, + onSetSelectionEnd: setSelectionEnd, + onSetCurrentTime: setCurrentTime, + onSetLocalSelectionTrackId: setLocalSelectionTrackId, + onSetLocalSelectionStart: setLocalSelectionStart, + onSetLocalSelectionEnd: setLocalSelectionEnd, + localSelLeft: localSelectionStart !== null && localSelectionEnd !== null ? Math.min(localSelectionStart, localSelectionEnd) : null, + localSelRight: localSelectionStart !== null && localSelectionEnd !== null ? Math.max(localSelectionStart, localSelectionEnd) : null + }), selectionMode === 'local' && localSelectionTrackId === track.id && localSelectionStart !== null && localSelectionEnd !== null && Math.abs(localSelectionEnd - localSelectionStart) > 0 && /*#__PURE__*/React.createElement("div", { + className: "absolute top-0 bottom-0 border-l border-r border-amber-400 bg-amber-500/20 z-20 pointer-events-none", + style: { + left: `${Math.min(localSelectionStart, localSelectionEnd) * zoom}px`, + width: `${Math.abs(localSelectionEnd - localSelectionStart) * zoom}px` + } + }, /*#__PURE__*/React.createElement("div", { + className: "absolute -left-[1px] top-0 bottom-0 w-[2px] bg-amber-400 z-30 pointer-events-auto cursor-ew-resize", + onMouseDown: e => handleHandleDragStart(e, 'left') + }), /*#__PURE__*/React.createElement("div", { + className: "absolute -right-[1px] top-0 bottom-0 w-[2px] bg-amber-400 z-30 pointer-events-auto cursor-ew-resize", + onMouseDown: e => handleHandleDragStart(e, 'right') + })), track.buffer && /*#__PURE__*/React.createElement("div", { + className: "absolute right-2 top-2 flex items-center gap-1 z-10 opacity-70 hover:opacity-100" + }, /*#__PURE__*/React.createElement("button", { + onClick: () => handleSplitTrack(track.id), + className: "px-1.5 py-0.5 bg-zinc-900/95 text-zinc-300 rounded text-[9px] flex items-center gap-1 border border-zinc-700/50" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "scissors", + className: "w-2.5 h-2.5 text-cyan-400" + })), " Cắt")), /*#__PURE__*/React.createElement("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() + })); + }), /*#__PURE__*/React.createElement("div", { + className: "h-[48px] flex items-center justify-center text-xs text-zinc-500 hover:bg-zinc-900/20 cursor-pointer select-none border-t border-dashed border-zinc-800", + onMouseEnter: () => { + if (draggedClipRef.current) { + setHoveredTrackId(addNewTrack()); + } + }, + onClick: addNewTrack + }, /*#__PURE__*/React.createElement("span", { + className: "flex items-center gap-1 text-zinc-400" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "plus", + className: "w-3.5 h-3.5" + })), " Kéo clip xuống hoặc Click tạo Track")), selectionMode === 'global' && selLeft !== null && selRight !== null && selRight > selLeft && /*#__PURE__*/React.createElement("div", { + className: "absolute top-0 bottom-0 border-l border-r border-amber-500 bg-amber-500/10 z-20 selection-interactive-box cursor-grab active:cursor-grabbing", + style: { + left: `${selLeft * zoom}px`, + width: `${(selRight - selLeft) * zoom}px` + }, + onMouseDown: handleSelectionBodyDragStart + }, /*#__PURE__*/React.createElement("div", { + className: "absolute -left-[1px] top-0 bottom-0 w-[1px] bg-amber-500 hover:bg-amber-400 cursor-ew-resize z-30", + onMouseDown: e => handleHandleDragStart(e, 'left') + }), /*#__PURE__*/React.createElement("div", { + className: "absolute -right-[1px] top-0 bottom-0 w-[1px] bg-amber-500 hover:bg-amber-400 cursor-ew-resize z-30", + onMouseDown: e => handleHandleDragStart(e, 'right') + })), /*#__PURE__*/React.createElement("div", { + className: "absolute top-0 bottom-0 w-[2px] bg-[#ef4444] z-20 pointer-events-none", + style: { + left: `${playheadLeftPos}px` + } + }, /*#__PURE__*/React.createElement("div", { + className: "w-3 h-3 bg-[#ef4444] rotate-45 transform -translate-x-1/2 -translate-y-1/2 absolute top-0" + })))))) : (() => { + 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 /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { + className: "w-[320px] shrink-0 z-30 bg-[#262626] overflow-y-scroll no-scrollbar flex flex-col border-r border-zinc-900", + style: { + scrollbarWidth: 'none', + msOverflowStyle: 'none' + } + }, /*#__PURE__*/React.createElement("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" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[10px] font-bold text-zinc-500 uppercase" + }, "Sub-Tab"), /*#__PURE__*/React.createElement("button", { + onClick: () => closeSubTab(st.id), + className: "px-1.5 py-0.5 text-[9px] bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded border border-zinc-700 flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "x", + className: "w-3 h-3" + })), " Close")), vTrack ? /*#__PURE__*/React.createElement("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" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center justify-between mb-2" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-2" + }, /*#__PURE__*/React.createElement("label", { + onClick: e => { + e.stopPropagation(); + const el = e.currentTarget.querySelector('input'); + if (el) el.click(); + }, + className: "cursor-pointer" + }, /*#__PURE__*/React.createElement("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" + }), /*#__PURE__*/React.createElement("div", { + className: "w-3 h-3 rounded-full border border-zinc-600 hover:scale-110 transition-transform", + style: { + backgroundColor: vTrack.color + } + })), editingTrackName === vTrack.id ? /*#__PURE__*/React.createElement("input", { + type: "text", + value: editNameInput, + autoFocus: true, + 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" + }) : /*#__PURE__*/React.createElement("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)), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1" + }, /*#__PURE__*/React.createElement("button", { + onClick: e => { + e.stopPropagation(); + toggleTrackMute(vTrack.id); + }, + className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border ${vTrack.muted ? 'bg-red-950 text-red-400 border-red-700' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}` + }, "M"), /*#__PURE__*/React.createElement("button", { + onClick: e => { + e.stopPropagation(); + toggleTrackSoloEvaluate(vTrack.id); + }, + 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"))), /*#__PURE__*/React.createElement("div", { + className: "flex flex-col gap-2.5 text-[14px] mb-3" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "w-10 text-right text-zinc-500 text-[14px]" + }, "Vol:"), /*#__PURE__*/React.createElement("input", { + type: "range", + min: "-50", + max: "7", + step: "0.5", + id: `tcp-vol-${st.id}`, + 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' + } + }), /*#__PURE__*/React.createElement("span", { + className: "w-16 text-right font-mono text-zinc-300 text-[14px]", + id: `tcp-vol-label-${st.id}` + }, vTrack.volumeDb ?? 0, "dB")), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "w-10 text-right text-zinc-500 text-[14px]" + }, "Pan:"), /*#__PURE__*/React.createElement("input", { + type: "range", + min: "-100", + max: "100", + step: "1", + id: `tcp-pan-${st.id}`, + 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' + } + }), /*#__PURE__*/React.createElement("span", { + className: "w-16 text-right font-mono text-zinc-300 text-[14px]", + id: `tcp-pan-label-detailed-${st.id}` + }, vTrack.pan > 0 ? 'R' + vTrack.pan : vTrack.pan < 0 ? 'L' + Math.abs(vTrack.pan) : 'C')), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1.5 text-[14px]" + }, /*#__PURE__*/React.createElement("button", { + onClick: () => setSubTabs(prev => prev.map(s => s.id === st.id ? { + ...s, + isLooping: !s.isLooping + } : s)), + className: `px-2 py-0.5 text-[14px] rounded border font-bold ${st.isLooping ? 'bg-amber-700 text-white border-amber-500' : 'bg-zinc-800 text-zinc-400 border-zinc-700'}` + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "repeat", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("button", { + onClick: () => updateSubTabEffects(st.id, { + reverse: !(st.effects || {}).reverse + }), + className: `px-2 py-0.5 rounded border text-[14px] font-bold ${(st.effects || {}).reverse ? 'bg-zinc-600 text-white border-zinc-500' : 'bg-zinc-800 text-zinc-400 border-zinc-700'} flex items-center justify-center`, + title: "Reverse" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "arrow-left-right", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("span", { + className: "w-10 text-right text-zinc-500 text-[14px]" + }, "Loop:"), /*#__PURE__*/React.createElement("input", { + type: "number", + min: "0", + max: "999", + value: st.loopCount || 0, + onChange: e => setSubTabs(prev => prev.map(s => s.id === st.id ? { + ...s, + loopCount: Math.max(0, parseInt(e.target.value) || 0) + } : s)), + className: "w-12 bg-black text-amber-300 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono", + title: "Loop count" + }))), /*#__PURE__*/React.createElement("div", { + className: "flex gap-1 justify-between my-2.5" + }, /*#__PURE__*/React.createElement("div", { + className: "flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[14px] font-bold text-zinc-400 uppercase tracking-tighter", + title: "Normalize" + }, "Norm"), /*#__PURE__*/React.createElement("input", { + type: "range", + min: "-12", + max: "0", + step: "0.1", + value: subTabNormVal, + onChange: e => setSubTabNormVal(parseFloat(e.target.value)), + style: { + writingMode: 'vertical-lr', + direction: 'rtl', + height: '120px', + width: '20px', + accentColor: '#a1a1aa' + }, + className: "my-2 cursor-pointer" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[14px] font-mono text-zinc-300 font-bold" + }, subTabNormVal, "dB"), /*#__PURE__*/React.createElement("button", { + onClick: () => applySubTabEffect(st.id, 'normalize', subTabNormVal), + className: "mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650" + }, "Apply")), /*#__PURE__*/React.createElement("div", { + className: "flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[14px] font-bold text-zinc-400 uppercase tracking-tighter", + title: "Pitch Shift" + }, "Pitch"), /*#__PURE__*/React.createElement("input", { + type: "range", + min: "-12", + max: "12", + step: "0.5", + value: subTabPitchVal, + onChange: e => setSubTabPitchVal(parseFloat(e.target.value)), + style: { + writingMode: 'vertical-lr', + direction: 'rtl', + height: '120px', + width: '20px', + accentColor: '#a1a1aa' + }, + className: "my-2 cursor-pointer" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[14px] font-mono text-zinc-300 font-bold" + }, subTabPitchVal > 0 ? '+' : '', subTabPitchVal, "st"), /*#__PURE__*/React.createElement("button", { + onClick: () => applySubTabEffect(st.id, 'pitch', subTabPitchVal), + className: "mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650" + }, "Apply")), /*#__PURE__*/React.createElement("div", { + className: "flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[14px] font-bold text-zinc-400 uppercase tracking-tighter", + title: "Gain Multiplier" + }, "Gain"), /*#__PURE__*/React.createElement("input", { + type: "range", + min: "0", + max: "150", + step: "1", + value: subTabGainVal, + onChange: e => setSubTabGainVal(parseInt(e.target.value)), + style: { + writingMode: 'vertical-lr', + direction: 'rtl', + height: '120px', + width: '20px', + accentColor: '#a1a1aa' + }, + className: "my-2 cursor-pointer" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[14px] font-mono text-zinc-300 font-bold" + }, subTabGainVal, "%"), /*#__PURE__*/React.createElement("button", { + onClick: () => applySubTabEffect(st.id, 'gain', subTabGainVal), + className: "mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650" + }, "Apply"))), /*#__PURE__*/React.createElement("div", { + className: "flex-1" + }), /*#__PURE__*/React.createElement("div", { + className: "mt-auto pt-2.5 border-t border-zinc-800" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center justify-between text-[14px] text-zinc-500 mb-1 font-semibold uppercase" + }, /*#__PURE__*/React.createElement("span", null, "Duration:"), /*#__PURE__*/React.createElement("span", { + className: "font-mono text-zinc-300" + }, st.buffer ? formatTime(st.buffer.duration) : '0s')), /*#__PURE__*/React.createElement("div", { + className: "flex items-center justify-between text-[14px] text-zinc-500 mb-2.5 font-semibold uppercase" + }, /*#__PURE__*/React.createElement("span", null, "SR:"), /*#__PURE__*/React.createElement("span", { + className: "font-mono text-zinc-300" + }, st.buffer ? st.buffer.sampleRate : 0, " Hz")), /*#__PURE__*/React.createElement("div", { + className: "grid grid-cols-2 gap-1 mb-2" + }, /*#__PURE__*/React.createElement("button", { + onClick: async e => { + subTabAiTrackIdRef.current = st.trackId; + handleAIScan(); + }, + disabled: analysisState.isRunning, + className: "py-1 bg-purple-900 hover:bg-purple-800 text-purple-100 font-bold rounded text-[12px] border border-purple-700 flex items-center justify-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "map-pin", + className: "w-3 h-3" + })), " Scan"), /*#__PURE__*/React.createElement("button", { + onClick: async e => { + subTabAiTrackIdRef.current = st.trackId; + handleAICutToNewTrack(); + }, + disabled: analysisState.isRunning, + className: "py-1 bg-fuchsia-800 hover:bg-fuchsia-700 text-white font-bold rounded text-[12px] flex items-center justify-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "scissors", + className: "w-3 h-3" + })), " Cut"), /*#__PURE__*/React.createElement("button", { + onClick: async e => { + subTabAiTrackIdRef.current = st.trackId; + if (st.buffer) { + setSelectionRangeOnBuffer(st.buffer, st.selectionStart || 0, st.selectionEnd || st.buffer.duration); + } + handleAIAnalysicLoop(); + }, + disabled: analysisState.isRunning, + className: "py-1 bg-violet-800 hover:bg-violet-700 text-violet-100 font-bold rounded text-[12px] border border-violet-600 flex items-center justify-center gap-1 col-span-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "sparkles", + className: "w-3 h-3" + })), " AI Analysic Loop")), /*#__PURE__*/React.createElement("button", { + onClick: () => exportSubTabBuffer(st.id), + className: "w-full py-1.5 mb-1.5 bg-blue-700 hover:bg-blue-600 text-white font-bold rounded text-[14px] flex items-center justify-center gap-1 transition" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "download", + className: "w-4 h-4" + })), " Export"), /*#__PURE__*/React.createElement("button", { + onClick: () => applySubTab(st.id), + className: "w-full py-1.5 bg-emerald-700 hover:bg-emerald-600 text-white font-bold rounded text-[14px] flex items-center justify-center gap-1 transition" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "save", + className: "w-4 h-4" + })), " Save"))) : /*#__PURE__*/React.createElement("div", { + className: "flex-1 flex items-center justify-center text-[10px] text-zinc-500" + }, "Track not found")), /*#__PURE__*/React.createElement("div", { + ref: timelineWrapperRef, + className: "flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0" + }, /*#__PURE__*/React.createElement("div", { + style: { + width: `${subTabTimelineWidth}px` + }, + className: "relative flex flex-col min-h-full" + }, /*#__PURE__*/React.createElement("div", { + className: "sticky top-0 z-45 flex h-10 border-b border-zinc-900 bg-[#242424] shrink-0" + }, /*#__PURE__*/React.createElement("div", { + className: "flex-1 relative h-full flex items-center select-none cursor-ew-resize overflow-hidden", + onMouseDown: handleRulerMouseDown + }, Array.from({ + length: Math.ceil(st.buffer ? st.buffer.duration : 0) + }).map((_, i) => { + const sec = i; + const x = sec * zoom; + return /*#__PURE__*/React.createElement("div", { + key: i, + className: "absolute h-full border-l border-zinc-700 pl-1 pt-1 text-[14px] font-mono text-zinc-300 pointer-events-none", + style: { + left: `${x}px` + } + }, formatTimeSimple(sec)); + }))), /*#__PURE__*/React.createElement("div", { + className: "sticky top-10 z-35 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] shrink-0" + }, /*#__PURE__*/React.createElement("div", { + className: "flex-1 relative h-full overflow-hidden bg-[#1a1a2e]" + }, /*#__PURE__*/React.createElement(TempoTrackLane, { + bpm: parseInt(bpm) || 120, + zoom: zoom, + timelineWidth: subTabTimelineWidth, + onPlayheadSet: setCurrentTime, + snapValue: snapValue + }))), /*#__PURE__*/React.createElement("div", { + className: "flex-1 flex flex-col relative bg-[#111111] min-h-full" + }, vTrack && /*#__PURE__*/React.createElement("div", { + style: { + height: `${subTabHeight}px` + }, + className: "relative overflow-hidden border-2 border-purple-500/40 rounded bg-[#151515] mx-1 mt-1 shrink-0" + }, /*#__PURE__*/React.createElement(SubTabWaveform, { + buffer: st.buffer, + subTabId: st.id, + activeTab: activeTab, + activeTool: activeTool, + currentTime: st.currentTime, + selectionStart: st.selectionStart, + selectionEnd: st.selectionEnd, + 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, + color: vTrack.color, + name: vTrack.name, + speed: st.speed || 1.0, + volumeNodes: st.volumeNodes || [], + panningNodes: st.panningNodes || [], + fadeInLen: st.fadeInLen || 0, + fadeOutLen: st.fadeOutLen || 0, + graphMode: st.graphMode, + channelInfo: st.channelInfo, + selectedNodeTime: subTabSelectedNodeTime, + setSelectedNodeTime: setSubTabSelectedNodeTime, + onUpdateNodes: nodes => setSubTabs(prev => prev.map(s => s.id === st.id ? { + ...s, + [s.graphMode === 'pan' ? 'panningNodes' : 'volumeNodes']: nodes + } : s)), + onUpdateFade: fade => setSubTabs(prev => prev.map(s => s.id === st.id ? { + ...s, + fadeInLen: fade.fadeInLen ?? s.fadeInLen, + fadeOutLen: fade.fadeOutLen ?? s.fadeOutLen + } : s)), + onModeToggle: () => setSubTabs(prev => prev.map(s => s.id === st.id ? { + ...s, + graphMode: s.graphMode === 'pan' ? null : 'pan' + } : s)), + onSpeedChange: newSpeed => { + setSubTabs(prev => prev.map(s => { + if (s.id !== st.id) return s; + const oldSpeed = s.speed || 1.0; + const ratio = oldSpeed / newSpeed; + const newVolumeNodes = (s.volumeNodes || []).map(n => ({ + ...n, + time: n.time * ratio + })); + const newPanningNodes = (s.panningNodes || []).map(n => ({ + ...n, + time: n.time * ratio + })); + return { + ...s, + speed: newSpeed, + volumeNodes: newVolumeNodes, + panningNodes: newPanningNodes, + fadeInLen: (s.fadeInLen || 0) * ratio, + fadeOutLen: (s.fadeOutLen || 0) * ratio, + currentTime: (s.currentTime || 0) * ratio, + label: s.label.replace(/\s\(\d+%\)$/, '') + ` (${Math.round(newSpeed * 100)}%)` + }; + })); + const n = activeTrackNodesRef.current[st.trackId]; + if (n && n.source) n.source.playbackRate.value = newSpeed; + // Reset time refs to prevent playhead jump when speed changes mid-playback + const ctx = getAudioContext(); + const elapsed = ctx.currentTime - startAudioTimeRef.current; + const oldSpeed = activePlaybackSpeedRef.current; + const ratio = oldSpeed / newSpeed; + startBufferOffsetRef.current = startBufferOffsetRef.current + elapsed * oldSpeed; + startOffsetTimeRef.current = (startOffsetTimeRef.current + elapsed) * ratio; + startAudioTimeRef.current = ctx.currentTime; + activePlaybackSpeedRef.current = newSpeed; + } + }), /*#__PURE__*/React.createElement("div", { + onMouseDown: handleSubTabResizeMouseDown, + className: "absolute bottom-0 left-0 right-0 h-1.5 cursor-ns-resize z-30 hover:bg-purple-500/50 transition-colors" + })), st.selectionStart !== null && st.selectionEnd !== null && st.selectionEnd > st.selectionStart && /*#__PURE__*/React.createElement("div", { + className: "absolute top-0 bottom-0 border-l border-r border-amber-500 bg-amber-500/10 z-20 pointer-events-none", + style: { + left: `${Math.min(st.selectionStart, st.selectionEnd) * zoom}px`, + width: `${Math.abs(st.selectionEnd - st.selectionStart) * zoom}px` + } + }))))); + })())), renderDock('right', 'Right')), renderDock('bottom', 'Bottom')); + })(), /*#__PURE__*/React.createElement("div", { + className: "h-6 bg-[#141414] border-t border-zinc-900 px-4 flex items-center justify-between text-[10px] text-zinc-500 select-none shrink-0" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-4" + }, /*#__PURE__*/React.createElement("span", null, "Status: ", isPlaying ? 'Playing' : 'Stopped'), activeTab !== 'main' && /*#__PURE__*/React.createElement("span", { + className: "text-amber-400 font-semibold uppercase" + }, "Sub-Tab"), activeTab === 'main' && /*#__PURE__*/React.createElement("span", { + className: "text-cyan-400 font-semibold uppercase" + }, "Track: ID ", selectedTrackId), selectionMode === 'local' && /*#__PURE__*/React.createElement("span", { + className: "text-amber-400 font-semibold uppercase text-[9px]" + }, "Local Sel"), selectionMode === 'global' && /*#__PURE__*/React.createElement("span", { + className: "text-purple-400 font-semibold uppercase text-[9px]" + }, "Global Sel"), soloedTrackId && /*#__PURE__*/React.createElement("span", { + className: "text-amber-500 font-semibold" + }, "Solo: ID ", soloedTrackId), isLoopingSelection && selectionMode === 'local' && /*#__PURE__*/React.createElement("span", { + className: "text-emerald-400 font-semibold uppercase text-[9px]" + }, "Solo Loop"), isLoopingSelection && selectionMode !== 'local' && /*#__PURE__*/React.createElement("span", { + className: "text-cyan-400 font-semibold uppercase text-[9px]" + }, "Master Loop")), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-2" + }, /*#__PURE__*/React.createElement("button", { + onClick: () => setShowExportPanel(p => !p), + className: `px-1.5 py-0.5 rounded text-[9px] font-semibold transition flex items-center gap-1 ${showExportPanel ? 'bg-cyan-900 text-cyan-300' : 'text-zinc-500 hover:text-zinc-300'}`, + title: `Export Panel (${panelPositions.export})` + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "save", + className: "w-3 h-3" + })), /*#__PURE__*/React.createElement("span", { + className: "text-[7px] opacity-60" + }, showExportPanel ? panelPositions.export[0].toUpperCase() : '')), /*#__PURE__*/React.createElement("button", { + onClick: () => setShowSelectionPanel(p => !p), + className: `px-1.5 py-0.5 rounded text-[9px] font-semibold transition flex items-center gap-1 ${showSelectionPanel ? 'bg-amber-900 text-amber-300' : 'text-zinc-500 hover:text-zinc-300'}`, + title: `Selection Panel (${panelPositions.selection})` + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "sliders", + className: "w-3 h-3" + })), /*#__PURE__*/React.createElement("span", { + className: "text-[7px] opacity-60" + }, showSelectionPanel ? panelPositions.selection[0].toUpperCase() : '')), /*#__PURE__*/React.createElement("button", { + onClick: () => setShowAIPanel(p => !p), + className: `px-1.5 py-0.5 rounded text-[9px] font-semibold transition flex items-center gap-1 ${showAIPanel ? 'bg-purple-900 text-purple-300' : 'text-zinc-500 hover:text-zinc-300'}`, + title: `AI Panel (${panelPositions.ai})` + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "cpu", + className: "w-3 h-3" + })), /*#__PURE__*/React.createElement("span", { + className: "text-[7px] opacity-60" + }, showAIPanel ? panelPositions.ai[0].toUpperCase() : '')), /*#__PURE__*/React.createElement("span", { + className: "w-[1px] h-3 bg-zinc-800 mx-1" + }), /*#__PURE__*/React.createElement("span", { + className: "flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "info", + className: "w-3 h-3 text-zinc-600" + })), " Scroll: Zoom"), /*#__PURE__*/React.createElement("span", null, "|"), /*#__PURE__*/React.createElement("span", { + className: "flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "keyboard", + className: "w-3 h-3 text-zinc-600" + })), " Ctrl+Scroll: Playhead"))), contextMenu && (contextMenu.isSubTab ? /*#__PURE__*/React.createElement("div", { + className: "fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64", + style: { + left: contextMenu.x, + top: contextMenu.y + }, + onClick: e => e.stopPropagation() + }, /*#__PURE__*/React.createElement("div", { + className: "px-3 py-1 text-[10px] text-zinc-500 font-bold uppercase border-b border-zinc-800 pb-1 mb-1" + }, "Selection: ", formatTime(subTabs.find(s => s.id === contextMenu.subTabId)?.selectionStart || 0), " - ", formatTime(subTabs.find(s => s.id === contextMenu.subTabId)?.selectionEnd || 0)), /*#__PURE__*/React.createElement("div", { + className: "h-px bg-zinc-700 my-1" + }), /*#__PURE__*/React.createElement("button", { + onClick: () => { + handleSubTabCut(contextMenu.subTabId); + closeContextMenu(); + }, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "scissors", + className: "w-3.5 h-3.5 text-rose-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Cut"), /*#__PURE__*/React.createElement("span", { + className: "text-purple-400 text-[12px] font-semibold font-mono ml-auto" + }, "Ctrl+X")), /*#__PURE__*/React.createElement("button", { + onClick: () => { + handleSubTabCopy(contextMenu.subTabId); + closeContextMenu(); + }, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "copy", + className: "w-3.5 h-3.5 text-zinc-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Copy"), /*#__PURE__*/React.createElement("span", { + className: "text-purple-400 text-[12px] font-semibold font-mono ml-auto" + }, "Ctrl+C")), /*#__PURE__*/React.createElement("button", { + onClick: () => { + handleSubTabPaste(contextMenu.subTabId); + closeContextMenu(); + }, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "clipboard", + className: "w-3.5 h-3.5 text-emerald-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Paste"), /*#__PURE__*/React.createElement("span", { + className: "text-purple-400 text-[12px] font-semibold font-mono ml-auto" + }, "Ctrl+V")), /*#__PURE__*/React.createElement("button", { + onClick: () => { + handleSubTabDelete(contextMenu.subTabId); + closeContextMenu(); + }, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "trash-2", + className: "w-3.5 h-3.5 text-red-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Delete Selected Segment"), /*#__PURE__*/React.createElement("span", { + className: "text-amber-400 text-[12px] font-semibold font-mono ml-auto" + }, "Del")), /*#__PURE__*/React.createElement("div", { + className: "h-px bg-zinc-700 my-1" + }), /*#__PURE__*/React.createElement("button", { + onClick: () => { + handleSubTabLoop(contextMenu.subTabId, 4); + closeContextMenu(); + }, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "repeat", + className: "w-3.5 h-3.5 text-cyan-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Loop Selection 4 times"), /*#__PURE__*/React.createElement("span", { + className: "text-purple-400 text-[12px] font-semibold font-mono ml-auto" + }, "Ctrl+L"))) : /*#__PURE__*/React.createElement("div", { + className: "fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64", + style: { + left: contextMenu.x, + top: contextMenu.y + }, + onClick: e => e.stopPropagation() + }, /*#__PURE__*/React.createElement("button", { + onClick: contextMenuEdit, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "file-edit", + className: "w-3.5 h-3.5 text-amber-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Edit"), /*#__PURE__*/React.createElement("span", { + className: "text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8" + }, "Ctrl+E")), /*#__PURE__*/React.createElement("button", { + onClick: contextMenuSplit, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "scissors", + className: "w-3.5 h-3.5 text-cyan-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Split"), /*#__PURE__*/React.createElement("span", { + className: "text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8" + }, "S")), /*#__PURE__*/React.createElement("button", { + onClick: contextMenuMerge, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "combine", + className: "w-3.5 h-3.5 text-purple-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Merge"), /*#__PURE__*/React.createElement("span", { + className: "text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8" + }, "Ctrl+M")), /*#__PURE__*/React.createElement("div", { + className: "h-px bg-zinc-700 my-1" + }), /*#__PURE__*/React.createElement("button", { + onClick: contextMenuCopy, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "copy", + className: "w-3.5 h-3.5 text-zinc-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Copy"), /*#__PURE__*/React.createElement("span", { + className: "text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8" + }, "Ctrl+C")), /*#__PURE__*/React.createElement("button", { + onClick: contextMenuCut, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "scissors", + className: "w-3.5 h-3.5 text-rose-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Cut"), /*#__PURE__*/React.createElement("span", { + className: "text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8" + }, "Ctrl+X")), /*#__PURE__*/React.createElement("button", { + onClick: contextMenuPaste, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "clipboard", + className: "w-3.5 h-3.5 text-emerald-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Paste"), /*#__PURE__*/React.createElement("span", { + className: "text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8" + }, "Ctrl+V")), /*#__PURE__*/React.createElement("div", { + className: "h-px bg-zinc-700 my-1" + }), /*#__PURE__*/React.createElement("button", { + onClick: contextMenuDelete, + className: "w-full px-3 py-1.5 text-xs text-red-300 hover:bg-red-950 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "trash-2", + className: "w-3.5 h-3.5 text-red-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Delete"), /*#__PURE__*/React.createElement("span", { + className: "text-amber-400 text-[12px] font-semibold font-mono ml-auto pl-8" + }, "Del")))), toastMessage && /*#__PURE__*/React.createElement("div", { + className: "absolute top-14 right-4 z-50 px-4 py-2.5 rounded shadow-lg text-xs font-semibold flex items-center gap-2 border bg-zinc-900 text-zinc-100 border-zinc-800" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "bell", + className: `w-4 h-4 ${toastMessage.type === 'success' ? 'text-emerald-400' : toastMessage.type === 'error' ? 'text-rose-400' : toastMessage.type === 'warning' ? 'text-amber-400' : 'text-cyan-400'}` + })), toastMessage.text), /*#__PURE__*/React.createElement(AuthModal, { + isOpen: authModalOpen, + mode: authMode, + forceMandatory: isMandatoryLogin, + onClose: () => setAuthModalOpen(false), + onSuccess: handleAuthSuccess + }), /*#__PURE__*/React.createElement(ProfileModal, { + isOpen: profileModalOpen, + onClose: () => setProfileModalOpen(false) + }), /*#__PURE__*/React.createElement(AIConfigModal, { + isOpen: aiConfigModalOpen, + onClose: () => setAiConfigModalOpen(false) + }), /*#__PURE__*/React.createElement(SystemManagerModal, { + isOpen: systemManagerModalOpen, + onClose: () => setSystemManagerModalOpen(false) + })); +}; +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render(/*#__PURE__*/React.createElement(App, null)); +setTimeout(() => lucide.createIcons(), 300); diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js new file mode 100644 index 0000000..d287e12 --- /dev/null +++ b/app/static/js/app.precompiled.js @@ -0,0 +1,9235 @@ +const { + useState, + useRef, + useEffect, + useMemo +} = React; + +// ── FastAPI Backend Configuration ── +const API_BASE_URL = window.location.origin; +const API_AUDIO = `${API_BASE_URL}/api/v1/audio`; +const API_MULTITRACK = `${API_BASE_URL}/api/v1/multitrack`; +const API_TASKS = `${API_BASE_URL}/api/v1/audio/tasks`; + +// Handle ?sfs= from double-clicking a .sfs file (opens domain -> loads project) +(function handleSfsDeepLink() { + try { + const params = new URLSearchParams(window.location.search); + const sfsParam = params.get('sfs'); + if (!sfsParam) return; + const decoded = JSON.parse(decodeURIComponent(sfsParam)); + window.__pendingSfsProject = decoded; // consumed after auth in App + if (window.history.replaceState) { + window.history.replaceState({}, document.title, window.location.pathname); + } + } catch (e) { + window.__pendingSfsProject = null; + } +})(); + +// Storage for server-side file IDs mapped to track IDs +let serverFileIdMap = {}; +let audioCtx; +function getAudioContext() { + if (!audioCtx) { + audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + if (window.SonicAudio && window.SonicAudio.initAudioWorklet) { + window.SonicAudio.initAudioWorklet(); + } + } + if (audioCtx.state === 'suspended') { + audioCtx.resume(); + } + return audioCtx; +} +const formatTime = secs => { + if (isNaN(secs) || secs < 0) return "0:00.000"; + const m = Math.floor(secs / 60); + const s = Math.floor(secs % 60); + const ms = Math.floor(secs % 1 * 1000).toString().padStart(3, '0'); + return `${m}:${s.toString().padStart(2, '0')}.${ms}`; +}; +const formatTimeSimple = secs => { + if (isNaN(secs) || secs < 0) return "0.00s"; + return `${secs.toFixed(2)}s`; +}; +const formatBeat = (secs, bpmVal) => { + if (isNaN(secs) || secs < 0) return "1.1.1"; + const beatDuration = 60 / bpmVal; + const barDuration = beatDuration * 4; + const bar = Math.floor(secs / barDuration) + 1; + const beat = Math.floor(secs % barDuration / beatDuration) + 1; + const sub = Math.floor(secs % beatDuration / (beatDuration / 4)) + 1; + return `${bar}.${beat}.${sub}`; +}; +const getBeatMarkers = (maxDur, bpmVal) => { + const beatDuration = 60 / bpmVal; + const barDuration = beatDuration * 4; + const markers = []; + for (let t = 0; t <= maxDur; t += beatDuration) { + const isBar = Math.abs(t % barDuration) < 0.001 || Math.abs(t % barDuration - barDuration) < 0.001; + markers.push({ + time: t, + isBar, + beatNum: Math.floor(t / beatDuration) + 1 + }); + } + return markers; +}; +const findZeroCrossing = (buffer, targetTime) => { + if (!buffer) return targetTime; + const sampleRate = buffer.sampleRate; + const data = buffer.getChannelData(0); + const targetSample = Math.floor(targetTime * sampleRate); + const windowSize = Math.floor(0.04 * sampleRate); + const start = Math.max(0, targetSample - windowSize); + const end = Math.min(data.length - 2, targetSample + windowSize); + let bestSample = targetSample; + let minDistance = Infinity; + for (let i = start; i <= end; i++) { + if (data[i] >= 0 && data[i + 1] <= 0 || data[i] <= 0 && data[i + 1] >= 0) { + const dist = Math.abs(i - targetSample); + if (dist < minDistance) { + minDistance = dist; + bestSample = i; + } + } + } + return bestSample / sampleRate; +}; +const VolumeKnob = ({ + value, + onChange, + min = 0, + max = 1 +}) => { + const [isDragging, setIsDragging] = useState(false); + const startY = useRef(0); + const startValue = useRef(0); + const rotation = useMemo(() => { + const percent = (value - min) / (max - min); + return -135 + percent * 270; + }, [value, min, max]); + const handleMouseDown = e => { + setIsDragging(true); + startY.current = e.clientY; + startValue.current = value; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + const handleMouseMove = e => { + const deltaY = startY.current - e.clientY; + const sensitivity = 0.005; + const newValue = Math.max(min, Math.min(max, startValue.current + deltaY * sensitivity)); + onChange(parseFloat(newValue.toFixed(2))); + }; + const handleMouseUp = () => { + setIsDragging(false); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + return /*#__PURE__*/React.createElement("div", { + className: "knob-container cursor-ns-resize flex flex-col items-center", + onMouseDown: handleMouseDown, + title: `Volume: ${Math.round(value * 100)}%` + }, /*#__PURE__*/React.createElement("svg", { + className: "w-7 h-7", + viewBox: "0 0 40 40" + }, /*#__PURE__*/React.createElement("circle", { + cx: "20", + cy: "20", + r: "16", + fill: "#141414", + stroke: "#444", + strokeWidth: "2" + }), /*#__PURE__*/React.createElement("g", { + transform: `rotate(${rotation} 20 20)`, + className: "knob-dial" + }, /*#__PURE__*/React.createElement("line", { + x1: "20", + y1: "20", + x2: "20", + y2: "6", + stroke: "#ef4444", + strokeWidth: "3", + strokeLinecap: "round" + })))); +}; +const WaveformLane = ({ + track, + zoom, + timelineWidth, + viewportWidth, + onSelectRange, + onPlayheadSet, + isSelected, + onSelectTrack, + markers, + selectionMode, + localSelectionTrackId, + localSelectionStart, + currentTime, + getLocalAnchor, + onClearLocalSelection, + onSetSelectionMode, + onSetSelectionStart, + onSetSelectionEnd, + onSetCurrentTime, + onSetLocalSelectionTrackId, + onSetLocalSelectionStart, + onSetLocalSelectionEnd, + localSelLeft, + localSelRight, + onTrackLaneMouseDown, + onContextMenu, + onClipDragStart, + onClipStretchStart, + onSelectionEdgeDragStart, + setSelectedClipId, + selectedClipId, + activeTool, + onSplitTrackAtTime, + onEditClipInSubTab, + snapValue, + bpm +}) => { + const canvasRef = useRef(null); + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null; + + // Viewport Virtualization: canvas pixel width is strictly capped to visible window width (e.g. 1200px) + const scrollLeft = wrapper ? wrapper.scrollLeft : 0; + const vWidth = viewportWidth || (wrapper ? wrapper.clientWidth : 1200); + const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200)); + const height = canvas.parentElement ? canvas.parentElement.clientHeight : 96; + canvas.width = Math.min(Math.round(drawWidth * dpr), 32768); + canvas.height = Math.min(Math.round(height * dpr), 32768); + ctx.scale(dpr, dpr); + ctx.imageSmoothingEnabled = false; + + // Position canvas element at current scrollLeft inside track container + canvas.style.position = 'absolute'; + canvas.style.left = `${scrollLeft}px`; + canvas.style.width = `${drawWidth}px`; + canvas.style.height = `${height}px`; + ctx.fillStyle = isSelected ? '#2a2a2a' : track.id % 2 === 0 ? '#181818' : '#1d1d1d'; + ctx.fillRect(0, 0, drawWidth, height); + + // Grid lines based on Snap value + ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)'; + ctx.lineWidth = 1; + const tStart = scrollLeft / zoom; + const tEnd = (scrollLeft + drawWidth) / zoom; + let gridSpacing = 1.0; + if (snapValue && snapValue !== 'free') { + const beatDuration = 60 / parseFloat(bpm || 120); + let divisor = 1; + if (snapValue === '1') divisor = 1;else if (snapValue === '1/2') divisor = 0.5;else if (snapValue === '1/4') divisor = 0.25;else if (snapValue === '1/8') divisor = 0.125;else if (snapValue === '1/16') divisor = 0.0625;else if (snapValue === '1/32') divisor = 0.03125; + gridSpacing = beatDuration * divisor; + } else { + gridSpacing = 60 / parseFloat(bpm || 120); + } + let drawSpacing = gridSpacing; + while (drawSpacing * zoom < 10) { + drawSpacing *= 2; + } + const firstGridStep = Math.floor(tStart / drawSpacing) * drawSpacing; + for (let s = firstGridStep; s <= tEnd; s += drawSpacing) { + const localX = (s - tStart) * zoom; + ctx.beginPath(); + ctx.moveTo(localX, 0); + ctx.lineTo(localX, height); + ctx.stroke(); + } + + // Draw waveform lane + const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ + id: 'default', + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name, + speed: track.speed || 1.0 + }] : []; + if (clips.length > 0) { + clips.forEach(clip => { + const numChannels = clip.buffer.numberOfChannels || 1; + const dataL = clip.buffer.getChannelData(0); + const dataR = numChannels >= 2 ? clip.buffer.getChannelData(1) : dataL; + const sampleRate = clip.buffer.sampleRate; + const totalSamples = dataL.length; + const originalDuration = totalSamples / sampleRate; + const clipSpeed = clip.speed || 1.0; + const duration = originalDuration / clipSpeed; + const clipStartTime = clip.startTime || 0; + const clipEndTime = clipStartTime + duration; + + // Culling: Skip rendering if clip is outside visible viewport window + if (clipEndTime < tStart || clipStartTime > tEnd) return; + const xStartGlobal = clipStartTime * zoom; + const wClip = duration * zoom; + const xStartLocal = xStartGlobal - scrollLeft; + const xEndLocal = xStartLocal + wClip; + + // 1. Draw Clip Layer Background & Border + const clipIdentifier = clip.id === 'default' ? 'default_' + track.id : clip.id; + const isClipSelected = selectedClipId && selectedClipId.trackId === track.id && selectedClipId.clipId === clipIdentifier; + ctx.fillStyle = isClipSelected ? track.color ? track.color + '44' : 'rgba(6, 182, 212, 0.30)' : track.color ? track.color + '22' : 'rgba(6, 182, 212, 0.12)'; + ctx.strokeStyle = isClipSelected ? '#fbbf24' : track.color || '#06b6d4'; + ctx.lineWidth = isClipSelected ? 1 : 1.5; + const clipTop = 4; + const clipHeight = height - 8; + ctx.beginPath(); + if (ctx.roundRect) { + ctx.roundRect(xStartLocal, clipTop, wClip, clipHeight, 4); + } else { + ctx.rect(xStartLocal, clipTop, wClip, clipHeight); + } + ctx.fill(); + ctx.stroke(); + + // 2. Draw Clip Label & Speed Label + ctx.fillStyle = '#e4e4e7'; + ctx.font = 'bold 9px sans-serif'; + ctx.fillText(clip.name || 'Clip', Math.max(xStartLocal + 8, 8), clipTop + 12); + if (clipSpeed !== 1.0) { + ctx.fillStyle = '#fbbf24'; + ctx.font = 'bold 8px sans-serif'; + ctx.fillText(`Speed: ${(clipSpeed * 100).toFixed(1)}%`, Math.max(xStartLocal + 8, 8), clipTop + 22); + } + + // Draw markers + if (markers && markers.length > 0) { + markers.forEach(m => { + const mxLocal = m.time * zoom - scrollLeft; + if (mxLocal >= 0 && mxLocal <= drawWidth) { + ctx.fillStyle = '#fbbf24'; + ctx.fillRect(mxLocal - 1, 0, 2, height); + } + }); + } + + // 3. Peak / Vector Waveform Drawing (Sound Forge Dual Stereo Channel Split Match) + const drawXStartLocal = Math.max(0, Math.floor(xStartLocal)); + const drawXEndLocal = Math.min(drawWidth, Math.ceil(xEndLocal)); + const samplesPerPixel = sampleRate / zoom * clipSpeed; + const isStereo = numChannels >= 2; + const channelConfigs = isStereo ? [{ + data: dataL, + mid: height / 4, + chHeight: height / 2 - 8, + label: '1' + }, { + data: dataR, + mid: 3 * height / 4, + chHeight: height / 2 - 8, + label: '2' + }] : [{ + data: dataL, + mid: height / 2, + chHeight: clipHeight - 12, + label: 'MONO' + }]; + if (isStereo) { + ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(xStartLocal, height / 2); + ctx.lineTo(xStartLocal + wClip, height / 2); + ctx.stroke(); + } + channelConfigs.forEach(ch => { + const data = ch.data; + const mid = ch.mid; + const peakRatio = ch.chHeight * 0.42; + + // Decibel Amplitude Grid Lines (+6.0dB, -Inf, -6.0dB) + const yPlus6 = mid - peakRatio * 0.8; + const yMinus6 = mid + peakRatio * 0.8; + ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(xStartLocal, yPlus6); + ctx.lineTo(xStartLocal + wClip, yPlus6); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(xStartLocal, mid); + ctx.lineTo(xStartLocal + wClip, mid); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(xStartLocal, yMinus6); + ctx.lineTo(xStartLocal + wClip, yMinus6); + ctx.stroke(); + + // Decibel Text Labels (Sound Forge Monospace) + ctx.fillStyle = '#71717a'; + ctx.font = '8px monospace'; + const labelX = Math.max(xStartLocal + 4, 4); + ctx.fillText('+6.0', labelX, yPlus6 - 2); + ctx.fillText('-Inf', labelX, mid - 2); + ctx.fillText('-6.0', labelX, yMinus6 + 8); + + // Sound Forge Channel ID Badge (1 or 2) + if (isStereo) { + ctx.fillStyle = 'rgba(6, 182, 212, 0.85)'; + ctx.font = 'bold 9px monospace'; + ctx.fillText(ch.label, Math.min(xStartLocal + wClip - 12, drawWidth - 16), mid - ch.chHeight * 0.35); + } + ctx.strokeStyle = '#5bc0be'; // Cornflower Blue + ctx.lineWidth = 1.2; + if (samplesPerPixel < 4) { + // High-zoom continuous vector line rendering (#5bc0be Cornflower Blue) + const visibleTStart = (drawXStartLocal - xStartLocal) / zoom; + const visibleTEnd = (drawXEndLocal - xStartLocal) / zoom; + const startSample = Math.max(0, Math.floor(visibleTStart * clipSpeed * sampleRate)); + const endSample = Math.min(totalSamples, Math.ceil(visibleTEnd * clipSpeed * sampleRate)); + ctx.beginPath(); + let first = true; + const maxSamples = 50000; + const vectorStep = Math.max(1, Math.floor((endSample - startSample) / maxSamples)); + for (let i = startSample; i < endSample; i += vectorStep) { + const sampleTime = i / sampleRate / clipSpeed; + const pxLocal = xStartLocal + sampleTime * zoom; + const y = mid - data[i] * peakRatio; + if (first) { + ctx.moveTo(pxLocal, y); + first = false; + } else { + ctx.lineTo(pxLocal, y); + } + } + ctx.stroke(); + + // Draw granular sample nodes only at extreme zoom (samplesPerPixel < 0.3) + if (samplesPerPixel < 0.3) { + const maxNodes = 5000; + const step = Math.max(1, Math.floor((endSample - startSample) / maxNodes)); + ctx.fillStyle = '#6ee7b7'; + let drawn = 0; + for (let i = startSample; i < endSample && drawn < maxNodes; i += step) { + const sampleTime = i / sampleRate / clipSpeed; + const pxLocal = xStartLocal + sampleTime * zoom; + const y = mid - data[i] * peakRatio; + ctx.fillRect(pxLocal - 1, y - 1, 2, 2); + drawn++; + } + } + } else { + // Coarse view vertical peak min/max bars per pixel + for (let pxLocal = drawXStartLocal; pxLocal < drawXEndLocal; pxLocal++) { + const timeInClip = (pxLocal - xStartLocal) / zoom * clipSpeed; + const sampleIdx = Math.floor(timeInClip * sampleRate); + const chunkSize = Math.max(1, Math.floor(samplesPerPixel)); + const chunkStart = Math.max(0, sampleIdx - Math.floor(chunkSize / 2)); + const chunkEnd = Math.min(totalSamples, chunkStart + chunkSize); + let maxVal = 0; + for (let i = chunkStart; i < chunkEnd; i++) { + const abs = Math.abs(data[i]); + if (abs > maxVal) maxVal = abs; + } + const peakHeight = maxVal * peakRatio; + ctx.beginPath(); + ctx.moveTo(pxLocal, mid - peakHeight); + ctx.lineTo(pxLocal, mid + peakHeight); + ctx.stroke(); + } + } + }); + }); + } else { + ctx.fillStyle = '#444'; + ctx.font = '12px Inter, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('Kéo thả hoặc tải file âm thanh vào Track này', drawWidth / 2, height / 2); + } + + // Selection highlight - local selection on this track + if (selectionMode === 'local' && localSelectionTrackId === track.id && localSelLeft !== null && localSelRight !== null && localSelRight > localSelLeft) { + const hlLeftLocal = localSelLeft * zoom - scrollLeft; + const hlWidth = (localSelRight - localSelLeft) * zoom; + ctx.fillStyle = 'rgba(245, 158, 11, 0.15)'; + ctx.fillRect(hlLeftLocal, 0, hlWidth, height); + ctx.strokeStyle = '#f59e0b'; + ctx.lineWidth = 1; + ctx.strokeRect(hlLeftLocal, 0, hlWidth, height); + } + }, [track, zoom, timelineWidth, viewportWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight, snapValue, bpm]); + return /*#__PURE__*/React.createElement("div", { + style: { + width: `${timelineWidth}px`, + height: '100%', + position: 'relative', + overflow: 'hidden' + } + }, /*#__PURE__*/React.createElement("canvas", { + ref: canvasRef, + style: { + position: 'absolute', + top: 0, + left: 0, + imageRendering: 'pixelated' + }, + className: "cursor-crosshair", + onMouseMove: e => { + if (!canvasRef.current) return; + const rect = canvasRef.current.getBoundingClientRect(); + const wrapper = canvasRef.current?.parentElement?.parentElement; + const scrollLeft = wrapper ? wrapper.scrollLeft : 0; + const x = e.clientX - rect.left + scrollLeft; + const time = x / zoom; + const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ + id: 'default', + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name, + speed: track.speed || 1.0 + }] : []; + + // 1. Shift+Drag (TOP PRIORITY): Range selection on track waveform + if (e.shiftKey && e.buttons > 0) { + e.preventDefault(); + e.stopPropagation(); + const currentAnchor = getLocalAnchor ? getLocalAnchor() : null; + const anchor = currentAnchor !== null && currentAnchor !== undefined ? currentAnchor : localSelectionStart !== null ? localSelectionStart : currentTime; + const selS = Math.min(anchor, time); + const selE = Math.max(anchor, time); + if (onSetSelectionMode) onSetSelectionMode('local'); + if (onSetLocalSelectionTrackId) onSetLocalSelectionTrackId(track.id); + if (onSetLocalSelectionStart) onSetLocalSelectionStart(selS); + if (onSetLocalSelectionEnd) onSetLocalSelectionEnd(selE); + if (onSetSelectionStart) onSetSelectionStart(selS); + if (onSetSelectionEnd) onSetSelectionEnd(selE); + if (onSetCurrentTime) onSetCurrentTime(time); + if (onSelectTrack) onSelectTrack(track.id); + return; + } + + // Check if hovering near local selection boundaries of this track + const isLocal = selectionMode === 'local' && localSelectionTrackId === track.id; + if (isLocal && localSelLeft !== null && localSelRight !== null) { + const leftPx = localSelLeft * zoom; + const rightPx = localSelRight * zoom; + const distToLeft = Math.abs(x - leftPx); + const distToRight = Math.abs(x - rightPx); + if (distToLeft <= 5 || distToRight <= 5) { + canvasRef.current.style.cursor = 'ew-resize'; + return; + } + } + + // Check if hovering near right edge of a clip for time-stretching (Alt key required) + const toleranceSec = 8 / zoom; + const rightEdgeClip = clips.find(c => { + const duration = c.buffer.duration / (c.speed || 1.0); + return Math.abs(time - (c.startTime + duration)) <= toleranceSec; + }); + if (rightEdgeClip && e.altKey) { + canvasRef.current.style.cursor = 'ew-resize'; + return; + } + const hoveredClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); + const isOverClip = !!hoveredClip; + if (activeTool === 'pen') { + canvasRef.current.style.cursor = isOverClip ? 'copy' : 'not-allowed'; + } else if (activeTool === 'grab') { + canvasRef.current.style.cursor = isOverClip ? 'grab' : 'default'; + } else if (activeTool === 'razor') { + canvasRef.current.style.cursor = isOverClip ? 'cell' : 'not-allowed'; + } else { + // select tool + canvasRef.current.style.cursor = isOverClip && (e.altKey || e.ctrlKey) ? 'grab' : 'crosshair'; + } + }, + onMouseDown: e => { + // Ignore right-click for local selection drag (context menu handles it) + if (e.button === 2) return; + const rect = canvasRef.current.getBoundingClientRect(); + const wrapper = canvasRef.current?.parentElement?.parentElement; + const scrollLeft = wrapper ? wrapper.scrollLeft : 0; + const x = e.clientX - rect.left + scrollLeft; + const time = Math.max(0, x / zoom); + onSelectTrack(track.id); + const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ + id: 'default', + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name, + speed: track.speed || 1.0 + }] : []; + + // 1. Shift+Click (TOP PRIORITY): Range selection on track waveform + if (e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + const currentAnchor = getLocalAnchor ? getLocalAnchor() : null; + const anchor = currentAnchor !== null && currentAnchor !== undefined ? currentAnchor : localSelectionStart !== null ? localSelectionStart : currentTime; + const selS = Math.min(anchor, time); + const selE = Math.max(anchor, time); + if (onSetSelectionMode) onSetSelectionMode('local'); + if (onSetLocalSelectionTrackId) onSetLocalSelectionTrackId(track.id); + if (onSetLocalSelectionStart) onSetLocalSelectionStart(selS); + if (onSetLocalSelectionEnd) onSetLocalSelectionEnd(selE); + if (onSetSelectionStart) onSetSelectionStart(selS); + if (onSetSelectionEnd) onSetSelectionEnd(selE); + if (onSetCurrentTime) onSetCurrentTime(time); + if (onSelectTrack) onSelectTrack(track.id); + return; + } + + // Check if dragging selection boundaries (local mode) + const isLocal = selectionMode === 'local' && localSelectionTrackId === track.id; + if (isLocal && localSelLeft !== null && localSelRight !== null) { + const leftPx = localSelLeft * zoom; + const rightPx = localSelRight * zoom; + const distToLeft = Math.abs(x - leftPx); + const distToRight = Math.abs(x - rightPx); + if (distToLeft <= 5) { + e.preventDefault(); + e.stopPropagation(); + if (onSelectionEdgeDragStart) onSelectionEdgeDragStart(e, track.id, 'left'); + return; + } else if (distToRight <= 5) { + e.preventDefault(); + e.stopPropagation(); + if (onSelectionEdgeDragStart) onSelectionEdgeDragStart(e, track.id, 'right'); + return; + } + } + + // Check if time-stretching (Alt + Right Edge) + const toleranceSec = 8 / zoom; + const rightEdgeClip = clips.find(c => { + const duration = c.buffer.duration / (c.speed || 1.0); + return Math.abs(time - (c.startTime + duration)) <= toleranceSec; + }); + if (rightEdgeClip && e.altKey) { + e.preventDefault(); + e.stopPropagation(); + if (onClipStretchStart) { + onClipStretchStart(track.id, rightEdgeClip.id, time); + } + return; + } + const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); + + // Set selected clip ID + if (clickedClip) { + setSelectedClipId({ + trackId: track.id, + clipId: clickedClip.id === 'default' ? 'default_' + track.id : clickedClip.id + }); + } else { + setSelectedClipId(null); + } + if (activeTool === 'pen' && !e.ctrlKey) { + if (clickedClip) { + e.preventDefault(); + e.stopPropagation(); + if (onEditClipInSubTab) { + onEditClipInSubTab(track.id, clickedClip.id); + } + } + return; + } + if (activeTool === 'razor' && !e.ctrlKey) { + if (clickedClip) { + e.preventDefault(); + e.stopPropagation(); + if (onSplitTrackAtTime) { + onSplitTrackAtTime(track.id, clickedClip.id, time); + } + } + return; + } + if (activeTool === 'grab') { + if (onTrackLaneMouseDown) { + onTrackLaneMouseDown(track.id, time, e); + } + if (clickedClip) { + e.preventDefault(); + e.stopPropagation(); + if (onClipDragStart) { + onClipDragStart(track.id, clickedClip.id, time - clickedClip.startTime, e.ctrlKey); + } + } else { + onPlayheadSet(time); + } + return; + } + + // Check for click drag clip (Alt to move, Ctrl to duplicate) + if (clickedClip && (e.altKey || e.ctrlKey)) { + e.preventDefault(); + e.stopPropagation(); + if (onClipDragStart) { + onClipDragStart(track.id, clickedClip.id, time - clickedClip.startTime, e.ctrlKey); + } + return; + } + + // Check if Ctrl+Click outside active local selection to clear + if (e.ctrlKey) { + if (onClearLocalSelection) onClearLocalSelection(); + if (onSetSelectionMode) onSetSelectionMode(null); + if (onSetSelectionStart) onSetSelectionStart(null); + if (onSetSelectionEnd) onSetSelectionEnd(null); + return; + } + onPlayheadSet(time); + if (onTrackLaneMouseDown) { + onTrackLaneMouseDown(track.id, time, e); + } + e.stopPropagation(); + }, + onDoubleClick: e => { + const rect = canvasRef.current.getBoundingClientRect(); + const wrapper = canvasRef.current?.parentElement?.parentElement; + const scrollLeft = wrapper ? wrapper.scrollLeft : 0; + const x = e.clientX - rect.left + scrollLeft; + const time = Math.max(0, x / zoom); + const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ + id: 'default', + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name, + speed: track.speed || 1.0 + }] : []; + const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); + if (clickedClip) { + e.preventDefault(); + e.stopPropagation(); + if (onEditClipInSubTab) { + onEditClipInSubTab(track.id, clickedClip.id); + } + } + }, + onContextMenu: e => { + e.preventDefault(); + e.stopPropagation(); + onSelectTrack(track.id); + const rect = canvasRef.current.getBoundingClientRect(); + const wrapper = canvasRef.current?.parentElement?.parentElement; + const scrollLeft = wrapper ? wrapper.scrollLeft : 0; + const x = e.clientX - rect.left + scrollLeft; + const time = Math.max(0, x / zoom); + if (onContextMenu) onContextMenu(e, track.id, time); + } + })); +}; +const TempoTrackLane = ({ + bpm, + zoom, + timelineWidth, + viewportWidth, + onPlayheadSet, + snapValue, + onRulerMouseDown +}) => { + const canvasRef = useRef(null); + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null; + const scrollLeft = wrapper ? wrapper.scrollLeft : 0; + const vWidth = viewportWidth || (wrapper ? wrapper.clientWidth : 1200); + const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200)); + const height = canvas.parentElement ? canvas.parentElement.clientHeight : 40; + canvas.width = Math.min(Math.round(drawWidth * dpr), 32768); + canvas.height = Math.min(Math.round(height * dpr), 32768); + ctx.scale(dpr, dpr); + ctx.imageSmoothingEnabled = false; + canvas.style.position = 'absolute'; + canvas.style.left = `${scrollLeft}px`; + canvas.style.width = `${drawWidth}px`; + canvas.style.height = `${height}px`; + ctx.fillStyle = '#1a1a2e'; + ctx.fillRect(0, 0, drawWidth, height); + const beatDuration = 60 / bpm; + const barDuration = beatDuration * 4; + const tStart = scrollLeft / zoom; + const tEnd = (scrollLeft + drawWidth) / zoom; + const firstBeat = Math.floor(tStart / beatDuration) * beatDuration; + for (let t = firstBeat; t <= tEnd; t += beatDuration) { + const beatNum = Math.floor(t / beatDuration) + 1; + const isBar = beatNum % 4 === 1; + const localX = (t - tStart) * zoom; + if (isBar) { + ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(localX, 0); + ctx.lineTo(localX, height); + ctx.stroke(); + ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'; + ctx.font = 'bold 9px Inter, sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText(`${Math.ceil(beatNum / 4)}`, localX + 3, 11); + } else { + ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(localX, 0); + ctx.lineTo(localX, height); + ctx.stroke(); + } + } + + // Draw snap sub-ticks at the bottom + if (snapValue && snapValue !== 'free') { + ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)'; + ctx.lineWidth = 0.8; + let divisor = 1; + if (snapValue === '1') divisor = 1;else if (snapValue === '1/2') divisor = 0.5;else if (snapValue === '1/4') divisor = 0.25;else if (snapValue === '1/8') divisor = 0.125;else if (snapValue === '1/16') divisor = 0.0625;else if (snapValue === '1/32') divisor = 0.03125; + const snapInterval = beatDuration * divisor; + if (snapInterval * zoom >= 4) { + const firstSnap = Math.floor(tStart / snapInterval) * snapInterval; + for (let t = firstSnap; t <= tEnd; t += snapInterval) { + const onBeat = Math.abs(t / beatDuration - Math.round(t / beatDuration)) < 0.001; + if (!onBeat) { + const localX = (t - tStart) * zoom; + ctx.beginPath(); + ctx.moveTo(localX, height - 6); + ctx.lineTo(localX, height); + ctx.stroke(); + } + } + } + } + ctx.fillStyle = 'rgba(255, 255, 255, 0.35)'; + ctx.font = 'bold 10px Inter, sans-serif'; + ctx.textAlign = 'right'; + ctx.fillText(`${bpm} BPM`, drawWidth - 6, 12); + }, [bpm, zoom, timelineWidth, viewportWidth, snapValue]); + return /*#__PURE__*/React.createElement("div", { + style: { + width: `${timelineWidth}px`, + height: '100%', + position: 'relative', + overflow: 'hidden' + } + }, /*#__PURE__*/React.createElement("canvas", { + ref: canvasRef, + style: { + position: 'absolute', + top: 0, + left: 0, + imageRendering: 'pixelated' + }, + className: "cursor-crosshair", + onMouseDown: e => { + const wrapper = canvasRef.current?.parentElement?.parentElement; + const scrollLeft = wrapper ? wrapper.scrollLeft : 0; + const rect = canvasRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left + scrollLeft; + const time = Math.max(0, x / zoom); + if (e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + } + if (onRulerMouseDown) { + onRulerMouseDown(e); + } else { + onPlayheadSet(time, e.shiftKey); + } + } + })); +}; + +// ── 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, + zoom, + timelineWidth, + color, + name, + speed = 1.0, + onSpeedChange, + volumeNodes = [], + panningNodes = [], + fadeInLen = 0, + fadeOutLen = 0, + graphMode = null, + onUpdateNodes, + onUpdateFade, + onModeToggle, + selectedNodeTime, + setSelectedNodeTime, + channelInfo = null +}) => { + const canvasRef = useRef(null); + const isStretchingRef = useRef(false); + const stretchStartRef = useRef({ + mouseX: 0, + originalDuration: 0, + originalSpeed: 1.0 + }); + const subTabAnchorRef = useRef(null); + const isStereo = channelInfo ? channelInfo.isStereo : buffer && buffer.numberOfChannels >= 2; + const channelLabel = channelInfo ? channelInfo.label : isStereo ? 'STEREO' : 'MONO'; + // Mono: force volume mode (panning not applicable) + const effectiveGraphMode = !isStereo && graphMode === 'pan' ? null : graphMode; + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !buffer) return; + const ctx = canvas.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null; + const scrollLeft = wrapper ? wrapper.scrollLeft : 0; + const vWidth = wrapper ? wrapper.clientWidth : 1200; + const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200)); + const h = canvas.parentElement ? canvas.parentElement.clientHeight : 200; + canvas.width = Math.min(Math.round(drawWidth * dpr), 32768); + canvas.height = Math.min(Math.round(h * dpr), 32768); + ctx.scale(dpr, dpr); + ctx.imageSmoothingEnabled = false; + canvas.style.position = 'absolute'; + canvas.style.left = `${scrollLeft}px`; + canvas.style.width = `${drawWidth}px`; + canvas.style.height = `${h}px`; + ctx.fillStyle = '#181818'; + ctx.fillRect(0, 0, drawWidth, h); + const data = buffer.getChannelData(0); + const len = data.length; + if (len === 0) return; + + // Helper to compute volume gain at a specific time in clip using Monotone Cubic Hermite Spline + const computeHermiteTangents = pts => { + const n = pts.length; + if (n < 2) return []; + const m = new Array(n); + for (let i = 1; i < n - 1; i++) { + const hP = pts[i].time - pts[i - 1].time; + const hN = pts[i + 1].time - pts[i].time; + const sP = (pts[i].db - pts[i - 1].db) / hP; + const sN = (pts[i + 1].db - pts[i].db) / hN; + m[i] = (sP + sN) / 2; + } + m[0] = n > 1 ? (pts[1].db - pts[0].db) / (pts[1].time - pts[0].time) : 0; + m[n - 1] = n > 1 ? (pts[n - 1].db - pts[n - 2].db) / (pts[n - 1].time - pts[n - 2].time) : 0; + return m; + }; + const getVolumeDbAtTime = t => { + const volNodes = volumeNodes || []; + if (volNodes.length === 0) return 0.0; + const sortedNodes = [...volNodes].sort((a, b) => a.time - b.time); + if (sortedNodes.length === 1) return sortedNodes[0].db; + if (t <= sortedNodes[0].time) return sortedNodes[0].db; + if (t >= sortedNodes[sortedNodes.length - 1].time) return sortedNodes[sortedNodes.length - 1].db; + const tangents = computeHermiteTangents(sortedNodes); + for (let i = 0; i < sortedNodes.length - 1; i++) { + const n1 = sortedNodes[i]; + const n2 = sortedNodes[i + 1]; + if (t >= n1.time && t <= n2.time) { + const h = n2.time - n1.time; + if (h <= 0) return n1.db; + const frac = (t - n1.time) / h; + const frac2 = frac * frac, + frac3 = frac2 * frac; + return (2 * frac3 - 3 * frac2 + 1) * n1.db + (frac3 - 2 * frac2 + frac) * h * tangents[i] + (-2 * frac3 + 3 * frac2) * n2.db + (frac3 - frac2) * h * tangents[i + 1]; + } + } + return 0.0; + }; + const getVolumeGainAtTime = t => { + return Math.pow(10, getVolumeDbAtTime(t) / 20); + }; + const computeHermiteTangentsForPan = pts => { + const n = pts.length; + if (n < 2) return []; + const m = new Array(n); + for (let i = 1; i < n - 1; i++) { + const hP = pts[i].time - pts[i - 1].time; + const hN = pts[i + 1].time - pts[i].time; + const sP = (pts[i].pan - pts[i - 1].pan) / hP; + const sN = (pts[i + 1].pan - pts[i].pan) / hN; + m[i] = (sP + sN) / 2; + } + m[0] = n > 1 ? (pts[1].pan - pts[0].pan) / (pts[1].time - pts[0].time) : 0; + m[n - 1] = n > 1 ? (pts[n - 1].pan - pts[n - 2].pan) / (pts[n - 1].time - pts[n - 2].time) : 0; + return m; + }; + const getPanningValueAtTime = t => { + const panNodes = panningNodes || []; + if (panNodes.length === 0) return 0; + const sortedNodes = [...panNodes].sort((a, b) => a.time - b.time); + if (sortedNodes.length === 1) return Math.round(sortedNodes[0].pan * 100); + if (t <= sortedNodes[0].time) return Math.round(sortedNodes[0].pan * 100); + if (t >= sortedNodes[sortedNodes.length - 1].time) return Math.round(sortedNodes[sortedNodes.length - 1].pan * 100); + const tangents = computeHermiteTangentsForPan(sortedNodes); + for (let i = 0; i < sortedNodes.length - 1; i++) { + const n1 = sortedNodes[i]; + const n2 = sortedNodes[i + 1]; + if (t >= n1.time && t <= n2.time) { + const h = n2.time - n1.time; + if (h <= 0) return Math.round(n1.pan * 100); + const frac = (t - n1.time) / h; + const frac2 = frac * frac, + frac3 = frac2 * frac; + const val = (2 * frac3 - 3 * frac2 + 1) * n1.pan + (frac3 - 2 * frac2 + frac) * h * tangents[i] + (-2 * frac3 + 3 * frac2) * n2.pan + (frac3 - frac2) * h * tangents[i + 1]; + return Math.round(val * 100); + } + } + return 0; + }; + + // Draw clip container (like main session clips) + const xStart = 0; + const wClip = buffer.duration / speed * zoom; // speed-adjusted width + const xEnd = xStart + wClip; + const clipTop = 8; + const clipHeight = h - 16; + const clipColor = color || '#06b6d4'; + ctx.fillStyle = clipColor + '22'; + ctx.strokeStyle = clipColor; + ctx.lineWidth = 1.5; + if (ctx.roundRect) { + ctx.beginPath(); + ctx.roundRect(xStart, clipTop, wClip, clipHeight, 4); + ctx.fill(); + ctx.stroke(); + } else { + ctx.fillRect(xStart, clipTop, wClip, clipHeight); + ctx.strokeRect(xStart, clipTop, wClip, clipHeight); + } + + // Draw clip label with speed % + ctx.fillStyle = '#e4e4e7'; + ctx.font = 'bold 10px sans-serif'; + let displayName = name || 'Audio Clip'; + if (speed !== 1.0) { + displayName += ` (${Math.round(speed * 100)}%)`; + } + ctx.fillText(displayName, xStart + 8, clipTop + 14); + + // ── Graph Grid & Axes ── + const drawGrid = true; + if (drawGrid) { + // Background fill + ctx.fillStyle = '#181818'; + ctx.fillRect(xStart, clipTop, wClip, clipHeight); + + // Vertical grid lines (time markers) + ctx.strokeStyle = '#2a2a2a'; + ctx.lineWidth = 0.5; + ctx.setLineDash([]); + const timeStep = Math.max(0.1, Math.ceil(buffer.duration / 20 * 10) / 10); + for (let t = 0; t <= buffer.duration; t += timeStep) { + const px = t * zoom; + ctx.beginPath(); + ctx.moveTo(px, clipTop); + ctx.lineTo(px, clipTop + clipHeight); + ctx.stroke(); + } + + // Always draw the reference Volume 0dB Axis (White) and Panning Center Axis (Brown) + const volZeroY = clipTop + 1 / 3 * clipHeight; + const panZeroY = clipTop + 1 / 2 * clipHeight; + + // White line for volume 0dB + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.moveTo(xStart, volZeroY); + ctx.lineTo(xStart + wClip, volZeroY); + ctx.stroke(); + + // Brown line for panning center + ctx.strokeStyle = '#854d0e'; + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.moveTo(xStart, panZeroY); + ctx.lineTo(xStart + wClip, panZeroY); + ctx.stroke(); + + // Horizontal grid lines (other value markers) + const isPanMode = effectiveGraphMode === 'pan'; + if (isPanMode) { + for (let p = -100; p <= 100; p += 20) { + if (p === 0) continue; + const y = clipTop + clipHeight * (1 - (p / 100 + 1) / 2); + ctx.strokeStyle = '#2a2a2a'; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(xStart, y); + ctx.lineTo(xStart + wClip, y); + ctx.stroke(); + } + } else { + for (let db = -30; db <= 3; db += 3) { + if (db === 0) continue; + const y = db >= 0 ? volZeroY - db / 3 * (2 / 3 * clipHeight) : volZeroY + -db / 30 * (1 / 3 * clipHeight); + ctx.strokeStyle = '#2a2a2a'; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(xStart, y); + ctx.lineTo(xStart + wClip, y); + ctx.stroke(); + } + } + + // Y-axis labels (left side) + ctx.fillStyle = '#71717a'; + ctx.font = '7px monospace'; + ctx.textAlign = 'right'; + if (isPanMode) { + ctx.fillText('L100', xStart - 2, clipTop + 8); + ctx.fillText('R50', xStart - 2, clipTop + clipHeight * 0.25 + 2); + ctx.fillStyle = '#854d0e'; // Brown label for active Panning Center + ctx.fillText('C (Pan)', xStart - 2, clipTop + clipHeight * 0.5 + 2); + ctx.fillStyle = '#71717a'; + ctx.fillText('L50', xStart - 2, clipTop + clipHeight * 0.75 + 2); + ctx.fillText('R100', xStart - 2, clipTop + clipHeight - 2); + } else { + ctx.fillText('+3dB', xStart - 2, clipTop + 8); + ctx.fillStyle = '#ffffff'; // White label for active Volume 0dB + ctx.fillText('0dB (Vol)', xStart - 2, volZeroY + 2); + ctx.fillStyle = '#71717a'; + ctx.fillText('-15dB', xStart - 2, volZeroY + clipHeight / 6 + 2); + ctx.fillText('-30dB', xStart - 2, clipTop + clipHeight - 2); + } + ctx.textAlign = 'start'; + } + + // Drag hint labels for fade endpoints + ctx.fillStyle = '#71717a'; + ctx.font = '8px sans-serif'; + ctx.fillText('Kéo FI/FO trên đường cong để điều chỉnh', 8, clipTop + clipHeight + 12); + + // Display speed percentage in the bottom left corner of the waveform area + ctx.fillStyle = '#e4e4e7'; + ctx.font = 'bold 9px sans-serif'; + ctx.fillText(`Tốc độ: ${Math.round(speed * 100)}%`, xStart + 8, clipTop + clipHeight - 8); + + // Mode toggle button on waveform (bottom-right) + const modeBtnW = 28; + const modeBtnH = 14; + const modeBtnX = wClip - modeBtnW - 4; + const modeBtnY = clipTop + clipHeight - modeBtnH - 2; + const isPanMode = effectiveGraphMode === 'pan'; + ctx.fillStyle = isPanMode ? 'rgba(168, 85, 247, 0.5)' : 'rgba(6, 182, 212, 0.5)'; + ctx.beginPath(); + ctx.roundRect(modeBtnX, modeBtnY, modeBtnW, modeBtnH, 3); + ctx.fill(); + ctx.fillStyle = '#fff'; + ctx.font = 'bold 7px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(isPanMode ? 'PAN' : 'VOL', modeBtnX + modeBtnW / 2, modeBtnY + 10); + ctx.textAlign = 'start'; + + // Channel label (L / R for stereo, M for mono) + ctx.fillStyle = '#a1a1aa'; + ctx.font = 'bold 8px monospace'; + if (isStereo) { + ctx.fillText('L', xStart + 2, clipTop + clipHeight * 0.28); + ctx.fillText('R', xStart + 2, clipTop + clipHeight * 0.72); + } else { + ctx.fillText('M', xStart + 2, clipTop + clipHeight / 2); + } + + // Draw waveform inside clip (speed-adjusted) with fade envelope applied + const drawXStart = Math.max(0, Math.floor(xStart)); + const drawXEnd = Math.min(drawWidth, Math.ceil(xEnd)); + const samplesPerPixel = buffer.sampleRate / zoom * speed; + const bufDur = buffer.duration; + const mid = h / 2; + const peakRatio = clipHeight * 0.45; + const getFadeGainAtTime = t => { + if (fadeInLen > 0 && t < fadeInLen) { + return (1 - Math.cos(Math.PI * t / fadeInLen)) / 2; + } else if (fadeOutLen > 0 && t > bufDur - fadeOutLen) { + const ratio = (t - (bufDur - fadeOutLen)) / fadeOutLen; + return (1 + Math.cos(Math.PI * ratio)) / 2; + } + return 1; + }; + ctx.strokeStyle = '#5bc0be'; + ctx.lineWidth = 1.2; + if (samplesPerPixel < 4) { + // High-zoom continuous vector line rendering (#5bc0be Cornflower Blue) + const startSample = Math.max(0, Math.floor((drawXStart - xStart) / zoom * speed * buffer.sampleRate)); + const endSample = Math.min(len, Math.ceil((drawXEnd - xStart) / zoom * speed * buffer.sampleRate)); + ctx.beginPath(); + let first = true; + const maxSamples = 50000; + const vectorStep = Math.max(1, Math.floor((endSample - startSample) / maxSamples)); + for (let i = startSample; i < endSample; i += vectorStep) { + const timeInClip = i / buffer.sampleRate / speed; + const px = xStart + timeInClip * zoom; + const fadeGain = getFadeGainAtTime(timeInClip); + const volGain = getVolumeGainAtTime(px / zoom); + const totalGain = fadeGain * volGain; + const y = mid - data[i] * totalGain * peakRatio; + if (first) { + ctx.moveTo(px, y); + first = false; + } else { + ctx.lineTo(px, y); + } + } + ctx.stroke(); + + // Draw granular sample nodes only at extreme zoom (samplesPerPixel < 0.3) + if (samplesPerPixel < 0.3) { + const maxNodes = 5000; + const step = Math.max(1, Math.floor((endSample - startSample) / maxNodes)); + ctx.fillStyle = '#6ee7b7'; + let drawn = 0; + for (let i = startSample; i < endSample && drawn < maxNodes; i += step) { + const timeInClip = i / buffer.sampleRate / speed; + const px = xStart + timeInClip * zoom; + const fadeGain = getFadeGainAtTime(timeInClip); + const volGain = getVolumeGainAtTime(px / zoom); + const totalGain = fadeGain * volGain; + const y = mid - data[i] * totalGain * peakRatio; + ctx.fillRect(px - 1, y - 1, 2, 2); + drawn++; + } + } + } else { + // Coarse view vertical peak min/max bars + for (let px = drawXStart; px < drawXEnd; px++) { + const timeInClip = px / zoom * speed; + const sampleIdx = Math.floor(timeInClip * 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 fadeGain = getFadeGainAtTime(timeInClip); + const volGain = getVolumeGainAtTime(px / zoom); + const totalGain = fadeGain * volGain; + let maxVal = 0; + let minVal = 0; + for (let i = chunkStart; i < chunkEnd; i++) { + const val = data[i]; + if (val > maxVal) maxVal = val; + if (val < minVal) minVal = val; + } + maxVal *= totalGain; + minVal *= totalGain; + const yTop = mid + minVal * peakRatio; + const yBottom = mid + maxVal * peakRatio; + ctx.beginPath(); + ctx.moveTo(px, yTop); + ctx.lineTo(px, yBottom); + ctx.stroke(); + } + } + + // Volume: 0dB at 1/3 from top + const autoY = node => { + const db = typeof node === 'number' ? node : node.db; + const zeroY = clipTop + 1 / 3 * clipHeight; + return db >= 0 ? zeroY - db / 3 * (1 / 3 * clipHeight) : zeroY + -db / 30 * (2 / 3 * clipHeight); + }; + // Panning: 0 at center + const autoPanY = node => { + const pan = typeof node === 'number' ? node : node.pan; + return clipTop + (1 - (pan + 1) / 2) * clipHeight; + }; + + // Helper: compute tangents for monotone Hermite spline + const computeTangents = (pts, yFn) => { + const n = pts.length; + if (n < 2) return []; + const m = new Array(n); + for (let i = 1; i < n - 1; i++) { + const hP = pts[i].time - pts[i - 1].time; + const hN = pts[i + 1].time - pts[i].time; + const sP = (yFn(pts[i]) - yFn(pts[i - 1])) / hP; + const sN = (yFn(pts[i + 1]) - yFn(pts[i])) / hN; + m[i] = (sP + sN) / 2; + } + m[0] = n > 1 ? (yFn(pts[1]) - yFn(pts[0])) / (pts[1].time - pts[0].time) : 0; + m[n - 1] = n > 1 ? (yFn(pts[n - 1]) - yFn(pts[n - 2])) / (pts[n - 1].time - pts[n - 2].time) : 0; + return m; + }; + + // Helper: evaluate Hermite at pixel position px + const hermiteY = (px, x0, y0, m0, x1, y1, m1) => { + const h = x1 - x0; + if (h <= 0) return y0; + const t = (px - x0) / h; + const t2 = t * t, + t3 = t2 * t; + return (2 * t3 - 3 * t2 + 1) * y0 + (t3 - 2 * t2 + t) * h * m0 + (-2 * t3 + 3 * t2) * y1 + (t3 - t2) * h * m1; + }; + + // Draw automation curve with Hermite spline + const drawSpline = (nodes, yFn, color, lineDash) => { + if (nodes.length < 2) { + if (nodes.length === 1) { + const px = nodes[0].time * zoom; + const y = yFn(nodes[0]); + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(px, y, 4, 0, Math.PI * 2); + ctx.fill(); + } + return; + } + const tangents = computeTangents(nodes, yFn); + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.setLineDash(lineDash || []); + ctx.beginPath(); + for (let i = 0; i < nodes.length - 1; i++) { + const x0 = nodes[i].time * zoom, + y0 = yFn(nodes[i]); + const x1 = nodes[i + 1].time * zoom, + y1 = yFn(nodes[i + 1]); + const t0 = nodes[i].time, + t1 = nodes[i + 1].time; + const m0 = tangents[i], + m1 = tangents[i + 1]; + for (let px = Math.floor(x0); px < Math.ceil(x1); px++) { + const t = px / zoom; + const y = hermiteY(t, t0, y0, m0, t1, y1, m1); + if (px === Math.floor(x0) && i === 0) ctx.moveTo(px, y);else ctx.lineTo(px, y); + } + } + ctx.stroke(); + ctx.setLineDash([]); + + // Draw node handles + nodes.forEach(n => { + const px = n.time * zoom, + y = yFn(n); + const isSelected = n.time === selectedNodeTime; + ctx.fillStyle = isSelected ? '#ffebb3' : '#fff'; + ctx.beginPath(); + ctx.arc(px, y, isSelected ? 6 : 4, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = isSelected ? '#fbbf24' : color; + ctx.lineWidth = isSelected ? 2.5 : 1.5; + ctx.beginPath(); + ctx.arc(px, y, isSelected ? 6 : 4, 0, Math.PI * 2); + ctx.stroke(); + + // Display value at node + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 12px sans-serif'; + const labelText = isPanMode ? n.pan > 0 ? 'R' + Math.round(n.pan * 100) : n.pan < 0 ? 'L' + Math.round(Math.abs(n.pan) * 100) : 'C' : `${n.db >= 0 ? '+' : ''}${n.db.toFixed(1)}dB`; + ctx.fillText(labelText, px + 8, y + 4); + }); + }; + drawSpline(volumeNodes, autoY, '#f43f5e'); + drawSpline(panningNodes, autoPanY, '#a855f7', [4, 4]); + + // ── Fade Curves (transparent, only curve lines + endpoint handles) ── + const FADE_COLOR = '#b91c1c'; + const HANDLE_RADIUS = 5; + if (fadeInLen >= 0) { + const fiPx = fadeInLen * zoom; + if (fadeInLen > 0) { + ctx.strokeStyle = FADE_COLOR; + ctx.lineWidth = 1.8; + ctx.setLineDash([]); + ctx.beginPath(); + ctx.moveTo(0, clipTop + clipHeight); + for (let px = 0; px <= fiPx; px++) { + const ratio = px / fiPx; + const amp = (1 - Math.cos(Math.PI * ratio)) / 2; + const y = clipTop + clipHeight - amp * clipHeight; + ctx.lineTo(px, y); + } + ctx.stroke(); + } + + // Endpoint handle (draggable) + const endX = fiPx, + endY = clipTop; + ctx.fillStyle = '#fff'; + ctx.beginPath(); + ctx.arc(endX, endY, HANDLE_RADIUS, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = FADE_COLOR; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(endX, endY, HANDLE_RADIUS, 0, Math.PI * 2); + ctx.stroke(); + // Label + ctx.fillStyle = '#b91c1c'; + ctx.font = 'bold 7px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('FI', endX, endY - HANDLE_RADIUS - 3); + ctx.textAlign = 'start'; + } + if (fadeOutLen >= 0) { + const foPx = fadeOutLen * zoom; + const startX = wClip - foPx; + if (fadeOutLen > 0) { + ctx.strokeStyle = FADE_COLOR; + ctx.lineWidth = 1.8; + ctx.setLineDash([]); + ctx.beginPath(); + ctx.moveTo(startX, clipTop); + for (let px = 0; px <= foPx; px++) { + const ratio = px / foPx; + const amp = (1 + Math.cos(Math.PI * ratio)) / 2; + const y = clipTop + clipHeight - amp * clipHeight; + ctx.lineTo(startX + px, y); + } + ctx.stroke(); + } + + // Endpoint handle (draggable) + const handleX = startX, + handleY = clipTop; + ctx.fillStyle = '#fff'; + ctx.beginPath(); + ctx.arc(handleX, handleY, HANDLE_RADIUS, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = FADE_COLOR; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(handleX, handleY, HANDLE_RADIUS, 0, Math.PI * 2); + ctx.stroke(); + ctx.fillStyle = '#b91c1c'; + ctx.font = 'bold 7px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('FO', handleX, handleY - HANDLE_RADIUS - 3); + ctx.textAlign = 'start'; + } + + // Draw right-edge stretch handle indicator + if (wClip > 0 && wClip < drawWidth) { + ctx.strokeStyle = clipColor; + ctx.lineWidth = 1.5; + ctx.setLineDash([3, 3]); + ctx.beginPath(); + ctx.moveTo(wClip + xStart, clipTop); + ctx.lineTo(wClip + xStart, clipTop + clipHeight); + ctx.stroke(); + ctx.setLineDash([]); + } + + // 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 * zoom; + const rightPx = right * zoom; + ctx.fillStyle = 'rgba(245, 158, 11, 0.15)'; + ctx.fillRect(leftPx, 0, rightPx - leftPx, h); + ctx.strokeStyle = '#f59e0b'; + ctx.lineWidth = 1; + ctx.beginPath(); + 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 / speed) { + const playheadPx = currentTime * zoom; + ctx.strokeStyle = '#ef4444'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(playheadPx, 0); + ctx.lineTo(playheadPx, h); + ctx.stroke(); + } + + // Update TCP slider values in real-time to match the curve at currentTime + const volInput = document.getElementById(`tcp-vol-${subTabId}`); + const volLabel = document.getElementById(`tcp-vol-label-${subTabId}`); + if (volInput && volLabel) { + const dbVal = getVolumeDbAtTime(currentTime || 0); + volInput.value = dbVal.toFixed(1); + volLabel.textContent = `${dbVal.toFixed(1)}dB`; + } + const panInput = document.getElementById(`tcp-pan-${subTabId}`); + const panLabel = document.getElementById(`tcp-pan-label-${subTabId}`); + if (panInput && panLabel) { + const panVal = getPanningValueAtTime(currentTime || 0); + panInput.value = panVal; + panLabel.textContent = panVal > 0 ? 'R' : panVal < 0 ? 'L' : 'C'; + const panLabelDetailed = document.getElementById(`tcp-pan-label-detailed-${subTabId}`); + if (panLabelDetailed) { + panLabelDetailed.textContent = panVal > 0 ? 'R' + panVal : panVal < 0 ? 'L' + Math.abs(panVal) : 'C'; + } + } + }, [buffer, currentTime, selectionStart, selectionEnd, zoom, timelineWidth, color, name, speed, volumeNodes, panningNodes, fadeInLen, fadeOutLen, graphMode, selectedNodeTime, subTabId]); + const handleMouseDown = e => { + if (e.button === 2) return; + const canvas = canvasRef.current; + const rect = canvas.getBoundingClientRect(); + const parent = canvas.parentElement; + const scrollContainer = parent ? parent.parentElement : null; + const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; + const bufDuration = buffer.duration; + const wallDuration = bufDuration / (speed || 1.0); + const mouseX = e.clientX - rect.left + scrollLeft; + const startTime = Math.max(0, Math.min(wallDuration, mouseX / zoom)); + + // Shift + Click range selection in SubTab Waveform + if (e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + const anchor = subTabAnchorRef.current !== null && subTabAnchorRef.current !== undefined ? subTabAnchorRef.current : selectionStart !== null && selectionStart !== undefined ? selectionStart : currentTime; + const selS = Math.min(anchor, startTime); + const selE = Math.max(anchor, startTime); + onSelectRange(selS, selE); + onPlayheadSet(startTime); + return; + } + + // Alt+Click near right edge → speed stretch + if (e.altKey && onSpeedChange) { + const clipRightEdge = bufDuration / (speed || 1.0) * zoom; + const tolerance = 8; + if (Math.abs(mouseX - clipRightEdge) <= tolerance) { + isStretchingRef.current = true; + stretchStartRef.current = { + mouseX, + originalDuration: bufDuration, + originalSpeed: speed + }; + canvas.style.cursor = 'ew-resize'; + const handleMouseMove = moveEvent => { + const currentX = moveEvent.clientX - rect.left + scrollLeft; + const wClipPx = stretchStartRef.current.originalDuration / stretchStartRef.current.originalSpeed * zoom; + const deltaX = currentX - stretchStartRef.current.mouseX; + const newWClip = Math.max(10, wClipPx + deltaX); + const newSpeed = stretchStartRef.current.originalDuration / (newWClip / zoom); + if (onSpeedChange) onSpeedChange(Math.max(0.05, Math.min(10, newSpeed))); + }; + const handleMouseUp = () => { + isStretchingRef.current = false; + canvas.style.cursor = 'default'; + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return; + } + } + + // Mode toggle button click (bottom-right VOL/PAN) + const wClipPx = bufDuration / (speed || 1.0) * zoom; + const cTop = 8; + const cSize = 16; + const cHeight = rect.height - 16; + const modeBtnX = wClipPx - 28 - 4; + const modeBtnY = cTop + cHeight - 14 - 2; + if (mouseX >= modeBtnX && mouseX <= modeBtnX + 28 && e.clientY - rect.top >= modeBtnY && e.clientY - rect.top <= modeBtnY + 14) { + if (onModeToggle) onModeToggle(); + return; + } + + // Fade endpoint handle drag (click on FI/FO handle circles) + const HANDLE_R = 5; + const fiEndPx = fadeInLen * zoom; + const foStartPx = wClipPx - fadeOutLen * zoom; + const distToFiHandle = Math.abs(mouseX - fiEndPx) + Math.abs(e.clientY - rect.top - cTop); + const distToFoHandle = Math.abs(mouseX - foStartPx) + Math.abs(e.clientY - rect.top - cTop); + if (distToFiHandle <= HANDLE_R + 6) { + const handleMouseMove = moveEvent => { + const x = moveEvent.clientX - rect.left + scrollLeft; + const t = Math.max(0, Math.min(wallDuration, x / zoom)); + if (onUpdateFade) onUpdateFade({ + fadeInLen: t + }); + }; + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return; + } + if (distToFoHandle <= HANDLE_R + 6) { + const handleMouseMove = moveEvent => { + const x = moveEvent.clientX - rect.left + scrollLeft; + const t = Math.max(0, Math.min(wallDuration, (wClipPx - x) / zoom)); + if (onUpdateFade) onUpdateFade({ + fadeOutLen: t + }); + }; + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return; + } + + // Tool-specific behavior + if (activeTool === 'grab') { + setSelectedNodeTime(null); + onPlayheadSet(startTime); + const handleMouseMove = moveEvent => { + const currentX = moveEvent.clientX - rect.left + scrollLeft; + const ct = Math.max(0, Math.min(wallDuration, currentX / zoom)); + onPlayheadSet(ct); + }; + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return; + } + if (activeTool === 'razor') { + setSelectedNodeTime(null); + onPlayheadSet(startTime); + showToast(`Cut point at ${formatTime(startTime)}`, 'info'); + return; + } + if (activeTool === 'pen') { + // Deduplicate by time: keep last occurrence per time key + const mergeNodes = (existing, incoming) => { + const map = new Map(); + existing.forEach(n => map.set(n.time, n)); + incoming.forEach(n => map.set(n.time, n)); + return Array.from(map.values()).sort((a, b) => a.time - b.time); + }; + onPlayheadSet(startTime); + canvas.style.cursor = 'crosshair'; + const isPan = (graphMode || 'volume') === 'pan'; + const isCtrl = e.ctrlKey || e.metaKey; + const cTop = 8; + const cHeight = rect.height - 16; + const curNodes = isPan ? panningNodes : volumeNodes; + const valFromY = y => { + if (isPan) return Math.max(-1, Math.min(1, -(y - cTop) / cHeight * 2 + 1)); + const yOff = (y - cTop) / cHeight; + return yOff <= 1 / 3 ? Math.max(0, Math.min(3, 3 * (1 - yOff * 3))) : Math.max(-30, Math.min(0, -30 * (yOff - 1 / 3) * (3 / 2))); + }; + const snapVal = v => isPan ? Math.round(v * 20) / 20 : Math.round(v * 2) / 2; + const snapTime = t => Math.round(t * 10) / 10; + + // Check if clicking near existing node (any mode) + const volNodeY = v => { + const z = cTop + 1 / 3 * cHeight; + return v >= 0 ? z - v / 3 * (1 / 3 * cHeight) : z + -v / 30 * (2 / 3 * cHeight); + }; + const nearNode = curNodes.findIndex(n => { + const t = Math.abs(n.time - startTime); + const val = isPan ? n.pan : n.db; + const ny = isPan ? cTop + (1 - (val + 1) / 2) * cHeight : volNodeY(val); + const dy = Math.abs(e.clientY - rect.top - ny); + return t < 0.1 / (speed || 1) && dy < 10; + }); + if (nearNode >= 0) { + // Drag existing node + setSelectedNodeTime(curNodes[nearNode].time); + let working = [...curNodes]; + let dragIdx = nearNode; + const handleMouseMove = moveEvent => { + const currentX = moveEvent.clientX - rect.left + scrollLeft; + const ct = Math.max(0, Math.min(wallDuration, currentX / zoom)); + const val = snapVal(valFromY(moveEvent.clientY - rect.top)); + const updated = isPan ? { + time: Math.min(snapTime(ct), wallDuration), + pan: val + } : { + time: Math.min(snapTime(ct), wallDuration), + db: val + }; + const cleaned = mergeNodes(working.filter((_, i) => i !== dragIdx), [updated]); + if (onUpdateNodes) onUpdateNodes(cleaned); + working = [...cleaned]; + dragIdx = working.findIndex(n => n.time === updated.time && (isPan ? n.pan === updated.pan : n.db === updated.db)); + // Follow the selected node during dragging + setSelectedNodeTime(updated.time); + }; + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + canvas.style.cursor = 'crosshair'; + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return; + } + if (isCtrl) { + setSelectedNodeTime(null); + const pts = []; + let lastKey = ''; + const handleMouseMove = moveEvent => { + const currentX = moveEvent.clientX - rect.left + scrollLeft; + const ct = Math.max(0, Math.min(wallDuration, currentX / zoom)); + const t = +snapTime(ct).toFixed(3); + const v = isPan ? +snapVal(valFromY(moveEvent.clientY - rect.top)).toFixed(2) : +snapVal(valFromY(moveEvent.clientY - rect.top)).toFixed(1); + const key = t + '|' + v; + if (key !== lastKey) { + pts.push({ + time: t, + [isPan ? 'pan' : 'db']: v + }); + lastKey = key; + } + }; + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + canvas.style.cursor = 'crosshair'; + const merged = mergeNodes(curNodes, pts); + if (onUpdateNodes) onUpdateNodes(merged); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + } else { + const newNode = isPan ? { + time: +snapTime(startTime).toFixed(3), + pan: +snapVal(valFromY(e.clientY - rect.top)).toFixed(2) + } : { + time: +snapTime(startTime).toFixed(3), + db: +snapVal(valFromY(e.clientY - rect.top)).toFixed(1) + }; + setSelectedNodeTime(newNode.time); + const merged = mergeNodes(curNodes, [newNode]); + if (onUpdateNodes) onUpdateNodes(merged); + + // Now drag this newly created node + let working = [...merged]; + let dragIdx = working.findIndex(n => n.time === newNode.time && (isPan ? n.pan === newNode.pan : n.db === newNode.db)); + const handleMouseMove = moveEvent => { + const currentX = moveEvent.clientX - rect.left + scrollLeft; + const ct = Math.max(0, Math.min(wallDuration, currentX / zoom)); + const val = snapVal(valFromY(moveEvent.clientY - rect.top)); + const updated = isPan ? { + time: Math.min(snapTime(ct), wallDuration), + pan: val + } : { + time: Math.min(snapTime(ct), wallDuration), + db: val + }; + const cleaned = mergeNodes(working.filter((_, i) => i !== dragIdx), [updated]); + if (onUpdateNodes) onUpdateNodes(cleaned); + working = [...cleaned]; + dragIdx = working.findIndex(n => n.time === updated.time && (isPan ? n.pan === updated.pan : n.db === updated.db)); + setSelectedNodeTime(updated.time); + }; + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + canvas.style.cursor = 'crosshair'; + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + } + return; + } + + // Select tool (default): drag to select range + subTabAnchorRef.current = startTime; + setSelectedNodeTime(null); + onSelectRange(startTime, startTime); + onPlayheadSet(startTime); + const handleMouseMove = moveEvent => { + const currentX = moveEvent.clientX - rect.left + scrollLeft; + const ct = Math.max(0, Math.min(wallDuration, currentX / zoom)); + const anchor = subTabAnchorRef.current ?? startTime; + onSelectRange(Math.min(anchor, ct), Math.max(anchor, ct)); + }; + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + const handleContextMenuInternal = e => { + e.preventDefault(); + e.stopPropagation(); + const canvas = canvasRef.current; + const rect = canvas.getBoundingClientRect(); + const parent = canvas.parentElement; + const scrollContainer = parent ? parent.parentElement : null; + const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; + const x = e.clientX - rect.left + scrollLeft; + const clickTime = Math.max(0, Math.min(buffer.duration / (speed || 1.0), x / zoom)); + onContextMenu(e, clickTime); + }; + + // Double-click on automation curve to create a new node + const handleDoubleClick = e => { + const canvas = canvasRef.current; + if (!canvas || !buffer) return; + if (activeTool !== 'select' && activeTool !== 'pen') return; + const rect = canvas.getBoundingClientRect(); + const parent = canvas.parentElement; + const scrollContainer = parent ? parent.parentElement : null; + const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; + const x = e.clientX - rect.left + scrollLeft; + const clickTime = Math.max(0, Math.min(buffer.duration / (speed || 1.0), x / zoom)); + const isPan = (graphMode || 'volume') === 'pan'; + const curNodes = isPan ? panningNodes : volumeNodes; + const cTop = 8; + const cHeight = rect.height - 16; + const valFromY = y => { + if (isPan) return Math.max(-1, Math.min(1, -(y - cTop) / cHeight * 2 + 1)); + const yOff = (y - cTop) / cHeight; + return yOff <= 2 / 3 ? Math.max(0, Math.min(3, 3 * (1 - yOff * 3 / 2))) : Math.max(-30, Math.min(0, -30 * (yOff - 2 / 3) * 3)); + }; + + // Interpolate value at click position from existing curve + let interpolatedVal = valFromY(e.clientY - rect.top); + if (curNodes.length >= 2) { + const sorted = [...curNodes].sort((a, b) => a.time - b.time); + for (let i = 0; i < sorted.length - 1; i++) { + if (clickTime >= sorted[i].time && clickTime <= sorted[i + 1].time) { + const t = (clickTime - sorted[i].time) / (sorted[i + 1].time - sorted[i].time); + const v1 = isPan ? sorted[i].pan : sorted[i].db; + const v2 = isPan ? sorted[i + 1].pan : sorted[i + 1].db; + interpolatedVal = v1 + t * (v2 - v1); + break; + } + } + } + const newNode = isPan ? { + time: +Math.round(clickTime * 10) / 10, + pan: +Math.round(interpolatedVal * 20) / 20 + } : { + time: +Math.round(clickTime * 10) / 10, + db: +Math.round(interpolatedVal * 2) / 2 + }; + const merged = (() => { + const map = new Map(); + curNodes.forEach(n => map.set(n.time, n)); + map.set(newNode.time, newNode); + return Array.from(map.values()).sort((a, b) => a.time - b.time); + })(); + if (onUpdateNodes) onUpdateNodes(merged); + }; + return /*#__PURE__*/React.createElement("div", { + style: { + width: `${timelineWidth}px`, + height: '100%', + position: 'relative', + overflow: 'hidden' + } + }, /*#__PURE__*/React.createElement("canvas", { + ref: canvasRef, + style: { + position: 'absolute', + top: 0, + left: 0, + imageRendering: 'pixelated' + }, + className: "cursor-crosshair rounded border border-zinc-800", + onMouseDown: handleMouseDown, + onDoubleClick: handleDoubleClick, + onContextMenu: handleContextMenuInternal, + onMouseMove: e => { + if (canvasRef.current && e.altKey && onSpeedChange) { + const rect = canvasRef.current.getBoundingClientRect(); + const parent = canvasRef.current.parentElement; + const scrollContainer = parent ? parent.parentElement : null; + const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; + const mx = e.clientX - rect.left + scrollLeft; + const wClip = buffer.duration / speed * zoom; + const tolerance = 8; + canvasRef.current.style.cursor = Math.abs(mx - wClip) <= tolerance && !isStretchingRef.current ? 'ew-resize' : 'crosshair'; + } else if (canvasRef.current && !isStretchingRef.current) { + const rect = canvasRef.current.getBoundingClientRect(); + const parent = canvasRef.current.parentElement; + const scrollContainer = parent ? parent.parentElement : null; + const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; + const mx = e.clientX - rect.left + scrollLeft; + const wClipPx = buffer.duration / (speed || 1.0) * zoom; + const cH = canvasRef.current.height / (window.devicePixelRatio || 1) - 16; + const btnX = wClipPx - 28 - 4; + const btnY = 8 + cH - 14 - 2; + const overBtn = mx >= btnX && mx <= btnX + 28 && e.clientY - rect.top >= btnY && e.clientY - rect.top <= btnY + 14; + canvasRef.current.style.cursor = overBtn ? 'pointer' : 'crosshair'; + } + } + })); +}; +const SubTabToolbar = ({ + st, + activeTool, + setActiveTool, + handleSubTabNormalizeWithValue, + handleSubTabGainWithValue, + handleSubTabPitch, + handleSubTabStretch, + handleSubTabFade, + onPlayPause, + onStop, + onRewind, + onForward, + onLoop, + onRecord, + onRateChange, + onCut, + onCopy, + onPaste, + onGlue, + snapValue, + onSnapChange +}) => { + const isPlaying = st?.isPlaying || false; + const isLooping = st?.isLooping || false; + const isRecording = st?.isRecording || false; + return /*#__PURE__*/React.createElement("div", { + className: "daw-header flex h-16 items-center px-4 border-b daw-border gap-3 bg-zinc-800" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center space-x-1 border-r border-zinc-700 pr-3" + }, /*#__PURE__*/React.createElement("button", { + className: `p-1 rounded ${activeTool === 'select' ? 'bg-cyan-700' : 'bg-zinc-700'}`, + onClick: () => setActiveTool('select'), + title: "Select Tool" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "mouse-pointer", + className: "w-4 h-4" + }))), /*#__PURE__*/React.createElement("button", { + className: `p-1 rounded ${activeTool === 'grab' ? 'bg-cyan-700' : 'bg-zinc-700'}`, + onClick: () => setActiveTool('grab'), + title: "Grab Tool" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "hand", + className: "w-4 h-4" + }))), /*#__PURE__*/React.createElement("button", { + className: `p-1 rounded ${activeTool === 'razor' ? 'bg-cyan-700' : 'bg-zinc-700'}`, + onClick: () => setActiveTool('razor'), + title: "Razor Tool" + }, /*#__PURE__*/React.createElement("svg", { + className: "w-4 h-4 text-orange-400", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: "2.5", + strokeLinecap: "round", + strokeLinejoin: "round" + }, /*#__PURE__*/React.createElement("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" + }), /*#__PURE__*/React.createElement("path", { + d: "M4 9h16l-3 9H7z" + }), /*#__PURE__*/React.createElement("circle", { + cx: "12", + cy: "6", + r: "1" + }))), /*#__PURE__*/React.createElement("button", { + className: `p-1 rounded ${activeTool === 'pen' ? 'bg-cyan-700' : 'bg-zinc-700'}`, + onClick: () => setActiveTool('pen'), + title: "Pen Tool" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "pen-tool", + className: "w-4 h-4" + }))), /*#__PURE__*/React.createElement("div", { + className: "w-[1px] h-4 bg-zinc-800 mx-0.5" + }), /*#__PURE__*/React.createElement("button", { + onClick: onGlue, + className: "p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-purple-400 transition", + title: "Glue Clips" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "link", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("button", { + onClick: onCut, + className: "p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-red-400 transition", + title: "Cut (Ctrl+X)" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "scissors", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("button", { + onClick: onCopy, + className: "p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-blue-400 transition", + title: "Copy (Ctrl+C)" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "copy", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("button", { + onClick: onPaste, + className: "p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-emerald-400 transition", + title: "Paste (Ctrl+V)" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "clipboard", + className: "w-3.5 h-3.5" + })))), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1 border-r border-zinc-700 pr-3" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[9px] text-zinc-500 font-bold uppercase" + }, "Snap"), /*#__PURE__*/React.createElement("select", { + value: snapValue || 'free', + onChange: e => onSnapChange(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 font-mono" + }, /*#__PURE__*/React.createElement("option", { + value: "free" + }, "Free"), /*#__PURE__*/React.createElement("option", { + value: "1" + }, "1"), /*#__PURE__*/React.createElement("option", { + value: "1/2" + }, "1/2"), /*#__PURE__*/React.createElement("option", { + value: "1/4" + }, "1/4"), /*#__PURE__*/React.createElement("option", { + value: "1/8" + }, "1/8"), /*#__PURE__*/React.createElement("option", { + value: "1/16" + }, "1/16"), /*#__PURE__*/React.createElement("option", { + value: "1/32" + }, "1/32"))), /*#__PURE__*/React.createElement("div", { + className: "flex items-center space-x-2 flex-1 overflow-x-auto no-scrollbar" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[10px] text-zinc-400" + }, "Normalize:"), /*#__PURE__*/React.createElement("input", { + type: "range", + min: "-12", + max: "0", + value: st.effects?.normalizeDb || 0, + step: "0.1", + onChange: e => handleSubTabNormalizeWithValue(st.id, parseFloat(e.target.value)), + className: "w-20 h-1.5" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[10px] text-zinc-500 w-10 text-right" + }, st.effects?.normalizeDb || 0, " dB")), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[10px] text-zinc-400" + }, "Gain:"), /*#__PURE__*/React.createElement("input", { + type: "range", + min: "-40", + max: "24", + value: st.effects?.gainDb || 0, + step: "0.1", + onChange: e => handleSubTabGainWithValue(st.id, parseFloat(e.target.value)), + className: "w-20 h-1.5" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[10px] text-zinc-500 w-10 text-right" + }, st.effects?.gainDb || 0, " dB")), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[10px] text-zinc-400" + }, "Pitch:"), /*#__PURE__*/React.createElement("input", { + type: "range", + min: "-12", + max: "12", + value: st.effects?.pitch || 0, + step: "0.1", + onChange: e => handleSubTabPitch(st.id, parseFloat(e.target.value)), + className: "w-20 h-1.5" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[10px] text-zinc-500 w-10 text-right" + }, st.effects?.pitch || 0, " st")), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[10px] text-zinc-400" + }, "Stretch:"), /*#__PURE__*/React.createElement("input", { + type: "range", + min: "50", + max: "200", + value: st.effects?.speedStretch || 100, + step: "1", + onChange: e => handleSubTabStretch(st.id, parseInt(e.target.value)), + className: "w-20 h-1.5" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[10px] text-zinc-500 w-10 text-right" + }, st.effects?.speedStretch || 100, "%"))), /*#__PURE__*/React.createElement("div", { + className: "flex items-center space-x-1 border-r border-zinc-700 pr-3" + }, /*#__PURE__*/React.createElement("button", { + onClick: () => handleSubTabFade(st.id, 'in'), + className: "px-2 py-1 text-[10px] rounded hover:bg-zinc-700" + }, "Fade In"), /*#__PURE__*/React.createElement("button", { + onClick: () => handleSubTabFade(st.id, 'out'), + className: "px-2 py-1 text-[10px] rounded hover:bg-zinc-700" + }, "Fade Out")), /*#__PURE__*/React.createElement("div", { + className: "flex items-center space-x-1 border-r border-zinc-700 pr-3" + }, /*#__PURE__*/React.createElement("button", { + onClick: onRewind, + className: "w-8 h-8 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", + title: "Rewind" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "skip-back", + className: "w-4 h-4" + }))), /*#__PURE__*/React.createElement("button", { + onClick: onPlayPause, + className: `w-8 h-8 flex items-center justify-center rounded border transition ${isPlaying ? 'bg-emerald-600 text-black border-emerald-500 hover:bg-emerald-500' : 'bg-cyan-600 text-white border-cyan-500 hover:bg-cyan-500'}`, + title: isPlaying ? "Pause" : "Play" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": isPlaying ? 'pause' : 'play', + className: "w-4 h-4 fill-current" + }))), /*#__PURE__*/React.createElement("button", { + onClick: onStop, + className: "w-8 h-8 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", + title: "Stop" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "square", + className: "w-4 h-4 fill-current" + }))), /*#__PURE__*/React.createElement("button", { + onClick: onForward, + className: "w-8 h-8 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", + title: "Forward" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "skip-forward", + className: "w-4 h-4" + }))), /*#__PURE__*/React.createElement("button", { + onClick: onLoop, + className: `w-8 h-8 flex items-center justify-center rounded border transition ${isLooping ? 'bg-amber-600 text-black border-amber-500 hover:bg-amber-500' : 'bg-zinc-800 text-zinc-200 border-zinc-700 hover:bg-zinc-700'}`, + title: isLooping ? "Loop On" : "Loop Off" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "repeat", + className: "w-4 h-4" + }))), /*#__PURE__*/React.createElement("button", { + onClick: onRecord, + className: `w-8 h-8 flex items-center justify-center rounded border transition ${isRecording ? 'bg-red-600 text-white border-red-500' : 'bg-zinc-800 text-zinc-200 border-zinc-700 hover:bg-zinc-700'}`, + title: isRecording ? "Recording" : "Record" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "circle", + className: "w-4 h-4" + })))), /*#__PURE__*/React.createElement("div", { + className: "flex items-center space-x-1" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[10px] text-zinc-400" + }, "Rate:"), /*#__PURE__*/React.createElement("select", { + value: st.playbackRate || 1, + onChange: e => onRateChange(parseFloat(e.target.value)), + className: "bg-zinc-700 text-zinc-100 text-[10px] px-1 py-0.5 rounded border border-zinc-600" + }, /*#__PURE__*/React.createElement("option", { + value: "0.5" + }, "0.5x"), /*#__PURE__*/React.createElement("option", { + value: "0.75" + }, "0.75x"), /*#__PURE__*/React.createElement("option", { + value: "1" + }, "1x"), /*#__PURE__*/React.createElement("option", { + value: "1.25" + }, "1.25x"), /*#__PURE__*/React.createElement("option", { + value: "1.5" + }, "1.5x"), /*#__PURE__*/React.createElement("option", { + value: "2" + }, "2x")))); +}; + +// ── Graph Editor Canvas for Volume/Pan/Fade Automation ── +const GraphEditorCanvas = ({ + buffer, + zoom, + timelineWidth, + volumeNodes, + panningNodes, + fadeInLen, + fadeOutLen, + onUpdateNodes, + graphMode +}) => { + const canvasRef = useRef(null); + const isDraggingNode = useRef(false); + const dragNodeIdx = useRef(-1); + const isCreatingNode = useRef(false); + const getNodes = () => graphMode === 'pan' ? panningNodes : volumeNodes; + const nodeLabel = n => graphMode === 'pan' ? `${n.pan.toFixed(2)}` : `${n.db.toFixed(1)}dB`; + const nodeY = (n, h) => { + if (graphMode === 'pan') return (1 - (n.pan + 1) / 2) * h; + const zeroY = 2 / 3 * h; + return n.db >= 0 ? zeroY - n.db / 3 * (2 / 3 * h) : zeroY + -n.db / 30 * (1 / 3 * h); + }; + const nodeValFromY = (y, h) => { + if (graphMode === 'pan') return -(y / h * 2 - 1); + const yOff = y / h; + return yOff <= 2 / 3 ? 3 * (1 - yOff * 3 / 2) : -30 * (yOff - 2 / 3) * 3; + }; + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !buffer) return; + const ctx = canvas.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null; + const scrollLeft = wrapper ? wrapper.scrollLeft : 0; + const vWidth = wrapper ? wrapper.clientWidth : 1200; + const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200)); + const h = canvas.parentElement ? canvas.parentElement.clientHeight : 200; + canvas.width = Math.min(Math.round(drawWidth * dpr), 32768); + canvas.height = Math.min(Math.round(h * dpr), 32768); + ctx.scale(dpr, dpr); + ctx.imageSmoothingEnabled = false; + canvas.style.position = 'absolute'; + canvas.style.left = `${scrollLeft}px`; + canvas.style.width = `${drawWidth}px`; + canvas.style.height = `${h}px`; + ctx.fillStyle = '#1a1a2e'; + ctx.fillRect(0, 0, drawWidth, h); + ctx.strokeStyle = '#2a2a4e'; + ctx.lineWidth = 0.5; + for (let t = 0; t <= buffer.duration; t += 0.5) { + const x = t / buffer.duration * drawWidth; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + } + for (let i = 0; i <= 10; i++) { + const y = i / 10 * h; + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(drawWidth, y); + ctx.stroke(); + } + if (fadeInLen > 0) { + const fadeX = fadeInLen / buffer.duration * drawWidth; + ctx.fillStyle = 'rgba(16, 185, 129, 0.12)'; + ctx.fillRect(0, 0, fadeX, h); + } + if (fadeOutLen > 0) { + const fadeX = (buffer.duration - fadeOutLen) / buffer.duration * drawWidth; + const fadeW = fadeOutLen / buffer.duration * drawWidth; + ctx.fillStyle = 'rgba(239, 68, 68, 0.12)'; + ctx.fillRect(fadeX, 0, fadeW, h); + } + const nodes = getNodes(); + if (nodes.length > 0) { + ctx.strokeStyle = graphMode === 'pan' ? '#a855f7' : '#06b6d4'; + ctx.lineWidth = 2; + ctx.beginPath(); + nodes.forEach((n, i) => { + const x = n.time / buffer.duration * drawWidth; + const y = nodeY(n, h); + if (i === 0) ctx.moveTo(x, y);else ctx.lineTo(x, y); + }); + ctx.stroke(); + nodes.forEach((n, i) => { + const x = n.time / buffer.duration * drawWidth; + const y = nodeY(n, h); + ctx.fillStyle = graphMode === 'pan' ? '#a855f7' : '#06b6d4'; + ctx.beginPath(); + ctx.arc(x, y, 5, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#e4e4e7'; + ctx.font = '9px monospace'; + ctx.fillText(nodeLabel(n), x + 8, y + 3); + }); + } else { + ctx.fillStyle = '#52525b'; + ctx.font = '11px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(graphMode === 'pan' ? 'Click to add Pan points' : 'Click to add Volume points', drawWidth / 2, h / 2); + ctx.textAlign = 'start'; + } + const zeroY = graphMode === 'pan' ? h / 2 : nodeY({ + db: 0 + }, h); + ctx.strokeStyle = graphMode === 'pan' ? '#a855f744' : '#06b6d444'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(0, zeroY); + ctx.lineTo(drawWidth, zeroY); + ctx.stroke(); + ctx.setLineDash([]); + }, [buffer, zoom, timelineWidth, volumeNodes, panningNodes, fadeInLen, fadeOutLen, graphMode]); + const handleMouseDown = e => { + const canvas = canvasRef.current; + if (!canvas || !buffer) return; + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const time = x / rect.width * buffer.duration; + const val = nodeValFromY(y, rect.height); + const nodes = getNodes(); + const snapped = Math.max(-30, Math.min(3, val)); + const snappedPan = Math.max(-1, Math.min(1, val)); + const threshold = 12 / rect.width * buffer.duration; + const nearIdx = nodes.findIndex(n => Math.abs(n.time - time) < threshold); + if (nearIdx >= 0) { + isDraggingNode.current = true; + dragNodeIdx.current = nearIdx; + return; + } + const newNode = graphMode === 'pan' ? { + time: +time.toFixed(3), + pan: +snappedPan.toFixed(2) + } : { + time: +time.toFixed(3), + db: +snapped.toFixed(1) + }; + const sorted = [...nodes, newNode].sort((a, b) => a.time - b.time); + onUpdateNodes(sorted); + const newIdx = sorted.findIndex(n => n.time === newNode.time && (graphMode === 'pan' ? n.pan : n.db) === (graphMode === 'pan' ? newNode.pan : newNode.db)); + isDraggingNode.current = true; + dragNodeIdx.current = newIdx; + isCreatingNode.current = true; + }; + const handleMouseMove = e => { + if (!isDraggingNode.current || dragNodeIdx.current < 0) return; + const canvas = canvasRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const time = Math.max(0, Math.min(buffer.duration, x / rect.width * buffer.duration)); + const val = nodeValFromY(y, rect.height); + const nodes = [...getNodes()]; + nodes[dragNodeIdx.current] = graphMode === 'pan' ? { + time: +time.toFixed(3), + pan: +Math.max(-1, Math.min(1, val)).toFixed(2) + } : { + time: +time.toFixed(3), + db: +Math.max(-30, Math.min(3, val)).toFixed(1) + }; + onUpdateNodes(nodes.sort((a, b) => a.time - b.time)); + }; + const handleMouseUp = () => { + isDraggingNode.current = false; + dragNodeIdx.current = -1; + isCreatingNode.current = false; + }; + const handleContextMenu = e => { + e.preventDefault(); + const canvas = canvasRef.current; + if (!canvas || !buffer) return; + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const time = x / rect.width * buffer.duration; + const threshold = 12 / rect.width * buffer.duration; + const nodes = getNodes(); + const nearIdx = nodes.findIndex(n => Math.abs(n.time - time) < threshold); + if (nearIdx >= 0) onUpdateNodes(nodes.filter((_, i) => i !== nearIdx)); + }; + return /*#__PURE__*/React.createElement("div", { + style: { + width: `${timelineWidth}px`, + height: '100%', + position: 'relative', + overflow: 'hidden' + } + }, /*#__PURE__*/React.createElement("canvas", { + ref: canvasRef, + style: { + position: 'absolute', + top: 0, + left: 0, + imageRendering: 'pixelated' + }, + className: "cursor-crosshair rounded border border-zinc-700", + onMouseDown: handleMouseDown, + onMouseMove: handleMouseMove, + onMouseUp: handleMouseUp, + onMouseLeave: handleMouseUp, + onContextMenu: handleContextMenu + })); +}; +const AuthModal = ({ + isOpen, + mode, + forceMandatory, + onClose, + onSuccess +}) => { + if (!isOpen) return null; + const [activeTab, setActiveTab] = useState(mode || 'login'); + const [username, setUsername] = useState('admin'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [oldPassword, setOldPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + useEffect(() => { + if (mode) setActiveTab(mode); + if (mode === 'force_change' && !oldPassword) { + setOldPassword('admin123'); + } + }, [mode]); + const handleSubmit = async e => { + e.preventDefault(); + setError(''); + setLoading(true); + try { + if (activeTab === 'login') { + const targetUsername = username.trim() || 'admin'; + const targetPwd = password.trim() || 'admin123'; + const res = await window.SonicAPI.login(targetUsername, targetPwd); + localStorage.setItem('sonic_token', res.access_token); + localStorage.setItem('sonic_user', JSON.stringify(res.user)); + if (res.user && res.user.must_change_password) { + setOldPassword(targetPwd); + } + onSuccess(res.user, res.access_token); + } else if (activeTab === 'register') { + const res = await window.SonicAPI.register(username.trim(), email.trim(), password.trim()); + localStorage.setItem('sonic_token', res.access_token); + localStorage.setItem('sonic_user', JSON.stringify(res.user)); + onSuccess(res.user, res.access_token); + } else if (activeTab === 'force_change') { + const res = await window.SonicAPI.changePassword(oldPassword.trim(), newPassword.trim()); + localStorage.setItem('sonic_token', res.access_token); + const user = JSON.parse(localStorage.getItem('sonic_user') || '{}'); + user.must_change_password = false; + localStorage.setItem('sonic_user', JSON.stringify(user)); + onSuccess(user, res.access_token); + } + } catch (err) { + setError(err.message || (activeTab === 'login' ? 'Tài khoản hoặc mật khẩu không chính xác. (Nếu bạn đã đổi mật khẩu trước đó, vui lòng nhập mật khẩu mới mà bạn đã tạo)' : 'Thao tác không thành công')); + } finally { + setLoading(false); + } + }; + const isForceMode = activeTab === 'force_change'; + const canClose = !forceMandatory && !isForceMode; + return /*#__PURE__*/React.createElement("div", { + className: "fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md" + }, /*#__PURE__*/React.createElement("div", { + className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-md p-6 text-slate-200" + }, /*#__PURE__*/React.createElement("div", { + className: "flex justify-between items-center pb-4 border-b border-[#383838]" + }, /*#__PURE__*/React.createElement("h3", { + className: "text-lg font-bold text-teal-400" + }, isForceMode ? '⚠️ Bắt Buộc Đổi Mật Khẩu Khởi Tạo' : activeTab === 'login' ? '🔐 Đăng Nhập Hệ Thống' : '📝 Đăng Ký Tài Khoản'), canClose && /*#__PURE__*/React.createElement("button", { + onClick: onClose, + className: "text-slate-400 hover:text-slate-200" + }, "✕")), error && /*#__PURE__*/React.createElement("div", { + className: "mt-4 p-3 bg-red-900/40 border border-red-700 rounded-lg text-red-200 text-sm" + }, error), /*#__PURE__*/React.createElement("form", { + onSubmit: handleSubmit, + className: "mt-4 space-y-4" + }, isForceMode ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("p", { + className: "text-xs text-amber-400 bg-amber-950/60 p-2.5 border border-amber-800/80 rounded leading-relaxed" + }, "🔒 Tài khoản của bạn đang dùng mật khẩu khởi tạo mặc định. Để bảo mật hệ thống, bạn phải đổi mật khẩu mới trước khi tiếp tục."), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-xs font-semibold mb-1 text-slate-400" + }, "Mật khẩu hiện tại (Mặc định: admin123)"), /*#__PURE__*/React.createElement("input", { + type: "password", + autoComplete: "current-password", + required: true, + value: oldPassword, + onChange: e => setOldPassword(e.target.value), + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" + })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-xs font-semibold mb-1 text-slate-400" + }, "Mật khẩu mới"), /*#__PURE__*/React.createElement("input", { + type: "password", + autoComplete: "new-password", + required: true, + value: newPassword, + onChange: e => setNewPassword(e.target.value), + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" + }))) : /*#__PURE__*/React.createElement(React.Fragment, null, activeTab === 'login' ? /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-xs font-semibold mb-1 text-slate-400" + }, "Tên đăng nhập ", /*#__PURE__*/React.createElement("span", { + className: "text-teal-400 font-normal" + }, "(Tùy chọn - Admin có thể bỏ trống)")), /*#__PURE__*/React.createElement("input", { + type: "text", + placeholder: "Mặc định: admin", + value: username, + onChange: e => setUsername(e.target.value), + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" + })) : /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-xs font-semibold mb-1 text-slate-400" + }, "Tên đăng nhập"), /*#__PURE__*/React.createElement("input", { + type: "text", + required: true, + value: username, + onChange: e => setUsername(e.target.value), + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" + })), activeTab === 'register' && /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-xs font-semibold mb-1 text-slate-400" + }, "Email"), /*#__PURE__*/React.createElement("input", { + type: "email", + required: true, + value: email, + onChange: e => setEmail(e.target.value), + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" + })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-xs font-semibold mb-1 text-slate-400" + }, "Mật khẩu ", activeTab === 'login' && /*#__PURE__*/React.createElement("span", { + className: "text-amber-400 font-normal" + }, "(Lần đầu: admin123)")), /*#__PURE__*/React.createElement("input", { + type: "password", + autoComplete: activeTab === 'login' ? 'current-password' : 'new-password', + required: true, + value: password, + onChange: e => setPassword(e.target.value), + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" + }))), /*#__PURE__*/React.createElement("button", { + type: "submit", + disabled: loading, + className: "w-full py-2 bg-teal-600 hover:bg-teal-500 text-white font-semibold rounded-lg shadow transition duration-150" + }, loading ? 'Đang xác thực...' : isForceMode ? 'Đổi Mật Khẩu Ngay' : activeTab === 'login' ? 'Đăng Nhập System' : 'Tạo Tài Khoản Mới'), activeTab === 'login' && !isForceMode && /*#__PURE__*/React.createElement("button", { + type: "button", + onClick: () => { + setUsername('admin'); + setPassword('admin123'); + setError(''); + }, + className: "w-full mt-2 py-1.5 bg-amber-950/60 hover:bg-amber-900/80 text-amber-300 border border-amber-800/60 text-xs font-semibold rounded-lg transition flex items-center justify-center gap-1.5" + }, "🔑 Điền Nhanh Tài Khoản Admin (admin / admin123)")), !isForceMode && /*#__PURE__*/React.createElement("div", { + className: "mt-4 pt-4 border-t border-[#383838] text-center text-xs text-slate-400" + }, activeTab === 'login' ? /*#__PURE__*/React.createElement("span", null, "Chưa có tài khoản? ", /*#__PURE__*/React.createElement("button", { + onClick: () => setActiveTab('register'), + className: "text-teal-400 hover:underline" + }, "Đăng ký ngay")) : /*#__PURE__*/React.createElement("span", null, "Đã có tài khoản? ", /*#__PURE__*/React.createElement("button", { + onClick: () => setActiveTab('login'), + className: "text-teal-400 hover:underline" + }, "Đăng nhập"))))); +}; +const AIConfigModal = ({ + isOpen, + onClose +}) => { + if (!isOpen) return null; + const defaultProvidersList = [{ + id: 'openai_default', + name: 'OpenAI Official', + provider_type: 'openai', + api_base_url: 'https://api.openai.com/v1', + api_key: '', + model_name: 'gpt-4o', + temperature: 0.7, + is_active: true + }, { + id: 'openai_compat_default', + name: 'OpenAI Compatible (Ollama/LocalAI/DeepSeek)', + provider_type: 'openai_compatible', + api_base_url: 'http://localhost:11434/v1', + api_key: 'ollama', + model_name: 'deepseek-r1', + temperature: 0.7, + is_active: false + }, { + id: 'anthropic_default', + name: 'Anthropic Claude', + provider_type: 'anthropic', + api_base_url: 'https://api.anthropic.com/v1', + api_key: '', + model_name: 'claude-3-5-sonnet', + temperature: 0.7, + is_active: false + }, { + id: 'gemini_default', + name: 'Google Gemini', + provider_type: 'gemini', + api_base_url: 'https://generativelanguage.googleapis.com', + api_key: '', + model_name: 'gemini-1.5-pro', + temperature: 0.7, + is_active: false + }]; + const [providers, setProviders] = useState(defaultProvidersList); + const [selectedId, setSelectedId] = useState('openai_default'); + const [msg, setMsg] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + useEffect(() => { + if (isOpen) loadConfigs(); + }, [isOpen]); + const loadConfigs = async () => { + setLoading(true); + setError(''); + try { + const data = await window.SonicAPI.getAIConfigs(); + if (data && data.providers) { + setProviders(data.providers); + if (data.providers.length > 0) setSelectedId(data.providers[0].id); + } + } catch (err) { + setError(err.message || 'Lỗi nạp cấu hình AI'); + } finally { + setLoading(false); + } + }; + const handleSave = async e => { + e.preventDefault(); + setMsg(''); + setError(''); + setLoading(true); + try { + const res = await window.SonicAPI.saveAIConfigs(providers); + setMsg(res.message || 'Đã lưu cấu hình AI Providers thành công!'); + } catch (err) { + setError(err.message || 'Lỗi khi lưu cấu hình AI'); + } finally { + setLoading(false); + } + }; + const updateProviderField = (id, field, value) => { + setProviders(prev => prev.map(p => p.id === id ? { + ...p, + [field]: value + } : p)); + }; + const activeProvider = providers.find(p => p.id === selectedId) || providers[0]; + return /*#__PURE__*/React.createElement("div", { + className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm select-none" + }, /*#__PURE__*/React.createElement("div", { + className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-2xl p-6 text-slate-200" + }, /*#__PURE__*/React.createElement("div", { + className: "flex justify-between items-center pb-4 border-b border-[#383838]" + }, /*#__PURE__*/React.createElement("h3", { + className: "text-lg font-bold text-cyan-400 flex items-center gap-2" + }, "🤖 Quản Lý & Cấu Hình AI Providers"), /*#__PURE__*/React.createElement("button", { + onClick: onClose, + className: "text-slate-400 hover:text-slate-200" + }, "✕")), msg && /*#__PURE__*/React.createElement("div", { + className: "mt-3 p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs" + }, msg), error && /*#__PURE__*/React.createElement("div", { + className: "mt-3 p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs" + }, error), /*#__PURE__*/React.createElement("div", { + className: "mt-4 grid grid-cols-3 gap-4" + }, /*#__PURE__*/React.createElement("div", { + className: "space-y-1.5 border-r border-[#383838] pr-3" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[10px] uppercase font-bold text-slate-400 block mb-2" + }, "Providers"), providers.map(p => /*#__PURE__*/React.createElement("button", { + key: p.id, + onClick: () => setSelectedId(p.id), + className: `w-full text-left px-3 py-2 rounded-lg text-xs font-semibold flex items-center justify-between transition ${selectedId === p.id ? 'bg-cyan-600 text-white shadow' : 'bg-[#1e1e1e] text-slate-300 hover:bg-[#2e2e2e]'}` + }, /*#__PURE__*/React.createElement("span", { + className: "truncate" + }, p.name), p.is_active && /*#__PURE__*/React.createElement("span", { + className: "w-2 h-2 rounded-full bg-emerald-400" + })))), activeProvider && /*#__PURE__*/React.createElement("form", { + onSubmit: handleSave, + className: "col-span-2 space-y-3.5" + }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-xs font-semibold text-slate-400 mb-1" + }, "Tên Provider"), /*#__PURE__*/React.createElement("input", { + type: "text", + value: activeProvider.name, + onChange: e => updateProviderField(activeProvider.id, 'name', e.target.value), + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-cyan-500" + })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-xs font-semibold text-slate-400 mb-1" + }, "API Base URL (Endpoint)"), /*#__PURE__*/React.createElement("input", { + type: "text", + value: activeProvider.api_base_url || '', + onChange: e => updateProviderField(activeProvider.id, 'api_base_url', e.target.value), + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-cyan-300 focus:outline-none focus:border-cyan-500", + placeholder: "https://api.openai.com/v1" + })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-xs font-semibold text-slate-400 mb-1" + }, "API Key Cá Nhân"), /*#__PURE__*/React.createElement("input", { + type: "password", + value: activeProvider.api_key || '', + onChange: e => updateProviderField(activeProvider.id, 'api_key', e.target.value), + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-slate-200 focus:outline-none focus:border-cyan-500", + placeholder: "sk-..." + })), /*#__PURE__*/React.createElement("div", { + className: "grid grid-cols-2 gap-3" + }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-xs font-semibold text-slate-400 mb-1" + }, "Model Name"), /*#__PURE__*/React.createElement("input", { + type: "text", + value: activeProvider.model_name || '', + onChange: e => updateProviderField(activeProvider.id, 'model_name', e.target.value), + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-amber-300 focus:outline-none focus:border-cyan-500" + })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-xs font-semibold text-slate-400 mb-1" + }, "Temperature"), /*#__PURE__*/React.createElement("input", { + type: "number", + step: "0.1", + min: "0", + max: "2", + value: activeProvider.temperature ?? 0.7, + onChange: e => updateProviderField(activeProvider.id, 'temperature', parseFloat(e.target.value)), + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-cyan-500" + }))), /*#__PURE__*/React.createElement("div", { + className: "pt-2 flex items-center justify-between" + }, /*#__PURE__*/React.createElement("label", { + className: "flex items-center gap-2 cursor-pointer text-xs text-slate-300" + }, /*#__PURE__*/React.createElement("input", { + type: "checkbox", + checked: activeProvider.is_active, + onChange: e => updateProviderField(activeProvider.id, 'is_active', e.target.checked), + className: "rounded accent-cyan-500" + }), "Kích hoạt Provider này"), /*#__PURE__*/React.createElement("button", { + type: "submit", + disabled: loading, + className: "px-4 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-white font-semibold text-xs rounded-lg shadow transition" + }, loading ? 'Đang lưu...' : 'Lưu Cấu Hình AI')))))); +}; +const ProfileModal = ({ + isOpen, + onClose +}) => { + if (!isOpen) return null; + const [profile, setProfile] = useState(null); + const [oldPassword, setOldPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [msg, setMsg] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + useEffect(() => { + if (isOpen) fetchProfile(); + }, [isOpen]); + const fetchProfile = async () => { + try { + const data = await window.SonicAPI.getProfile(); + setProfile(data); + } catch (e) { + setError(e.message || 'Không thể tải thông tin profile'); + } + }; + const handleChangePassword = async e => { + e.preventDefault(); + setMsg(''); + setError(''); + setLoading(true); + try { + const res = await window.SonicAPI.changePassword(oldPassword, newPassword); + setMsg(res.message || 'Đổi mật khẩu thành công!'); + setOldPassword(''); + setNewPassword(''); + } catch (err) { + setError(err.message || 'Lỗi khi đổi mật khẩu'); + } finally { + setLoading(false); + } + }; + return /*#__PURE__*/React.createElement("div", { + className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" + }, /*#__PURE__*/React.createElement("div", { + className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-lg p-6 text-slate-200" + }, /*#__PURE__*/React.createElement("div", { + className: "flex justify-between items-center pb-4 border-b border-[#383838]" + }, /*#__PURE__*/React.createElement("h3", { + className: "text-lg font-bold text-teal-400" + }, "👤 Hồ Sơ Cá Nhân & Hạn Mức Quota"), /*#__PURE__*/React.createElement("button", { + onClick: onClose, + className: "text-slate-400 hover:text-slate-200" + }, "✕")), profile && /*#__PURE__*/React.createElement("div", { + className: "mt-4 space-y-4" + }, /*#__PURE__*/React.createElement("div", { + className: "bg-[#1e1e1e] p-4 rounded-lg border border-[#333] grid grid-cols-2 gap-4 text-xs" + }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", { + className: "text-slate-500 block" + }, "Tên người dùng"), /*#__PURE__*/React.createElement("span", { + className: "font-bold text-teal-300 text-sm" + }, profile.username)), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", { + className: "text-slate-500 block" + }, "Vai trò"), /*#__PURE__*/React.createElement("span", { + className: "uppercase font-semibold text-amber-400" + }, profile.role)), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", { + className: "text-slate-500 block" + }, "Email"), /*#__PURE__*/React.createElement("span", null, profile.email)), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", { + className: "text-slate-500 block" + }, "Dung lượng Quota"), /*#__PURE__*/React.createElement("span", { + className: "font-semibold text-slate-200" + }, profile.quota.used_mb, " MB / ", profile.quota.storage_limit_mb, " MB"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("div", { + className: "flex justify-between text-xs mb-1" + }, /*#__PURE__*/React.createElement("span", { + className: "text-slate-400" + }, "Tiến trình sử dụng bộ nhớ Server"), /*#__PURE__*/React.createElement("span", { + className: "font-bold text-teal-400" + }, (profile.quota.used_mb / profile.quota.storage_limit_mb * 100).toFixed(1), "%")), /*#__PURE__*/React.createElement("div", { + className: "w-full h-2 bg-slate-800 rounded-full overflow-hidden" + }, /*#__PURE__*/React.createElement("div", { + className: "h-full bg-teal-500 rounded-full transition-all duration-300", + style: { + width: `${Math.min(100, profile.quota.used_mb / profile.quota.storage_limit_mb * 100)}%` + } + }))), /*#__PURE__*/React.createElement("form", { + onSubmit: handleChangePassword, + className: "pt-4 border-t border-[#383838] space-y-3" + }, /*#__PURE__*/React.createElement("h4", { + className: "text-xs font-bold text-slate-300 uppercase" + }, "Thay Đổi Mật Khẩu"), msg && /*#__PURE__*/React.createElement("div", { + className: "p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs" + }, msg), error && /*#__PURE__*/React.createElement("div", { + className: "p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs" + }, error), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-xs text-slate-400 mb-1" + }, "Mật khẩu cũ"), /*#__PURE__*/React.createElement("input", { + type: "password", + autoComplete: "current-password", + required: true, + value: oldPassword, + onChange: e => setOldPassword(e.target.value), + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs focus:outline-none focus:border-teal-500" + })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-xs text-slate-400 mb-1" + }, "Mật khẩu mới"), /*#__PURE__*/React.createElement("input", { + type: "password", + autoComplete: "new-password", + required: true, + value: newPassword, + onChange: e => setNewPassword(e.target.value), + className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs focus:outline-none focus:border-teal-500" + })), /*#__PURE__*/React.createElement("button", { + type: "submit", + disabled: loading, + className: "w-full py-1.5 bg-teal-600 hover:bg-teal-500 text-white font-semibold text-xs rounded transition" + }, loading ? 'Đang cập nhật...' : 'Cập Nhật Mật Khẩu'))))); +}; +const SystemManagerModal = ({ + isOpen, + onClose +}) => { + if (!isOpen) return null; + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [msg, setMsg] = useState(''); + const [error, setError] = useState(''); + const [editingQuotaUser, setEditingQuotaUser] = useState(null); + const [newQuotaMb, setNewQuotaMb] = useState(500); + useEffect(() => { + if (isOpen) loadUsers(); + }, [isOpen]); + const loadUsers = async () => { + setLoading(true); + setError(''); + try { + const data = await window.SonicAPI.listUsers(); + setUsers(data); + } catch (err) { + setError(err.message || 'Không thể tải danh sách người dùng hệ thống'); + } finally { + setLoading(false); + } + }; + const handleSaveQuota = async userId => { + try { + await window.SonicAPI.updateUserQuota(userId, parseInt(newQuotaMb)); + setMsg('Đã cập nhật hạn mức Quota thành công!'); + setEditingQuotaUser(null); + loadUsers(); + } catch (err) { + setError(err.message || 'Lỗi cập nhật Quota'); + } + }; + const handleToggleRole = async user => { + const nextRole = user.role === 'admin' ? 'standard' : 'admin'; + try { + await window.SonicAPI.updateUserRole(user.id, nextRole, user.is_active); + setMsg(`Đã đổi vai trò người dùng ${user.username} thành ${nextRole}`); + loadUsers(); + } catch (err) { + setError(err.message || 'Lỗi cập nhật vai trò'); + } + }; + const handleDeleteUser = async userId => { + if (!confirm('Bạn có chắc chắn muốn xóa người dùng này khỏi hệ thống?')) return; + try { + await window.SonicAPI.deleteUser(userId); + setMsg('Đã xóa người dùng thành công'); + loadUsers(); + } catch (err) { + setError(err.message || 'Lỗi khi xóa người dùng'); + } + }; + return /*#__PURE__*/React.createElement("div", { + className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" + }, /*#__PURE__*/React.createElement("div", { + className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-4xl p-6 text-slate-200" + }, /*#__PURE__*/React.createElement("div", { + className: "flex justify-between items-center pb-4 border-b border-[#383838]" + }, /*#__PURE__*/React.createElement("h3", { + className: "text-lg font-bold text-amber-400" + }, "⚙️ Quản Lý Hệ Thống & Phân Quyền Admin"), /*#__PURE__*/React.createElement("button", { + onClick: onClose, + className: "text-slate-400 hover:text-slate-200" + }, "✕")), msg && /*#__PURE__*/React.createElement("div", { + className: "mt-3 p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs" + }, msg), error && /*#__PURE__*/React.createElement("div", { + className: "mt-3 p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs" + }, error), /*#__PURE__*/React.createElement("div", { + className: "mt-4 overflow-x-auto max-h-96 no-scrollbar" + }, loading ? /*#__PURE__*/React.createElement("div", { + className: "py-8 text-center text-slate-400 text-xs" + }, "Đang tải thông tin hệ thống...") : /*#__PURE__*/React.createElement("table", { + className: "w-full text-left text-xs border-collapse" + }, /*#__PURE__*/React.createElement("thead", null, /*#__PURE__*/React.createElement("tr", { + className: "border-b border-[#383838] text-slate-400 bg-[#1e1e1e]" + }, /*#__PURE__*/React.createElement("th", { + className: "p-3" + }, "Tên Người Dùng"), /*#__PURE__*/React.createElement("th", { + className: "p-3" + }, "Email"), /*#__PURE__*/React.createElement("th", { + className: "p-3" + }, "Vai Trò"), /*#__PURE__*/React.createElement("th", { + className: "p-3" + }, "Dung Lượng Sử Dụng"), /*#__PURE__*/React.createElement("th", { + className: "p-3" + }, "Hạn Mức Quota"), /*#__PURE__*/React.createElement("th", { + className: "p-3 text-right" + }, "Thao Tác"))), /*#__PURE__*/React.createElement("tbody", { + className: "divide-y divide-[#333]" + }, users.map(u => /*#__PURE__*/React.createElement("tr", { + key: u.id, + className: "hover:bg-[#2e2e2e]" + }, /*#__PURE__*/React.createElement("td", { + className: "p-3 font-semibold text-teal-300" + }, u.username, u.must_change_password && /*#__PURE__*/React.createElement("span", { + className: "ml-2 text-[10px] bg-amber-900/60 text-amber-300 px-1.5 py-0.5 rounded" + }, "Mật khẩu gốc")), /*#__PURE__*/React.createElement("td", { + className: "p-3 text-slate-300" + }, u.email), /*#__PURE__*/React.createElement("td", { + className: "p-3 uppercase font-bold text-amber-400" + }, u.role), /*#__PURE__*/React.createElement("td", { + className: "p-3" + }, u.used_mb, " MB"), /*#__PURE__*/React.createElement("td", { + className: "p-3" + }, editingQuotaUser === u.id ? /*#__PURE__*/React.createElement("div", { + className: "flex items-center space-x-1" + }, /*#__PURE__*/React.createElement("input", { + type: "number", + value: newQuotaMb, + onChange: e => setNewQuotaMb(e.target.value), + className: "w-16 bg-[#1e1e1e] border border-[#444] rounded px-1 py-0.5 text-xs text-slate-200" + }), /*#__PURE__*/React.createElement("span", null, "MB"), /*#__PURE__*/React.createElement("button", { + onClick: () => handleSaveQuota(u.id), + className: "px-2 py-0.5 bg-teal-600 rounded text-[10px]" + }, "Lưu")) : /*#__PURE__*/React.createElement("span", { + className: "font-semibold" + }, u.quota_mb, " MB")), /*#__PURE__*/React.createElement("td", { + className: "p-3 text-right space-x-2" + }, /*#__PURE__*/React.createElement("button", { + onClick: () => { + setEditingQuotaUser(u.id); + setNewQuotaMb(u.quota_mb); + }, + className: "px-2 py-1 bg-slate-700 hover:bg-slate-600 rounded text-[11px]" + }, "Sửa Quota"), /*#__PURE__*/React.createElement("button", { + onClick: () => handleToggleRole(u), + className: "px-2 py-1 bg-amber-700/60 hover:bg-amber-600 rounded text-[11px]" + }, "Đổi Role"), u.role !== 'admin' && /*#__PURE__*/React.createElement("button", { + onClick: () => handleDeleteUser(u.id), + className: "px-2 py-1 bg-red-700/60 hover:bg-red-600 rounded text-[11px]" + }, "Xóa"))))))))); +}; +const App = () => { + // ── State Definitions ── + const [tracks, setTracks] = useState([{ + id: '1', + name: 'Track 01', + buffer: null, + startTime: 0, + height: 128, + volumeDb: 0, + pan: 0, + muted: false, + solo: false, + color: '#0f766e', + markers: [], + serverFileId: null, + clips: [] + }, { + id: '2', + name: 'Track 02', + buffer: null, + startTime: 0, + height: 128, + 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 } + const [hoveredTrackId, setHoveredTrackId] = useState(null); + const openPanel = id => { + if (id === 'export') setShowExportPanel(true);else if (id === 'ai') setShowAIPanel(true);else if (id === 'python_tools') setShowPythonToolsPanel(true);else if (id === 'selection') setShowSelectionPanel(true); + }; + const [activeTool, setActiveTool] = useState('select'); // 'select' | 'grab' | 'razor' + const [snapValue, setSnapValue] = useState('free'); // 'free', '1', '1/2', '1/4', '1/8', '1/16', '1/32' + + const snapTime = (time, snapVal, bpmVal) => { + if (snapVal === 'free') return time; + const beatDuration = 60 / parseFloat(bpmVal || 120); + let divisor = 1; + if (snapVal === '1') divisor = 1;else if (snapVal === '1/2') divisor = 0.5;else if (snapVal === '1/4') divisor = 0.25;else if (snapVal === '1/8') divisor = 0.125;else if (snapVal === '1/16') divisor = 0.0625;else if (snapVal === '1/32') divisor = 0.03125; + const gridSpacing = beatDuration * divisor; + return Math.round(time / gridSpacing) * gridSpacing; + }; + const snapValueRef = useRef(snapValue); + snapValueRef.current = snapValue; + const bpmRef = useRef(bpm); + bpmRef.current = bpm; + // BMP for Tempo Track - LOOP_EDITOR_2.md §6 + const [selectedTrackId, setSelectedTrackId] = useState('1'); + const [currentTime, setCurrentTime] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + const [selectionStart, setSelectionStart] = useState(null); + const [selectionEnd, setSelectionEnd] = useState(null); + const [selectionMode, setSelectionMode] = useState(null); // 'global' (from ruler) | 'local' (from track) + const [localSelectionTrackId, setLocalSelectionTrackId] = useState(null); + const [localSelectionStart, setLocalSelectionStart] = useState(null); + const [localSelectionEnd, setLocalSelectionEnd] = useState(null); + const [mainZoom, setMainZoom] = useState(100); + const [subTabZoom, setSubTabZoom] = useState(100); + let zoom = 100; + const [isLoopingSelection, setIsLoopingSelection] = useState(false); + const [beginBar, setBeginBar] = useState(1); + const [endBar, setEndBar] = useState(1); + const [numberBar, setNumberBar] = useState(1); + const [subTabHeight, setSubTabHeight] = useState(96); + const [isExporting, setIsExporting] = useState(false); + const [soloedTrackId, setSoloedTrackId] = useState(null); + const [toastMessage, setToastMessage] = useState(null); + const [showAIConfig, setShowAIConfig] = useState(false); + const [showExportPanel, setShowExportPanel] = useState(true); + const [showAIPanel, setShowAIPanel] = useState(true); + const [showSelectionPanel, setShowSelectionPanel] = useState(true); + const [showPythonToolsPanel, setShowPythonToolsPanel] = useState(true); + const [panelPositions, setPanelPositions] = useState({ + export: 'bottom', + ai: 'bottom', + python_tools: 'bottom', + selection: 'bottom' + }); + const [panelDropZone, setPanelDropZone] = useState(null); + const [dragGhostPos, setDragGhostPos] = useState(null); + const [dragGhostPanel, setDragGhostPanel] = useState(null); + const panelDragRef = useRef(null); + const workspaceRef = useRef(null); + const [aiConfig, setAiConfig] = useState({ + baseUrl: localStorage.getItem('ai_base_url') || `${API_BASE_URL}`, + apiKey: localStorage.getItem('ai_api_key') || '', + model: localStorage.getItem('ai_model') || 'deepseek-chat' + }); + const [analysisState, setAnalysisState] = useState({ + status: 'Sẵn sàng. Chạy AI để phân tích nhịp.', + data: null, + isRunning: false + }); + const [exportSettings, setExportSettings] = useState({ + sampleRate: '44100', + bitDepth: '16', + format: 'wav' + }); + const [serverStatus, setServerStatus] = useState('checking...'); + 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 } + const clipboardRef = useRef(null); // { buffer, name, volume, color } for copy/paste + + // ── Undo/Redo Engine (LOOP_EDITOR.md §4) ── + const [undoStack, setUndoStack] = useState([]); + const [redoStack, setRedoStack] = useState([]); + const MAX_UNDO = 30; + const pushAction = (actionType, trackId, beforeState, afterState) => { + const node = { + action_type: actionType, + track_id: trackId, + timestamp: Date.now(), + before_state: beforeState, + after_state: afterState + }; + setUndoStack(prev => { + const next = [...prev, node]; + if (next.length > MAX_UNDO) next.shift(); + return next; + }); + setRedoStack([]); + }; + const handleUndo = () => { + if (undoStack.length === 0) return; + const last = undoStack[undoStack.length - 1]; + setUndoStack(prev => prev.slice(0, -1)); + setRedoStack(prev => [...prev, last]); + applyTrackState(last.track_id, last.before_state); + showToast(`Undo: ${last.action_type}`, 'info'); + }; + const handleRedo = () => { + if (redoStack.length === 0) return; + const last = redoStack[redoStack.length - 1]; + setRedoStack(prev => prev.slice(0, -1)); + setUndoStack(prev => [...prev, last]); + applyTrackState(last.track_id, last.after_state); + showToast(`Redo: ${last.action_type}`, 'info'); + }; + const applyTrackState = (trackId, state) => { + setTracks(prev => prev.map(t => { + if (t.id !== trackId) return t; + return { + ...t, + ...state + }; + })); + }; + const captureTrackSnapshot = trackId => { + const track = tracks.find(t => t.id === trackId); + if (!track) return null; + return { + volumeDb: track.volumeDb, + pan: track.pan, + muted: track.muted, + name: track.name, + markers: JSON.parse(JSON.stringify(track.markers || [])), + // buffer is captured via reference copy for undo; we store a clone for redo + buffer: track.buffer, + startTime: track.startTime || 0, + clips: track.clips ? track.clips.map(c => ({ + id: c.id, + buffer: c.buffer, + startTime: c.startTime, + name: c.name + })) : null + }; + }; + + // ── Tab System (LOOP_EDITOR_2.md §1) ── + const [activeTab, setActiveTab] = useState('main'); + zoom = activeTab === 'main' ? mainZoom : subTabZoom; + const [subTabSelectedNodeTime, setSubTabSelectedNodeTime] = useState(null); + const [subTabNormVal, setSubTabNormVal] = useState(0); + const [subTabGainVal, setSubTabGainVal] = useState(100); + const [subTabPitchVal, setSubTabPitchVal] = useState(0); + const [subTabs, setSubTabs] = useState([]); // [{id, label, trackId, startTime, endTime, buffer, effects, currentTime, selectionStart, selectionEnd, isPlaying}, ...] + const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2 + + // ── Temp Edit Tab (LOOP_EDITOR_2.md §1.2 Sub Tab) ── + const [tempTabActive, setTempTabActive] = useState(false); + const [tempTabBuffer, setTempTabBuffer] = useState(null); + const [tempTabTrackId, setTempTabTrackId] = useState(null); + const [tempTabOrigStart, setTempTabOrigStart] = useState(0); + const [tempTabOrigEnd, setTempTabOrigEnd] = useState(0); + const tempTabCanvasRef = useRef(null); + + // Effect parameters for temp tab + const [tempTabEffects, setTempTabEffects] = useState({ + reverse: false, + gainDb: 0, + fadeInMs: 0, + fadeOutMs: 0 + }); + + // ── Auth / User State ── + const [currentUser, setCurrentUser] = useState(null); + const [authModalOpen, setAuthModalOpen] = useState(false); + const [authMode, setAuthMode] = useState('login'); // 'login' | 'register' | 'force_change' + const [isMandatoryLogin, setIsMandatoryLogin] = useState(false); + const [profileModalOpen, setProfileModalOpen] = useState(false); + const [systemManagerModalOpen, setSystemManagerModalOpen] = useState(false); + const [aiConfigModalOpen, setAiConfigModalOpen] = useState(false); + useEffect(() => { + const checkAuthStatus = async () => { + const savedToken = localStorage.getItem('sonic_token'); + if (!savedToken) { + setIsMandatoryLogin(true); + setAuthMode('login'); + setAuthModalOpen(true); + return; + } + try { + const profile = await window.SonicAPI.getProfile(); + setCurrentUser(profile); + if (profile.must_change_password) { + setIsMandatoryLogin(true); + setAuthMode('force_change'); + setAuthModalOpen(true); + } else { + setIsMandatoryLogin(false); + setAuthModalOpen(false); + } + } catch (err) { + localStorage.removeItem('sonic_token'); + localStorage.removeItem('sonic_user'); + setCurrentUser(null); + setIsMandatoryLogin(true); + setAuthMode('login'); + setAuthModalOpen(true); + } + }; + checkAuthStatus(); + }, []); + const loadPendingSfsProject = () => { + const proj = window.__pendingSfsProject; + if (!proj) return; + try { + const restored = (proj.tracks || []).map(t => ({ + ...t, + buffer: null, + channelInfo: t.channelInfo || null, + clips: t.clips || [], + serverFileId: t.serverFileId || null + })); + if (restored.length > 0) { + setTracks(restored); + showToast(`Đã tải dự án "${proj.name}" từ liên kết .sfs thành công!`, "success"); + } + } catch (e) { + showToast("Lỗi tải dự án từ .sfs", "error"); + } finally { + window.__pendingSfsProject = null; + } + }; + const handleAuthSuccess = user => { + setCurrentUser(user); + if (user.must_change_password) { + setIsMandatoryLogin(true); + setAuthMode('force_change'); + setAuthModalOpen(true); + } else { + setIsMandatoryLogin(false); + setAuthModalOpen(false); + loadPendingSfsProject(); + } + }; + const handleLogout = () => { + localStorage.removeItem('sonic_token'); + localStorage.removeItem('sonic_user'); + setCurrentUser(null); + setIsMandatoryLogin(true); + setAuthMode('login'); + setAuthModalOpen(true); + }; + + // ── Temp project auto-save (local + server) ── + useEffect(() => { + const serializeSafe = arr => (arr || []).map(t => ({ + id: t.id, + name: t.name, + startTime: t.startTime, + height: t.height, + volumeDb: t.volumeDb, + pan: t.pan, + muted: t.muted, + solo: t.solo, + color: t.color, + markers: t.markers || [], + serverFileId: t.serverFileId || null, + channelInfo: t.channelInfo ? { + channels: t.channelInfo.channels, + isStereo: t.channelInfo.isStereo, + label: t.channelInfo.label + } : null + })); + window.SonicStorage.scheduleTempAutoSave(() => ({ + id: 'temp_project', + name: 'Dự án tạm chưa lưu', + tracks: serializeSafe(tracks), + subTabs: (subTabs || []).map(s => ({ + id: s.id, + label: s.label, + trackId: s.trackId, + clipId: s.clipId, + startTime: s.startTime, + endTime: s.endTime, + speed: s.speed, + fadeInLen: s.fadeInLen || 0, + fadeOutLen: s.fadeOutLen || 0, + graphMode: s.graphMode, + volumeNodes: s.volumeNodes || [], + panningNodes: s.panningNodes || [], + channelInfo: s.channelInfo ? { + channels: s.channelInfo.channels, + isStereo: s.channelInfo.isStereo, + label: s.channelInfo.label + } : null + })) + })); + }, [tracks, subTabs]); + + // Lucide icons initialization + useEffect(() => { + setTimeout(() => { + if (window.lucide) { + window.lucide.createIcons(); + } + }, 50); + }, [activeTool, activeTab]); + const timelineWrapperRef = useRef(null); + const tcpContainerRef = useRef(null); + const handleTimelineScroll = e => { + if (tcpContainerRef.current) { + tcpContainerRef.current.scrollTop = e.currentTarget.scrollTop; + } + }; + const handleTCPScroll = e => { + if (timelineWrapperRef.current) { + timelineWrapperRef.current.scrollTop = e.currentTarget.scrollTop; + } + }; + const panelDropZoneRef = useRef(null); + const startPanelDrag = (panelId, e) => { + panelDragRef.current = { + panelId, + startX: e.clientX, + startY: e.clientY + }; + setPanelDropZone(null); + setDragGhostPanel(panelId); + setDragGhostPos({ + x: e.clientX - 120, + y: e.clientY - 20 + }); + const onMove = ev => { + if (!panelDragRef.current || !workspaceRef.current) return; + const rect = workspaceRef.current.getBoundingClientRect(); + const x = ev.clientX - rect.left; + const y = ev.clientY - rect.top; + const w = rect.width; + const h = rect.height; + const margin = 60; + let zone = null; + if (x < margin && x > 10) zone = 'left';else if (x > w - margin && x < w - 10) zone = 'right';else if (y < margin && y > 10) zone = 'top';else if (y > h - margin && y < h - 10) zone = 'bottom'; + panelDropZoneRef.current = zone; + setPanelDropZone(zone); + setDragGhostPos({ + x: ev.clientX - 120, + y: ev.clientY - 20 + }); + }; + const onUp = () => { + if (panelDragRef.current) { + const pid = panelDragRef.current.panelId; + const targetZone = panelDropZoneRef.current; + if (targetZone && targetZone !== panelPositions[pid]) { + setPanelPositions(prev => ({ + ...prev, + [pid]: targetZone + })); + } + panelDragRef.current = null; + panelDropZoneRef.current = null; + setPanelDropZone(null); + setDragGhostPos(null); + setDragGhostPanel(null); + } + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }; + const rulerRef = useRef(null); + const activeSourcesRef = useRef([]); + const activeTrackNodesRef = useRef({}); // { [trackId]: { gainNode, pannerNode } } + const startOffsetTimeRef = useRef(0); + const startBufferOffsetRef = useRef(0); + const startAudioTimeRef = useRef(0); + const animationFrameIdRef = useRef(null); + const toastTimeoutRef = useRef(null); + const rulerDragStartRef = useRef(null); + const rulerAnchorRef = useRef(null); + const isDraggingRulerRef = useRef(false); + const handlePlayPauseRef = useRef(null); + + // ── Keyboard Shortcuts ── + const handleUndoRef = useRef(handleUndo); + const handleRedoRef = useRef(handleRedo); + handleUndoRef.current = handleUndo; + handleRedoRef.current = handleRedo; + const selectedClipIdRef = useRef(null); + selectedClipIdRef.current = selectedClipId; + const activeTabRef = useRef(activeTab); + activeTabRef.current = activeTab; + const activePlaybackSpeedRef = useRef(1.0); + const subTabsRef = useRef(subTabs); + subTabsRef.current = subTabs; + const subTabSelectedNodeTimeRef = useRef(null); + subTabSelectedNodeTimeRef.current = subTabSelectedNodeTime; + const handleSubTabNormalize = tabId => { + setSubTabs(prev => prev.map(st => { + if (st.id !== tabId || !st.buffer) return st; + const ctx = getAudioContext(); + const data = st.buffer.getChannelData(0); + const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : 0; + const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : st.buffer.duration; + const startSample = Math.floor(left * st.buffer.sampleRate); + const endSample = Math.floor(right * st.buffer.sampleRate); + let maxVal = 0; + for (let i = startSample; i < endSample; i++) { + const abs = Math.abs(data[i]); + if (abs > maxVal) maxVal = abs; + } + if (maxVal === 0) return st; + const scale = 1.0 / maxVal; + const clonedBuffer = ctx.createBuffer(1, data.length, st.buffer.sampleRate); + const clonedData = clonedBuffer.getChannelData(0); + clonedData.set(data); + for (let i = startSample; i < endSample; i++) { + clonedData[i] = Math.max(-1, Math.min(1, clonedData[i] * scale)); + } + showToast('Đã Normalize vùng chọn.', 'success'); + return { + ...st, + buffer: clonedBuffer + }; + })); + }; + const handleSubTabGain = (tabId, gainDb) => { + setSubTabs(prev => prev.map(st => { + if (st.id !== tabId || !st.buffer) return st; + const ctx = getAudioContext(); + const data = st.buffer.getChannelData(0); + const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : 0; + const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : st.buffer.duration; + const startSample = Math.floor(left * st.buffer.sampleRate); + const endSample = Math.floor(right * st.buffer.sampleRate); + const scale = Math.pow(10, gainDb / 20); + const clonedBuffer = ctx.createBuffer(1, data.length, st.buffer.sampleRate); + const clonedData = clonedBuffer.getChannelData(0); + clonedData.set(data); + for (let i = startSample; i < endSample; i++) { + clonedData[i] = Math.max(-1, Math.min(1, clonedData[i] * scale)); + } + showToast(`Đã điều chỉnh Gain: ${gainDb} dB.`, 'success'); + return { + ...st, + buffer: clonedBuffer + }; + })); + }; + const handleSubTabFade = (tabId, type) => { + setSubTabs(prev => prev.map(st => { + if (st.id !== tabId || !st.buffer) return st; + const ctx = getAudioContext(); + const data = st.buffer.getChannelData(0); + const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : 0; + const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : st.buffer.duration; + const startSample = Math.floor(left * st.buffer.sampleRate); + const endSample = Math.floor(right * st.buffer.sampleRate); + const durationSamples = endSample - startSample; + if (durationSamples <= 0) return st; + const clonedBuffer = ctx.createBuffer(1, data.length, st.buffer.sampleRate); + const clonedData = clonedBuffer.getChannelData(0); + clonedData.set(data); + if (type === 'in') { + for (let i = 0; i < durationSamples; i++) { + const alpha = i / durationSamples; + clonedData[startSample + i] *= alpha; + } + showToast('Đã áp dụng Fade In.', 'success'); + } else if (type === 'out') { + for (let i = 0; i < durationSamples; i++) { + const alpha = (durationSamples - i) / durationSamples; + clonedData[startSample + i] *= alpha; + } + showToast('Đã áp dụng Fade Out.', 'success'); + } + return { + ...st, + buffer: clonedBuffer + }; + })); + }; + const handleSubTabCut = tabId => { + const st = subTabsRef.current.find(s => s.id === tabId); + if (!st || !st.buffer) return; + const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : null; + const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : null; + if (left === null || right === null || left === right) { + showToast('Vui lòng chọn vùng để Cut.', 'warning'); + return; + } + const ctx = getAudioContext(); + const sr = st.buffer.sampleRate; + const data = st.buffer.getChannelData(0); + const startSample = Math.floor(left * sr); + const endSample = Math.floor(right * sr); + const len = endSample - startSample; + const cutBuffer = ctx.createBuffer(1, len, sr); + cutBuffer.copyToChannel(data.subarray(startSample, endSample), 0); + clipboardRef.current = { + buffer: cutBuffer, + name: 'Subtab Clip' + }; + const newBuffer = ctx.createBuffer(1, data.length - len, sr); + const newData = newBuffer.getChannelData(0); + let idx = 0; + for (let i = 0; i < startSample; i++) newData[idx++] = data[i]; + for (let i = endSample; i < data.length; i++) newData[idx++] = data[i]; + setSubTabs(prev => prev.map(s => s.id === tabId ? { + ...s, + buffer: newBuffer, + currentTime: left, + selectionStart: null, + selectionEnd: null + } : s)); + showToast('Đã Cut vùng chọn.', 'success'); + }; + const handleSubTabCopy = tabId => { + const st = subTabsRef.current.find(s => s.id === tabId); + if (!st || !st.buffer) return; + const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : null; + const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : null; + if (left === null || right === null || left === right) { + showToast('Vui lòng chọn vùng để Copy.', 'warning'); + return; + } + const ctx = getAudioContext(); + const sr = st.buffer.sampleRate; + const data = st.buffer.getChannelData(0); + const startSample = Math.floor(left * sr); + const endSample = Math.floor(right * sr); + const len = endSample - startSample; + const copyBuffer = ctx.createBuffer(1, len, sr); + copyBuffer.copyToChannel(data.subarray(startSample, endSample), 0); + clipboardRef.current = { + buffer: copyBuffer, + name: 'Subtab Clip' + }; + showToast('Đã Copy vùng chọn.', 'success'); + }; + const handleSubTabPaste = tabId => { + const st = subTabsRef.current.find(s => s.id === tabId); + if (!st || !st.buffer) return; + if (!clipboardRef.current || !clipboardRef.current.buffer) { + showToast('Clipboard trống.', 'warning'); + return; + } + const ctx = getAudioContext(); + const clipBuf = clipboardRef.current.buffer; + const sr = st.buffer.sampleRate; + const data = st.buffer.getChannelData(0); + const insertTime = st.currentTime || 0; + const insertSample = Math.floor(insertTime * sr); + const newBuffer = ctx.createBuffer(1, data.length + clipBuf.length, sr); + const newData = newBuffer.getChannelData(0); + let idx = 0; + for (let i = 0; i < insertSample; i++) newData[idx++] = data[i]; + const clipData = clipBuf.getChannelData(0); + for (let i = 0; i < clipBuf.length; i++) newData[idx++] = clipData[i]; + for (let i = insertSample; i < data.length; i++) newData[idx++] = data[i]; + setSubTabs(prev => prev.map(s => s.id === tabId ? { + ...s, + buffer: newBuffer, + currentTime: insertTime + clipBuf.duration, + selectionStart: null, + selectionEnd: null + } : s)); + showToast('Đã dán dữ liệu âm thanh.', 'success'); + }; + const handleSubTabDelete = tabId => { + const st = subTabsRef.current.find(s => s.id === tabId); + if (!st || !st.buffer) return; + const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : null; + const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : null; + if (left === null || right === null || left === right) { + showToast('Vui lòng chọn vùng để Delete.', 'warning'); + return; + } + const ctx = getAudioContext(); + const sr = st.buffer.sampleRate; + const data = st.buffer.getChannelData(0); + const startSample = Math.floor(left * sr); + const endSample = Math.floor(right * sr); + const len = endSample - startSample; + const newBuffer = ctx.createBuffer(1, data.length - len, sr); + const newData = newBuffer.getChannelData(0); + let idx = 0; + for (let i = 0; i < startSample; i++) newData[idx++] = data[i]; + for (let i = endSample; i < data.length; i++) newData[idx++] = data[i]; + setSubTabs(prev => prev.map(s => s.id === tabId ? { + ...s, + buffer: newBuffer, + currentTime: left, + selectionStart: null, + selectionEnd: null + } : s)); + showToast('Đã xóa vùng chọn.', 'success'); + }; + const handleSubTabLoop = (tabId, loopCount) => { + const st = subTabsRef.current.find(s => s.id === tabId); + if (!st || !st.buffer) return; + const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : null; + const right = st.selectionStart !== null && st.selectionEnd !== null ? Math.max(st.selectionStart, st.selectionEnd) : null; + if (left === null || right === null || left === right) { + showToast('Vui lòng chọn vùng để Loop.', 'warning'); + return; + } + const ctx = getAudioContext(); + const sr = st.buffer.sampleRate; + const data = st.buffer.getChannelData(0); + const startSample = Math.floor(left * sr); + const endSample = Math.floor(right * sr); + const len = endSample - startSample; + + // Loop payload N times + const segmentData = data.subarray(startSample, endSample); + const addedSamples = len * (loopCount - 1); + const newBuffer = ctx.createBuffer(1, data.length + addedSamples, sr); + const newData = newBuffer.getChannelData(0); + let idx = 0; + for (let i = 0; i < endSample; i++) newData[idx++] = data[i]; + for (let n = 0; n < loopCount - 1; n++) { + for (let i = 0; i < len; i++) newData[idx++] = segmentData[i]; + } + for (let i = endSample; i < data.length; i++) newData[idx++] = data[i]; + setSubTabs(prev => prev.map(s => s.id === tabId ? { + ...s, + buffer: newBuffer, + selectionStart: null, + selectionEnd: null + } : s)); + showToast(`Đã lặp vùng chọn ${loopCount} lần.`, 'success'); + }; + useEffect(() => { + const handler = e => { + // Bypass global hotkeys when typing inside input/textarea/contentEditable elements + if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable)) { + return; + } + 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 (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; + } + if (ctrl && e.key === 'l') { + e.preventDefault(); + handleSubTabLoop(curTabId, 4); + return; + } + if (e.key === 'v' || e.key === 'V') { + e.preventDefault(); + const val = prompt("Nhập Gain điều chỉnh (dB):", "0"); + if (val) handleSubTabGain(curTabId, parseFloat(val) || 0); + return; + } + if (ctrl && e.key === 'x') { + e.preventDefault(); + handleSubTabCut(curTabId); + return; + } + if (ctrl && e.key === 'c') { + e.preventDefault(); + handleSubTabCopy(curTabId); + return; + } + if (ctrl && e.key === 'v') { + e.preventDefault(); + handleSubTabPaste(curTabId); + return; + } + if (e.key === 'Delete' || e.key === 'Backspace' || e.key === 'Del') { + e.preventDefault(); + if (subTabSelectedNodeTimeRef.current !== null) { + const selTime = subTabSelectedNodeTimeRef.current; + setSubTabs(prev => prev.map(s => { + if (s.id !== curTabId) return s; + const curNodes = s.graphMode === 'pan' ? s.panningNodes || [] : s.volumeNodes || []; + const updated = curNodes.filter(n => n.time !== selTime); + return { + ...s, + [s.graphMode === 'pan' ? 'panningNodes' : 'volumeNodes']: updated + }; + })); + setSubTabSelectedNodeTime(null); + } else { + handleSubTabDelete(curTabId); + } + return; + } + 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 && !alt && e.key === 'o') { + e.preventDefault(); + handleImportSFS(); + return; + } + if (ctrl && !alt && e.key === 'n') { + e.preventDefault(); + setTracks([{ + id: '1', + name: 'Track 01', + buffer: null, + startTime: 0, + height: 128, + volumeDb: 0, + pan: 0, + muted: false, + solo: false, + color: '#0f766e', + markers: [], + serverFileId: null + }, { + id: '2', + name: 'Track 02', + buffer: null, + startTime: 0, + height: 128, + 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(); + handleExportSFS(); + return; + } + if (ctrl && alt && e.key === 's' || ctrl && e.shiftKey && e.key === 's') { + e.preventDefault(); + handleExportSFS(); + return; + } + if (ctrl && !alt && e.key === 'i') { + e.preventDefault(); + addNewTrack(); + return; + } + if (ctrl && alt && e.key === 'i') { + e.preventDefault(); + showToast('Import audio', 'info'); + return; + } + if (ctrl && !alt && e.key === 'e') { + e.preventDefault(); + openTempTab(); + return; + } + if (ctrl && !alt && e.key === 'm') { + e.preventDefault(); + handleMergeTracks(); + return; + } + if (ctrl && !alt && e.key === 'c') { + e.preventDefault(); + handleCopyTrack(); + return; + } + if (ctrl && !alt && e.key === 'x') { + e.preventDefault(); + handleCutTrack(); + return; + } + if (ctrl && !alt && e.key === 'v') { + e.preventDefault(); + handlePasteTrack(); + return; + } + if (e.key === 'Delete' || e.key === 'Backspace' || e.key === 'Del') { + const selClip = selectedClipIdRef.current; + if (selClip) { + e.preventDefault(); + const { + trackId, + clipId + } = selClip; + setTracks(prev => { + const track = prev.find(t => t.id === trackId); + if (!track) return prev; + const beforeSnap = captureTrackSnapshotRef.current ? captureTrackSnapshotRef.current(trackId) : null; + const updatedClips = (track.clips || []).filter(c => c.id !== clipId); + const updatedTracks = prev.map(t => { + if (t.id === trackId) { + return { + ...t, + clips: updatedClips, + buffer: updatedClips.length > 0 ? updatedClips[0].buffer : null, + startTime: updatedClips.length > 0 ? updatedClips[0].startTime : 0, + name: updatedClips.length > 0 ? updatedClips[0].name : `Track ${t.id}` + }; + } + return t; + }); + setTimeout(() => { + const afterSnap = captureTrackSnapshotRef.current ? captureTrackSnapshotRef.current(trackId) : null; + pushAction('DELETE_CLIP', trackId, beforeSnap, afterSnap); + }, 50); + return updatedTracks; + }); + setSelectedClipId(null); + showToast('Đã xóa clip.', 'info'); + return; + } else { + e.preventDefault(); + handleDeleteTrack(); + return; + } + } + if (!ctrl && !alt && e.key === 's') { + e.preventDefault(); + handleSplitTrack(selectedTrackId); + return; + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); + + // ── Temp Tab: draw isolated waveform ── + useEffect(() => { + if (!tempTabActive || !tempTabBuffer || !tempTabCanvasRef.current) return; + const canvas = tempTabCanvasRef.current; + const ctx = canvas.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + canvas.width = Math.min(Math.round(rect.width * dpr), 32768); + canvas.height = Math.min(Math.round(rect.height * dpr), 32768); + ctx.scale(dpr, dpr); + const w = rect.width; + const h = rect.height; + ctx.fillStyle = '#181818'; + ctx.fillRect(0, 0, w, h); + const data = tempTabBuffer.getChannelData(0); + const sr = tempTabBuffer.sampleRate; + const totalSamples = data.length; + if (totalSamples === 0) return; + ctx.strokeStyle = '#6ee7b7'; + ctx.lineWidth = 1; + for (let px = 0; px < w; px++) { + const startSample = Math.floor(px / w * totalSamples); + const endSample = Math.floor((px + 1) / w * totalSamples); + let maxVal = 0; + for (let i = startSample; i < endSample && i < totalSamples; i++) { + const abs = Math.abs(data[i]); + if (abs > maxVal) maxVal = abs; + } + const mid = h / 2; + const peakHeight = maxVal * (h * 0.4); + ctx.beginPath(); + ctx.moveTo(px, mid - peakHeight); + ctx.lineTo(px, mid + peakHeight); + ctx.stroke(); + } + }, [tempTabActive, tempTabBuffer]); + + // ── Sub Tab: open as new tab instead of modal (LOOP_EDITOR_2.md §1) ── + const openTempTab = () => { + const useLocal = selectionMode === 'local' && localSelectionTrackId; + const trackId = useLocal ? localSelectionTrackId : selectedTrackId; + const t = tracks.find(x => x.id === trackId); + if (!t || !t.buffer) { + showToast('Vui lòng chọn track có dữ liệu âm thanh.', 'warning'); + return; + } + if (selLeft === null || selRight === null || selRight <= selLeft) { + showToast('Vui lòng chọn một khoảng thời gian trên sóng âm.', 'warning'); + return; + } + + // Check if a subtab for this track+range already exists + const existing = subTabs.find(s => s.trackId === trackId && s.startTime === selLeft && s.endTime === selRight); + if (existing) { + setActiveTab(existing.id); + showToast(`Sub Tab already open.`, 'info'); + return; + } + const sr = t.buffer.sampleRate; + const trackStart = t.startTime || 0; + const relSelLeft = Math.max(0, selLeft - trackStart); + const relSelRight = Math.max(0, selRight - trackStart); + const startSample = Math.max(0, Math.floor(relSelLeft * sr)); + const endSample = Math.min(t.buffer.length, Math.floor(relSelRight * sr)); + const len = endSample - startSample; + if (len < 100) { + showToast('Khoảng chọn quá ngắn.', 'warning'); + return; + } + const ctx = getAudioContext(); + const numChannels = t.buffer.numberOfChannels || 1; + const subBuffer = ctx.createBuffer(numChannels, len, sr); + for (let c = 0; c < numChannels; c++) { + subBuffer.copyToChannel(t.buffer.getChannelData(c).subarray(startSample, endSample), c); + } + const subChannelInfo = window.SonicAudio.analyzeAudioBufferChannels(subBuffer); + const tabId = 'subtab_' + Date.now(); + const tabLabel = `Edit_${t.name.replace('.wav', '').slice(0, 10)}_${selLeft.toFixed(1)}s`; + setSubTabs(prev => [...prev, { + id: tabId, + label: tabLabel, + trackId: trackId, + startTime: selLeft, + endTime: selRight, + buffer: subBuffer, + channelInfo: subChannelInfo, + effects: { + reverse: false, + gainDb: 0, + fadeInMs: 0, + fadeOutMs: 0, + normalizeDb: 0, + pitch: 0, + speedStretch: 100 + }, + currentTime: 0, + selectionStart: null, + selectionEnd: null, + isPlaying: false, + speed: 1.0, + volumeNodes: [], + panningNodes: [], + fadeInLen: 0, + fadeOutLen: 0, + graphMode: null, + isLooping: false, + loopCount: 0 + }]); + setActiveTab(tabId); + }; + const handleEditClipInSubTab = (trackId, clipId) => { + const track = tracks.find(t => t.id === trackId); + if (!track) return; + const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ + id: 'default_' + track.id, + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name + }] : []; + const clip = clips.find(c => c.id === clipId || clipId === 'default' && c.id === 'default_' + trackId); + if (!clip || !clip.buffer) return; + const resolvedClipId = clip.id === 'default' ? 'default_' + trackId : clip.id; + const existing = subTabs.find(s => s.clipId === resolvedClipId && s.trackId === trackId); + if (existing) { + setActiveTab(existing.id); + showToast(`Sub Tab for "${clip.name}" already open.`, 'info'); + return; + } + const sr = clip.buffer.sampleRate; + const len = clip.buffer.length; + const numChannels = clip.buffer.numberOfChannels || 1; + const ctx = getAudioContext(); + const subBuffer = ctx.createBuffer(numChannels, len, sr); + for (let c = 0; c < numChannels; c++) { + subBuffer.copyToChannel(clip.buffer.getChannelData(c), c); + } + const subChannelInfo = window.SonicAudio.analyzeAudioBufferChannels(subBuffer); + const tabId = 'subtab_' + Date.now(); + const tabLabel = `Edit_${clip.name.replace('.wav', '').slice(0, 10)}`; + setSubTabs(prev => [...prev, { + id: tabId, + label: tabLabel, + trackId: trackId, + clipId: resolvedClipId, + startTime: clip.startTime, + endTime: clip.startTime + clip.buffer.duration, + buffer: subBuffer, + channelInfo: subChannelInfo, + effects: { + reverse: false, + gainDb: 0, + fadeInMs: 0, + fadeOutMs: 0, + normalizeDb: 0, + pitch: 0, + speedStretch: 100 + }, + currentTime: 0, + selectionStart: null, + selectionEnd: null, + isPlaying: false, + speed: 1.0, + volumeNodes: [], + panningNodes: [], + fadeInLen: 0, + fadeOutLen: 0, + graphMode: null, + isLooping: false, + loopCount: 0 + }]); + setActiveTab(tabId); + }; + + // ── Sub Tab: apply effects & merge back (LOOP_EDITOR_2.md §1.2) ── + const applySubTab = tabId => { + const subTab = subTabs.find(s => s.id === tabId); + if (!subTab || !subTab.buffer) return; + const track = tracks.find(t => t.id === subTab.trackId); + if (!track || !track.buffer) return; + const beforeSnap = captureTrackSnapshot(subTab.trackId); + + // Clone buffer and apply effects + const ctx = getAudioContext(); + const sr = subTab.buffer.sampleRate; + const eff = subTab.buffer.getChannelData(0); + let resultBuffer = ctx.createBuffer(1, eff.length, sr); + let resultData = resultBuffer.getChannelData(0); + resultData.set(eff); + + // Apply effects inline + const fx = subTab.effects || {}; + const applyResample = (data, ratio) => { + const newLen = Math.round(data.length * ratio); + const out = new Float32Array(newLen); + for (let i = 0; i < newLen; i++) { + const srcIdx = i / ratio; + const idx0 = Math.floor(srcIdx); + const idx1 = Math.min(idx0 + 1, data.length - 1); + const frac = srcIdx - idx0; + out[i] = data[idx0] * (1 - frac) + data[idx1] * frac; + } + return out; + }; + if (fx.reverse) { + const rev = new Float32Array(resultData); + for (let i = 0; i < resultData.length; i++) rev[i] = resultData[resultData.length - 1 - i]; + resultData.set(rev); + } + if (fx.gainDb !== 0) { + const gain = Math.pow(10, fx.gainDb / 20); + for (let i = 0; i < resultData.length; i++) resultData[i] = Math.max(-1, Math.min(1, resultData[i] * gain)); + } + if (fx.fadeInMs > 0) { + const fadeSamples = Math.min(resultData.length, Math.floor(fx.fadeInMs / 1000 * sr)); + for (let i = 0; i < fadeSamples; i++) resultData[i] *= i / fadeSamples; + } + // Graph Editor fade curves (trigonometric, 15_GRAPH_EDIT.md §2.3) + const gFadeIn = subTab.fadeInLen || 0; + const gFadeOut = subTab.fadeOutLen || 0; + if (gFadeIn > 0) { + const fadeSamples = Math.min(resultData.length, Math.floor(gFadeIn * sr)); + for (let i = 0; i < fadeSamples; i++) { + resultData[i] *= (1 - Math.cos(Math.PI * i / fadeSamples)) / 2; + } + } + if (gFadeOut > 0) { + const fadeSamples = Math.min(resultData.length, Math.floor(gFadeOut * sr)); + for (let i = resultData.length - fadeSamples; i < resultData.length; i++) { + const t = i - (resultData.length - fadeSamples); + resultData[i] *= (1 + Math.cos(Math.PI * t / fadeSamples)) / 2; + } + } + + // Graph Editor volume automation spline (Monotone Cubic Hermite Spline, 16_FIX_GRAPH.md §2.1) + const volNodes = subTab.volumeNodes || []; + if (volNodes.length > 0) { + const sortedNodes = [...volNodes].sort((a, b) => a.time - b.time); + const computeHermiteTangents = pts => { + const n = pts.length; + if (n < 2) return []; + const m = new Array(n); + for (let i = 1; i < n - 1; i++) { + const hP = pts[i].time - pts[i - 1].time; + const hN = pts[i + 1].time - pts[i].time; + const sP = (pts[i].db - pts[i - 1].db) / hP; + const sN = (pts[i + 1].db - pts[i].db) / hN; + m[i] = (sP + sN) / 2; + } + m[0] = n > 1 ? (pts[1].db - pts[0].db) / (pts[1].time - pts[0].time) : 0; + m[n - 1] = n > 1 ? (pts[n - 1].db - pts[n - 2].db) / (pts[n - 1].time - pts[n - 2].time) : 0; + return m; + }; + const tangents = computeHermiteTangents(sortedNodes); + const getVolumeGainAtTime = t => { + if (sortedNodes.length === 1) { + return Math.pow(10, sortedNodes[0].db / 20); + } + if (t <= sortedNodes[0].time) { + return Math.pow(10, sortedNodes[0].db / 20); + } + if (t >= sortedNodes[sortedNodes.length - 1].time) { + return Math.pow(10, sortedNodes[sortedNodes.length - 1].db / 20); + } + for (let i = 0; i < sortedNodes.length - 1; i++) { + const n1 = sortedNodes[i]; + const n2 = sortedNodes[i + 1]; + if (t >= n1.time && t <= n2.time) { + const h = n2.time - n1.time; + if (h <= 0) return Math.pow(10, n1.db / 20); + const frac = (t - n1.time) / h; + const frac2 = frac * frac, + frac3 = frac2 * frac; + const db = (2 * frac3 - 3 * frac2 + 1) * n1.db + (frac3 - 2 * frac2 + frac) * h * tangents[i] + (-2 * frac3 + 3 * frac2) * n2.db + (frac3 - frac2) * h * tangents[i + 1]; + return Math.pow(10, db / 20); + } + } + return 1.0; + }; + for (let i = 0; i < resultData.length; i++) { + const t = i / sr; + resultData[i] *= getVolumeGainAtTime(t); + } + } + if (fx.normalizeDb !== 0) { + let maxVal = 0; + for (let i = 0; i < resultData.length; i++) { + const abs = Math.abs(resultData[i]); + if (abs > maxVal) maxVal = abs; + } + if (maxVal > 0) { + const targetAmp = Math.pow(10, fx.normalizeDb / 20); + const scale = targetAmp / maxVal; + for (let i = 0; i < resultData.length; i++) resultData[i] = Math.max(-1, Math.min(1, resultData[i] * scale)); + } + } + if (fx.pitch !== 0) { + const ratio = Math.pow(2, fx.pitch / 12); + const newData = applyResample(resultData, 1 / ratio); + resultBuffer = ctx.createBuffer(1, newData.length, sr); + resultData = resultBuffer.getChannelData(0); + resultData.set(newData); + } + const stretchSpeed = subTab.speed || 1.0; + if (stretchSpeed !== 1.0) { + const ratio = 1.0 / stretchSpeed; + const newData = applyResample(resultData, ratio); + resultBuffer = ctx.createBuffer(1, newData.length, sr); + resultData = resultBuffer.getChannelData(0); + resultData.set(newData); + } + if (fx.speedStretch !== 100) { + const ratio = fx.speedStretch / 100; + const newData = applyResample(resultData, ratio); + resultBuffer = ctx.createBuffer(1, newData.length, sr); + resultData = resultBuffer.getChannelData(0); + resultData.set(newData); + } + if (subTab.clipId) { + // Clip-based merge + setTracks(prev => prev.map(t => { + if (t.id !== subTab.trackId) return t; + const updatedClips = (t.clips || []).map(c => { + if (c.id === subTab.clipId) { + return { + ...c, + buffer: resultBuffer, + name: c.name.endsWith('(edited)') ? c.name : c.name + ' (edited)' + }; + } + return c; + }); + return { + ...t, + clips: updatedClips, + buffer: updatedClips[0]?.buffer, + startTime: updatedClips[0]?.startTime || 0, + name: updatedClips[0]?.name || t.name + }; + })); + } else { + // Crossfade merge into original track (§2.2) + const sr = track.buffer.sampleRate; + const origData = track.buffer.getChannelData(0); + const trackStart = track.startTime || 0; + const startSample = Math.floor((subTab.startTime - trackStart) * sr); + const endSample = Math.floor((subTab.endTime - trackStart) * sr); + const crossfadeLen = Math.min(100, Math.floor(0.01 * sr)); // 10ms + + const mergedBuffer = ctx.createBuffer(1, track.buffer.length, sr); + const mergedData = mergedBuffer.getChannelData(0); + for (let i = 0; i < startSample; i++) mergedData[i] = origData[i]; + for (let i = endSample; i < track.buffer.length; i++) mergedData[i] = origData[i]; + for (let i = 0; i < resultData.length; i++) { + const globalIdx = startSample + i; + let val = resultData[i]; + if (i < crossfadeLen) { + const alpha = i / crossfadeLen; + val = (1 - alpha) * (origData[globalIdx] || 0) + alpha * resultData[i]; + } else if (i > resultData.length - crossfadeLen) { + const distFromEnd = resultData.length - 1 - i; + const alpha = distFromEnd / crossfadeLen; + const origEndIdx = endSample - (resultData.length - i); + val = alpha * (origEndIdx >= 0 ? origData[origEndIdx] : 0) + (1 - alpha) * resultData[i]; + } + mergedData[globalIdx] = val; + } + setTracks(prev => prev.map(t => { + if (t.id !== subTab.trackId) return t; + return { + ...t, + buffer: mergedBuffer, + name: t.name + ' (edited)' + }; + })); + } + const afterSnap = captureTrackSnapshot(subTab.trackId); + pushAction('EDIT_TAB', subTab.trackId, beforeSnap, afterSnap); + + // Tab Lifetime: Engaging the Apply trigger propagates data back to the primary environment but does not close down the active sub-tab view. + // closeSubTab(tabId); + showToast('Đã áp dụng chỉnh sửa vào track chính.', 'success'); + }; + const applySubTabEffect = (tabId, effectType, value) => { + const subTab = subTabs.find(s => s.id === tabId); + if (!subTab || !subTab.buffer) return; + const ctx = getAudioContext(); + const sr = subTab.buffer.sampleRate; + const eff = subTab.buffer.getChannelData(0); + + // Clone buffer + let resultBuffer = ctx.createBuffer(1, eff.length, sr); + let resultData = resultBuffer.getChannelData(0); + resultData.set(eff); + + // Calculate selection start/end in unstretched sample indices + const speed = subTab.speed || 1.0; + const hasSelection = subTab.selectionStart !== null && subTab.selectionEnd !== null && subTab.selectionStart !== subTab.selectionEnd; + const tStart = hasSelection ? Math.min(subTab.selectionStart, subTab.selectionEnd) * speed : 0; + const tEnd = hasSelection ? Math.max(subTab.selectionStart, subTab.selectionEnd) * speed : subTab.buffer.duration; + const startSample = Math.max(0, Math.min(eff.length, Math.floor(tStart * sr))); + const endSample = Math.max(0, Math.min(eff.length, Math.floor(tEnd * sr))); + if (effectType === 'normalize') { + let maxVal = 0; + for (let i = startSample; i < endSample; i++) { + const abs = Math.abs(resultData[i]); + if (abs > maxVal) maxVal = abs; + } + if (maxVal > 0) { + const targetAmp = Math.pow(10, value / 20); + const scale = targetAmp / maxVal; + for (let i = startSample; i < endSample; i++) { + resultData[i] = Math.max(-1, Math.min(1, resultData[i] * scale)); + } + } + } else if (effectType === 'gain') { + const gainScale = value / 100; + for (let i = startSample; i < endSample; i++) { + resultData[i] = Math.max(-1, Math.min(1, resultData[i] * gainScale)); + } + } else if (effectType === 'pitch') { + const ratio = Math.pow(2, value / 12); + const applyResample = (data, r) => { + const newLen = Math.round(data.length * r); + const out = new Float32Array(newLen); + for (let i = 0; i < newLen; i++) { + const srcIdx = i / r; + const idx0 = Math.floor(srcIdx); + const idx1 = Math.min(idx0 + 1, data.length - 1); + const frac = srcIdx - idx0; + out[i] = data[idx0] * (1 - frac) + data[idx1] * frac; + } + return out; + }; + const sub = resultData.slice(startSample, endSample); + const subResampled = applyResample(sub, 1 / ratio); + for (let i = 0; i < endSample - startSample; i++) { + resultData[startSample + i] = i < subResampled.length ? subResampled[i] : 0.0; + } + } + + // Update subTab buffer state + setSubTabs(prev => prev.map(s => { + if (s.id !== tabId) return s; + return { + ...s, + buffer: resultBuffer, + selectionStart: null, + selectionEnd: null + }; + })); + showToast(`Đã áp dụng ${effectType === 'normalize' ? 'Normalize' : effectType === 'gain' ? 'Gain' : 'Pitch'} cho ${hasSelection ? 'vùng chọn' : 'toàn bộ clip'}.`, 'success'); + }; + const exportSubTabBuffer = async tabId => { + const subTab = subTabs.find(s => s.id === tabId); + if (!subTab || !subTab.buffer) return; + try { + const buffer = subTab.buffer; + const sr = buffer.sampleRate; + const monoData = buffer.getChannelData(0); + const bufferLength = monoData.length; + const bitDepth = 16; + const bytesPerSample = 2; + const headerSize = 44; + const fileSizeBytes = headerSize + bufferLength * bytesPerSample; + const fileBuffer = new ArrayBuffer(fileSizeBytes); + const view = new DataView(fileBuffer); + const writeString = (offset, string) => { + for (let i = 0; i < string.length; i++) { + view.setUint8(offset + i, string.charCodeAt(i)); + } + }; + writeString(0, 'RIFF'); + view.setUint32(4, fileSizeBytes - 8, true); + writeString(8, 'WAVE'); + writeString(12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, 1, true); + view.setUint32(24, sr, true); + view.setUint32(28, sr * bytesPerSample, true); + view.setUint16(32, bytesPerSample, true); + view.setUint16(34, bitDepth, true); + writeString(36, 'data'); + view.setUint32(40, bufferLength * bytesPerSample, true); + let offset = 44; + for (let i = 0; i < bufferLength; i++) { + const sample = Math.max(-1, Math.min(1, monoData[i])); + view.setInt16(offset, Math.floor(sample < 0 ? sample * 0x8000 : sample * 0x7FFF), true); + offset += bytesPerSample; + } + const blob = new Blob([view], { + type: 'audio/wav' + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${subTab.label || 'subtab-export'}.wav`; + a.click(); + URL.revokeObjectURL(url); + showToast("Xuất bản sub-tab hoàn tất!", "success"); + } catch (err) { + showToast("Lỗi xuất âm thanh: " + err.message, "error"); + } + }; + const closeSubTab = tabId => { + setSubTabs(prev => prev.filter(s => s.id !== tabId)); + if (activeTab === tabId) setActiveTab('main'); + }; + const updateSubTabEffects = (tabId, effects) => { + setSubTabs(prev => prev.map(s => s.id === tabId ? { + ...s, + effects: { + ...s.effects, + ...effects + } + } : s)); + }; + const handleSubTabNormalizeWithValue = (tabId, normalizeDb) => { + updateSubTabEffects(tabId, { + normalizeDb + }); + }; + const handleSubTabGainWithValue = (tabId, gainDb) => { + updateSubTabEffects(tabId, { + gainDb + }); + }; + const handleSubTabPitch = (tabId, pitch) => { + updateSubTabEffects(tabId, { + pitch + }); + }; + const handleSubTabStretch = (tabId, speedStretch) => { + updateSubTabEffects(tabId, { + speedStretch + }); + }; + const handleSubTabFadeState = (tabId, type) => { + const st = subTabs.find(s => s.id === tabId); + if (!st) return; + const fx = st.effects || {}; + if (type === 'in') { + updateSubTabEffects(tabId, { + fadeInMs: (fx.fadeInMs || 0) + 10 + }); + } else if (type === 'out') { + updateSubTabEffects(tabId, { + fadeOutMs: (fx.fadeOutMs || 0) + 10 + }); + } + }; + + // ── Context Menu Handlers ── + const handleContextMenu = (e, trackId, clickTime) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ + x: e.clientX, + y: e.clientY, + trackId, + time: clickTime || currentTime + }); + }; + const closeContextMenu = () => setContextMenu(null); + + // Close context menu on any click outside + useEffect(() => { + const handler = () => { + if (contextMenu) closeContextMenu(); + }; + if (contextMenu) { + window.addEventListener('click', handler); + return () => window.removeEventListener('click', handler); + } + }, [contextMenu]); + const contextMenuEdit = () => { + const track = tracks.find(t => t.id === contextMenu.trackId); + if (track) setSelectedTrackId(contextMenu.trackId); + closeContextMenu(); + const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ + id: 'default', + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name, + speed: track.speed || 1.0 + }] : []; + const clickedClip = clips.find(c => contextMenu.time >= c.startTime && contextMenu.time < c.startTime + c.buffer.duration / (c.speed || 1.0)); + if (clickedClip) { + handleEditClipInSubTab(contextMenu.trackId, clickedClip.id); + } else { + openTempTab(); + } + }; + const contextMenuSplit = () => { + closeContextMenu(); + handleSplitTrack(contextMenu.trackId); + }; + const contextMenuDelete = () => { + const tid = contextMenu.trackId; + const beforeSnap = captureTrackSnapshot(tid); + setTracks(prev => prev.filter(t => t.id !== tid)); + const afterSnap = captureTrackSnapshot(tid); + pushAction('DELETE', tid, beforeSnap, afterSnap); + if (selectedTrackId === tid) { + setSelectedTrackId(tracks.filter(t => t.id !== tid)[0]?.id || '1'); + } + closeContextMenu(); + showToast('Đã xoá track.', 'info'); + }; + const contextMenuCopy = () => { + const track = tracks.find(t => t.id === contextMenu.trackId); + if (!track || !track.buffer) return; + const sr = track.buffer.sampleRate; + const data = track.buffer.getChannelData(0); + + // Copy selected region if selection exists + if (selLeft !== null && selRight !== null && selRight > selLeft) { + const trackStart = track.startTime || 0; + const relSelLeft = Math.max(0, selLeft - trackStart); + const relSelRight = Math.max(0, selRight - trackStart); + const startSample = Math.floor(relSelLeft * sr); + const endSample = Math.min(data.length, Math.floor(relSelRight * sr)); + const len = endSample - startSample; + if (len > 0) { + const ctx = getAudioContext(); + const clipBuffer = ctx.createBuffer(1, len, sr); + clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); + 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; + } + } + + // No selection: copy entire track + clipboardRef.current = { + buffer: track.buffer, + name: track.name, + volumeDb: track.volumeDb, + pan: track.pan, + color: track.color + }; + closeContextMenu(); + showToast('Đã sao chép toàn bộ track.', 'info'); + }; + const contextMenuCut = () => { + const t = tracks.find(x => x.id === contextMenu.trackId); + if (!t || !t.buffer) { + contextMenuDelete(); + return; + } + const beforeSnap = captureTrackSnapshot(contextMenu.trackId); + const sr = t.buffer.sampleRate; + const data = t.buffer.getChannelData(0); + if (selLeft !== null && selRight !== null && selRight > selLeft) { + const trackStart = t.startTime || 0; + const relSelLeft = Math.max(0, selLeft - trackStart); + const relSelRight = Math.max(0, selRight - trackStart); + const startSample = Math.floor(relSelLeft * sr); + const endSample = Math.min(data.length, Math.floor(relSelRight * sr)); + const len = endSample - startSample; + if (len > 0) { + const ctx = getAudioContext(); + const clipBuffer = ctx.createBuffer(1, len, sr); + clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); + 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); + let idx = 0; + for (let i = 0; i < startSample; i++) newData[idx++] = data[i]; + for (let i = endSample; i < data.length; i++) newData[idx++] = data[i]; + setTracks(p => p.map(tr => tr.id === contextMenu.trackId ? { + ...tr, + buffer: newBuffer + } : tr)); + const afterSnap = captureTrackSnapshot(contextMenu.trackId); + pushAction('CUT', contextMenu.trackId, beforeSnap, afterSnap); + closeContextMenu(); + showToast('Đã cắt vùng chọn vào clipboard.', 'info'); + return; + } + } + contextMenuCopy(); + contextMenuDelete(); + }; + const doPaste = (targetTrackId, pasteTime) => { + if (!clipboardRef.current || !clipboardRef.current.buffer) { + showToast('Clipboard trống.', 'warning'); + return null; + } + const { + buffer: clipBuffer, + name, + volumeDb, + pan, + color + } = clipboardRef.current; + const ctx = getAudioContext(); + const targetTrack = tracks.find(t => t.id === targetTrackId); + const newClip = { + id: 'clip_' + Date.now() + '_' + Math.floor(Math.random() * 1000), + startTime: pasteTime, + buffer: clipBuffer, + name: name || 'Pasted Clip' + }; + if (targetTrack) { + setTracks(p => p.map(t => { + if (t.id === targetTrackId) { + const existingClips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ + id: 'default_' + t.id, + buffer: t.buffer, + startTime: t.startTime || 0, + name: t.name + }] : []; + const updatedClips = [...existingClips, newClip]; + return { + ...t, + clips: updatedClips, + buffer: updatedClips[0].buffer, + startTime: updatedClips[0].startTime, + name: name || t.name, + volumeDb: volumeDb ?? t.volumeDb, + pan: pan ?? t.pan, + color: color || t.color + }; + } + return t; + })); + setCurrentTime(pasteTime); + showToast('Đã dán clip vào track.', 'success'); + return targetTrackId; + } + + // No matching track — create a new one + const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; + const newId = 'track_pasted_' + Date.now(); + setTracks(prev => [...prev, { + id: newId, + name: `Pasted_${name || 'track'}`, + buffer: clipBuffer, + startTime: pasteTime, + clips: [newClip], + volumeDb: volumeDb ?? 0, + pan: pan ?? 0, + muted: false, + solo: false, + color: color || colors[prev.length % colors.length], + markers: [], + serverFileId: null + }]); + setSelectedTrackId(newId); + setCurrentTime(pasteTime); + showToast('Đã dán track mới từ clipboard.', 'success'); + return newId; + }; + const handlePasteTrack = () => doPaste(selectedTrackId, currentTime); + const contextMenuPaste = () => { + const result = doPaste(contextMenu.trackId, contextMenu.time || currentTime); + closeContextMenu(); + }; + const contextMenuMerge = () => { + const activeTracks = tracks.filter(t => t.buffer && !t.muted); + if (activeTracks.length < 2) { + showToast('Cần ít nhất 2 track có dữ liệu để merge.', 'warning'); + closeContextMenu(); + return; + } + const ctx = getAudioContext(); + const maxDur = Math.max(...activeTracks.map(t => (t.startTime || 0) + t.buffer.duration)); + const sr = activeTracks[0].buffer.sampleRate; + const merged = ctx.createBuffer(1, Math.ceil(maxDur * sr), sr); + const mergedData = merged.getChannelData(0); + activeTracks.forEach(t => { + const data = t.buffer.getChannelData(0); + const startSample = Math.floor((t.startTime || 0) * sr); + 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] * volLinear; + } + } + }); + let maxPeak = 0; + for (let i = 0; i < mergedData.length; i++) { + const abs = Math.abs(mergedData[i]); + if (abs > maxPeak) maxPeak = abs; + } + if (maxPeak > 1.0) { + for (let i = 0; i < mergedData.length; i++) mergedData[i] /= maxPeak; + } + const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; + const newId = 'track_merged_' + Date.now(); + const names = activeTracks.map(t => t.name).join('+').slice(0, 30); + setTracks(prev => [...prev, { + id: newId, + name: `Merged_${names}.wav`, + buffer: merged, + startTime: 0, + volumeDb: 0, + pan: 0, + muted: false, + solo: false, + color: colors[prev.length % colors.length], + markers: [], + serverFileId: null + }]); + setSelectedTrackId(newId); + closeContextMenu(); + showToast(`Đã merge ${activeTracks.length} tracks.`, 'success'); + }; + + // Menu bar direct handlers (don't rely on contextMenu state) + const handleMergeTracks = () => { + const at = tracks.filter(t => t.buffer && !t.muted); + if (at.length < 2) { + showToast('Cần 2+ tracks để merge.', 'warning'); + return; + } + const actx = getAudioContext(); + const maxDur = Math.max(...at.map(t => (t.startTime || 0) + t.buffer.duration)); + const sr = at[0].buffer.sampleRate; + const mb = actx.createBuffer(1, Math.ceil(maxDur * sr), sr); + const mdata = mb.getChannelData(0); + at.forEach(t => { + const d = t.buffer.getChannelData(0); + 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] * volLinear; + } + } + }); + let mp = 0; + for (let i = 0; i < mdata.length; i++) { + const a = Math.abs(mdata[i]); + if (a > mp) mp = a; + } + if (mp > 1.0) for (let i = 0; i < mdata.length; i++) mdata[i] /= mp; + setTracks(p => [...p, { + id: 'merged_' + Date.now(), + name: 'Merged_mix.wav', + buffer: mb, + startTime: 0, + height: 128, + 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 = () => { + const t = tracks.find(x => x.id === selectedTrackId); + if (!t || !t.buffer) return; + const sr = t.buffer.sampleRate; + const data = t.buffer.getChannelData(0); + // If selection exists, copy only the selected region + if (selLeft !== null && selRight !== null && selRight > selLeft) { + const trackStart = t.startTime || 0; + const relSelLeft = Math.max(0, selLeft - trackStart); + const relSelRight = Math.max(0, selRight - trackStart); + const startSample = Math.floor(relSelLeft * sr); + const endSample = Math.min(data.length, Math.floor(relSelRight * sr)); + const len = endSample - startSample; + if (len > 0) { + const ctx = getAudioContext(); + const clipBuffer = ctx.createBuffer(1, len, sr); + clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); + 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, + volumeDb: t.volumeDb, + color: t.color + }; + showToast('Copied track to clipboard.', 'info'); + }; + const handleCutTrack = () => { + const t = tracks.find(x => x.id === selectedTrackId); + if (!t || !t.buffer) return; + const beforeSnap = captureTrackSnapshot(selectedTrackId); + const sr = t.buffer.sampleRate; + const data = t.buffer.getChannelData(0); + // If selection exists, cut only the selected region + if (selLeft !== null && selRight !== null && selRight > selLeft) { + const trackStart = t.startTime || 0; + const relSelLeft = Math.max(0, selLeft - trackStart); + const relSelRight = Math.max(0, selRight - trackStart); + const startSample = Math.floor(relSelLeft * sr); + const endSample = Math.min(data.length, Math.floor(relSelRight * sr)); + const len = endSample - startSample; + if (len > 0) { + // Copy selection to clipboard + const ctx = getAudioContext(); + const clipBuffer = ctx.createBuffer(1, len, sr); + clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); + 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); + const newData = newBuffer.getChannelData(0); + let idx = 0; + for (let i = 0; i < startSample; i++) newData[idx++] = data[i]; + for (let i = endSample; i < data.length; i++) newData[idx++] = data[i]; + setTracks(p => p.map(tr => tr.id === selectedTrackId ? { + ...tr, + buffer: newBuffer + } : tr)); + const afterSnap = captureTrackSnapshot(selectedTrackId); + pushAction('CUT', selectedTrackId, beforeSnap, afterSnap); + showToast('Cut selection to clipboard.', 'info'); + return; + } + } + // No selection: cut entire track (copy + delete) + handleCopyTrack(); + handleDeleteTrack(); + }; + const handleDeleteTrack = () => { + const tid = selectedTrackId; + setTracks(p => p.filter(t => t.id !== tid)); + setSelectedTrackId(tracks.filter(t => t.id !== tid)[0]?.id || '1'); + showToast('Deleted track.', 'info'); + }; + + // ── Server Health Check ── + const [viewportWidth, setViewportWidth] = useState(1200); + useEffect(() => { + const wrapper = timelineWrapperRef.current; + if (!wrapper) return; + const observer = new ResizeObserver(entries => { + for (let entry of entries) { + setViewportWidth(entry.contentRect.width); + } + }); + observer.observe(wrapper); + return () => observer.disconnect(); + }, []); + useEffect(() => { + fetch(API_BASE_URL).then(r => { + if (r.ok) setServerStatus('connected');else setServerStatus('error'); + }).catch(() => setServerStatus('offline')); + }, []); + + // ── Computed Values ── + const maxDuration = useMemo(() => { + let max = 10; + tracks.forEach(t => { + const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ + id: 'default', + buffer: t.buffer, + startTime: t.startTime || 0, + name: t.name, + speed: t.speed || 1.0 + }] : []; + clips.forEach(c => { + if (c.buffer) { + const cStart = c.startTime || 0; + const cDur = c.buffer.duration / (c.speed || 1.0); + max = Math.max(max, cStart + cDur); + } + }); + }); + return max; + }, [tracks]); + const maxDurationRef = useRef(maxDuration); + maxDurationRef.current = maxDuration; + const minZoom = useMemo(() => { + return viewportWidth / maxDuration; + }, [viewportWidth, maxDuration]); + const timelineWidth = useMemo(() => { + return Math.max(zoom * maxDuration, viewportWidth); + }, [zoom, maxDuration, viewportWidth]); + useEffect(() => { + if (zoom < minZoom) { + if (activeTab === 'main') setMainZoom(minZoom); else setSubTabZoom(minZoom); + } + }, [minZoom]); + const playheadLeftPos = useMemo(() => currentTime * zoom, [currentTime, zoom]); + const selLeft = useMemo(() => { + if (selectionMode === 'local' && localSelectionStart !== null && localSelectionEnd !== null) { + return Math.min(localSelectionStart, localSelectionEnd); + } + if (selectionStart === null || selectionEnd === null) return null; + return Math.min(selectionStart, selectionEnd); + }, [selectionStart, selectionEnd, selectionMode, localSelectionStart, localSelectionEnd]); + const selRight = useMemo(() => { + if (selectionMode === 'local' && localSelectionStart !== null && localSelectionEnd !== null) { + return Math.max(localSelectionStart, localSelectionEnd); + } + if (selectionStart === null || selectionEnd === null) return null; + return Math.max(selectionStart, selectionEnd); + }, [selectionStart, selectionEnd, selectionMode, localSelectionStart, localSelectionEnd]); + + // ── Toast helper ── + const showToast = (text, type = 'info') => { + if (toastTimeoutRef.current) clearTimeout(toastTimeoutRef.current); + setToastMessage({ + text, + type + }); + toastTimeoutRef.current = setTimeout(() => setToastMessage(null), 3500); + }; + + // ── Server-side upload ── + const uploadToServer = async (file, trackId) => { + const formData = new FormData(); + formData.append('file', file); + try { + const resp = await fetch(`${API_AUDIO}/upload`, { + method: 'POST', + body: formData + }); + if (!resp.ok) throw new Error(`Upload failed: ${resp.status}`); + const data = await resp.json(); + serverFileIdMap[trackId] = data.file_id; + return data; + } catch (err) { + console.warn('Server upload failed, using client-side only:', err.message); + return null; + } + }; + + // ── Server-side waveform loading ── + const loadServerWaveform = async fileId => { + try { + const resp = await fetch(`${API_AUDIO}/waveform/${fileId}?num_peaks=800`); + if (!resp.ok) return null; + return await resp.json(); + } catch { + return null; + } + }; + + // ── Check Celery task result ── + const pollTaskResult = async (taskId, maxPoll = 10) => { + for (let i = 0; i < maxPoll; i++) { + await new Promise(r => setTimeout(r, 1500)); + try { + const resp = await fetch(`${API_TASKS}/${taskId}`); + if (!resp.ok) continue; + const data = await resp.json(); + if (data.status === 'SUCCESS') return data.result; + if (data.status === 'FAILURE') throw new Error(data.error || 'Task failed'); + } catch (err) { + throw err; + } + } + throw new Error('Task polling timeout'); + }; + + // ── Wheel Zoom ── + useEffect(() => { + const timeline = timelineWrapperRef.current; + if (!timeline) return; + const handleWheel = e => { + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + const rect = timeline.getBoundingClientRect(); + const mouseXInViewport = e.clientX - rect.left; + const mouseXInCanvas = mouseXInViewport + timeline.scrollLeft; + const anchorTime = mouseXInCanvas / zoom; + const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; + const updateZoom = prevZoom => { + let newZoom = prevZoom * zoomFactor; + if (newZoom < minZoom) newZoom = minZoom; + if (newZoom > 50000) newZoom = 50000; + const newMouseXInCanvas = anchorTime * newZoom; + requestAnimationFrame(() => { + timeline.scrollLeft = newMouseXInCanvas - mouseXInViewport; + }); + return newZoom; + }; + if (activeTab === 'main') setMainZoom(updateZoom(mainZoom)); else setSubTabZoom(updateZoom(subTabZoom)); + } else if (e.shiftKey) { + e.preventDefault(); + timeline.scrollLeft += e.deltaY; + } + }; + timeline.addEventListener('wheel', handleWheel, { + passive: false + }); + return () => timeline.removeEventListener('wheel', handleWheel); + }, [zoom, minZoom, maxDuration, activeTab]); + + // ── Update Playhead ── + const startSubTabPlayback = (st, offsetWallTime) => { + const context = getAudioContext(); + const speed = st.speed || 1.0; + const offsetBuffer = offsetWallTime * speed; + 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.playbackRate.value = speed; + activePlaybackSpeedRef.current = speed; + + // Graph Editor Automation Node Chain (15_GRAPH_EDIT.md §3) + // source → volumeGainNode → pannerNode → fadeGainNode → destination + + const volumeGainNode = context.createGain(); + volumeGainNode.gain.setValueAtTime(1.0, context.currentTime); + const pannerNode = context.createStereoPanner(); + pannerNode.pan.setValueAtTime(0.0, context.currentTime); + const fadeGainNode = context.createGain(); + fadeGainNode.gain.setValueAtTime(1.0, context.currentTime); + + // Schedule volume automation nodes + const volNodes = st.volumeNodes || []; + if (volNodes.length > 0) { + volumeGainNode.gain.cancelScheduledValues(context.currentTime); + volNodes.forEach((n, i) => { + const t = context.currentTime + n.time / speed; + const linearGain = Math.pow(10, n.db / 20); + if (i === 0) volumeGainNode.gain.setValueAtTime(linearGain, t);else volumeGainNode.gain.linearRampToValueAtTime(linearGain, t); + }); + } + + // Schedule panning automation nodes + const panNodes = st.panningNodes || []; + if (panNodes.length > 0) { + pannerNode.pan.cancelScheduledValues(context.currentTime); + panNodes.forEach((n, i) => { + const t = context.currentTime + n.time / speed; + const clamped = Math.max(-1, Math.min(1, n.pan)); + if (i === 0) pannerNode.pan.setValueAtTime(clamped, t);else pannerNode.pan.linearRampToValueAtTime(clamped, t); + }); + } + + // Schedule fade curves + const duration = st.buffer.duration; + const fIn = st.fadeInLen || 0; + const fOut = st.fadeOutLen || 0; + if (fIn > 0) { + fadeGainNode.gain.setValueAtTime(0.0, context.currentTime); + fadeGainNode.gain.linearRampToValueAtTime(1.0, context.currentTime + fIn / speed); + } + if (fOut > 0) { + const fadeOutStart = (duration - fOut) / speed; + fadeGainNode.gain.setValueAtTime(1.0, context.currentTime + Math.max(0, fadeOutStart)); + fadeGainNode.gain.linearRampToValueAtTime(0.0, context.currentTime + duration / speed); + } + source.connect(volumeGainNode); + volumeGainNode.connect(pannerNode); + pannerNode.connect(fadeGainNode); + fadeGainNode.connect(context.destination); + source.start(context.currentTime, offsetBuffer); + activeSourcesRef.current = [source]; + activeTrackNodesRef.current[st.trackId] = { + gainNode: volumeGainNode, + pannerNode, + source + }; + startOffsetTimeRef.current = offsetWallTime; + startBufferOffsetRef.current = offsetBuffer; + startAudioTimeRef.current = context.currentTime; + }; + const updatePlayhead = () => { + if (activeTabRef.current !== 'main') { + const st = subTabsRef.current.find(s => s.id === activeTabRef.current); + if (!st || !st.isPlaying || !st.buffer) return; + const context = getAudioContext(); + const speedFactor = st.speed || 1.0; + const elapsed = context.currentTime - startAudioTimeRef.current; + const wallTime = startOffsetTimeRef.current + elapsed; + const bufferPos = startBufferOffsetRef.current + elapsed * speedFactor; + + // Loop sub-tab selection (bufferPos is buffer-time) + if (st.selectionStart !== null && st.selectionEnd !== null && st.selectionStart !== st.selectionEnd) { + const start = Math.min(st.selectionStart, st.selectionEnd); + const end = Math.max(st.selectionStart, st.selectionEnd); + if (bufferPos >= end) { + stopAllPlayback(); + setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? { + ...s, + currentTime: wallTime, + isPlaying: true + } : s)); + startSubTabPlayback(st, wallTime); + animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); + return; + } + } + if (bufferPos >= st.buffer.duration) { + stopAllPlayback(); + if (isLoopingSelection) { + setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? { + ...s, + currentTime: 0, + isPlaying: true + } : s)); + startSubTabPlayback(st, 0); + animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); + } else { + setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? { + ...s, + currentTime: 0, + isPlaying: false + } : s)); + } + return; + } + setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? { + ...s, + currentTime: wallTime + } : s)); + animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); + return; + } + if (!isPlaying) return; + const context = getAudioContext(); + const elapsed = context.currentTime - startAudioTimeRef.current; + const updatedTime = startOffsetTimeRef.current + elapsed; + + // Selection Loop - LOOP_MAKER.md + LOOP_EDITOR_2.md §4.2 + // If selection cleared by user, play linearly (don't loop) + if (!selectionCleared && isLoopingSelection && selLeft !== null && selRight !== null) { + if (selRight > selLeft && updatedTime >= selRight) { + if (selectionMode === 'local') { + // Local Solo Loop: only restart the selected track + stopAllPlayback(); + startOffsetTimeRef.current = selLeft; + startAudioTimeRef.current = context.currentTime; + startLocalTrackPlayback(localSelectionTrackId, selLeft); + setCurrentTime(selLeft); + setIsPlaying(true); + } else { + // Global Master Loop: restart all tracks + stopAllPlayback(); + startOffsetTimeRef.current = selLeft; + startAudioTimeRef.current = context.currentTime; + startTrackPlayback(selLeft); + setCurrentTime(selLeft); + setIsPlaying(true); + } + animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); + return; + } + } + if (updatedTime >= maxDurationRef.current) { + stopAllPlayback(); + setCurrentTime(0); + return; + } + setCurrentTime(updatedTime); + animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); + }; + useEffect(() => { + if (isPlaying || subTabs.some(s => s.isPlaying)) { + animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); + } else { + cancelAnimationFrame(animationFrameIdRef.current); + } + return () => cancelAnimationFrame(animationFrameIdRef.current); + }, [isPlaying, subTabs, isLoopingSelection, selLeft, selRight, selectionMode, localSelectionTrackId, selectionCleared, activeTab]); + + // ── Playback ── + const startTrackPlayback = offsetTime => { + const context = getAudioContext(); + const hasSolo = tracks.some(t => t.solo) || soloedTrackId !== null; + tracks.forEach(track => { + const isPlayable = hasSolo ? track.id === soloedTrackId || track.solo : !track.muted; + 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, + startTime: track.startTime || 0, + name: track.name, + speed: track.speed || 1.0 + }] : []; + clips.forEach(clip => { + if (!clip.buffer) return; + const source = context.createBufferSource(); + source.buffer = clip.buffer; + source.playbackRate.value = clip.speed || 1.0; + source.connect(gainNode); + gainNode.connect(pannerNode); + const clipStart = clip.startTime || 0; + const clipDuration = clip.buffer.duration / (clip.speed || 1.0); + const clipEnd = clipStart + clipDuration; + if (offsetTime < clipStart) { + const delay = clipStart - offsetTime; + source.start(context.currentTime + delay, 0); + activeSourcesRef.current.push(source); + } else if (offsetTime < clipEnd) { + const playOffset = offsetTime - clipStart; + source.start(context.currentTime, playOffset * (clip.speed || 1.0)); + activeSourcesRef.current.push(source); + } + }); + }); + }; + + // Solo playback for Local Selection Loop (LOOP_MAKER.md §2.2) + const startLocalTrackPlayback = (trackId, offsetTime) => { + const context = getAudioContext(); + const track = tracks.find(t => t.id === trackId); + if (!track) return; + const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ + id: 'default', + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name, + 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; + const source = context.createBufferSource(); + source.buffer = clip.buffer; + source.playbackRate.value = clip.speed || 1.0; + source.connect(gainNode); + gainNode.connect(pannerNode); + const clipStart = clip.startTime || 0; + const clipDuration = clip.buffer.duration / (clip.speed || 1.0); + const clipEnd = clipStart + clipDuration; + if (offsetTime < clipStart) { + const delay = clipStart - offsetTime; + source.start(context.currentTime + delay, 0); + activeSourcesRef.current.push(source); + } else if (offsetTime < clipEnd) { + const playOffset = offsetTime - clipStart; + source.start(context.currentTime, playOffset * (clip.speed || 1.0)); + activeSourcesRef.current.push(source); + } + }); + }; + const handlePlayPause = () => { + if (activeTab !== 'main') { + // Sub-tab playback transport + const st = subTabs.find(s => s.id === activeTab); + if (!st || !st.buffer) return; + if (st.isPlaying) { + stopAllPlayback(); + setSubTabs(prev => prev.map(s => s.id === activeTab ? { + ...s, + isPlaying: false + } : s)); + } else { + stopAllPlayback(); + const startOffset = st.currentTime || 0; + startSubTabPlayback(st, startOffset); + setSubTabs(prev => prev.map(s => s.id === activeTab ? { + ...s, + isPlaying: true, + currentTime: startOffset + } : s)); + } + return; + } + const context = getAudioContext(); + if (isPlaying) { + stopAllPlayback(); + } else { + startOffsetTimeRef.current = currentTime; + startAudioTimeRef.current = context.currentTime; + startTrackPlayback(currentTime); + setIsPlaying(true); + } + }; + handlePlayPauseRef.current = handlePlayPause; + const handlePause = () => { + if (isPlaying || subTabs.some(s => s.isPlaying)) stopAllPlayback(); + }; + const stopAllPlayback = () => { + activeSourcesRef.current.forEach(src => { + try { + src.stop(); + } catch (e) {} + }); + activeSourcesRef.current = []; + activeTrackNodesRef.current = {}; + setIsPlaying(false); + setSubTabs(prev => prev.map(s => ({ + ...s, + isPlaying: false + }))); + }; + const handleStop = () => { + stopAllPlayback(); + if (activeTab !== 'main') { + setSubTabs(prev => prev.map(s => s.id === activeTab ? { + ...s, + currentTime: 0 + } : s)); + } else { + setCurrentTime(0); + } + }; + const handleSubTabResizeMouseDown = e => { + e.preventDefault(); + e.stopPropagation(); + const startY = e.clientY; + const startHeight = subTabHeight; + const handleMouseMove = moveEvent => { + const deltaY = moveEvent.clientY - startY; + const newHeight = Math.max(48, Math.min(400, startHeight + deltaY)); + setSubTabHeight(newHeight); + }; + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + + // ── Playhead set with seek+play ── + const handlePlayheadSet = (time, shiftKey) => { + localSelectionAnchorRef.current = time; + if (isPlaying) { + // Click during playback: seek to position and continue playing + setCurrentTime(time); + stopAllPlayback(); + setTimeout(() => { + startOffsetTimeRef.current = time; + startAudioTimeRef.current = getAudioContext().currentTime; + startTrackPlayback(time); + setIsPlaying(true); + }, 50); + } else { + // Normal click: just set playhead + setCurrentTime(time); + } + }; + const clearLocalSelection = () => { + setSelectionMode(null); + setLocalSelectionTrackId(null); + setLocalSelectionStart(null); + setLocalSelectionEnd(null); + }; + const handleRulerMouseDown = e => { + if (e.ctrlKey) { + e.preventDefault(); + e.stopPropagation(); + clearLocalSelection(); + setSelectionMode(null); + setSelectionStart(null); + setSelectionEnd(null); + return; + } + const wrapper = timelineWrapperRef.current; + if (!wrapper) return; + const rect = wrapper.getBoundingClientRect(); + const scrollLeft = wrapper.scrollLeft; + const mouseX = e.clientX - rect.left + scrollLeft; + const rawTime = mouseX / zoom; + const time = snapValue !== 'free' ? snapTime(rawTime, snapValue, bpm) : rawTime; + clearLocalSelection(); + setSelectionMode('global'); + rulerDragStartRef.current = time; + isDraggingRulerRef.current = true; + if (e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + // Shift+click on ruler: lock existing anchor (or currentTime fallback) and extend global selection + const anchor = rulerAnchorRef.current !== null && rulerAnchorRef.current !== undefined ? rulerAnchorRef.current : selectionStart !== null && selectionStart !== undefined ? selectionStart : currentTime; + const selS = Math.min(anchor, time); + const selE = Math.max(anchor, time); + setSelectionStart(selS); + setSelectionEnd(selE); + } else { + rulerAnchorRef.current = time; + setSelectionStart(time); + setSelectionEnd(time); + } + handlePlayheadSet(time); + }; + + // Global Ruler mousemove is tracked via document listener set up in useEffect + useEffect(() => { + const handleMouseMove = e => { + if (!isDraggingRulerRef.current) return; + const wrapper = timelineWrapperRef.current; + if (!wrapper) return; + const rect = wrapper.getBoundingClientRect(); + const scrollLeft = wrapper.scrollLeft; + const mouseX = e.clientX - rect.left + scrollLeft; + const rawTime = Math.max(0, Math.min(maxDuration, mouseX / zoom)); + const time = snapValueRef.current !== 'free' ? snapTime(rawTime, snapValueRef.current, bpmRef.current) : rawTime; + const anchor = rulerAnchorRef.current ?? rulerDragStartRef.current ?? time; + setSelectionStart(Math.min(anchor, time)); + setSelectionEnd(Math.max(anchor, time)); + }; + const handleMouseUp = () => { + if (isDraggingRulerRef.current) { + isDraggingRulerRef.current = false; + rulerDragStartRef.current = null; + } + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [zoom, maxDuration]); + + // ── Track Lane Local Selection Drag ── + const localDragInProgressRef = useRef(false); + const localDragTrackRef = useRef(null); + const localDragStartTimeRef = useRef(0); + const localSelectionAnchorRef = useRef(null); + const handleTrackLaneMouseDown = (trackId, time) => { + setSelectedTrackId(trackId); + clearLocalSelection(); + localSelectionAnchorRef.current = time; + setSelectionMode('local'); + setLocalSelectionTrackId(trackId); + setLocalSelectionStart(time); + setLocalSelectionEnd(time); + setSelectionStart(time); + setSelectionEnd(time); + localDragInProgressRef.current = true; + localDragTrackRef.current = trackId; + localDragStartTimeRef.current = time; + }; + + // Document-level mousemove/mouseup for local selection drag + useEffect(() => { + const handleMouseMove = e => { + if (!localDragInProgressRef.current) return; + const wrapper = timelineWrapperRef.current; + if (!wrapper) return; + const rect = wrapper.getBoundingClientRect(); + const scrollLeft = wrapper.scrollLeft; + const mouseX = e.clientX - rect.left + scrollLeft; + const time = Math.max(0, Math.min(maxDuration, mouseX / zoom)); + const anchor = localSelectionAnchorRef.current ?? localDragStartTimeRef.current; + const selS = Math.min(anchor, time); + const selE = Math.max(anchor, time); + setLocalSelectionStart(selS); + setLocalSelectionEnd(selE); + setSelectionStart(selS); + setSelectionEnd(selE); + }; + const handleMouseUp = () => { + if (localDragInProgressRef.current) { + localDragInProgressRef.current = false; + localDragTrackRef.current = null; + localDragStartTimeRef.current = 0; + } + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [zoom, maxDuration]); + const draggedClipRef = useRef(null); + draggedClipRef.current = draggedClip; + const hoveredTrackIdRef = useRef(null); + hoveredTrackIdRef.current = hoveredTrackId; + const captureTrackSnapshotRef = useRef(null); + captureTrackSnapshotRef.current = captureTrackSnapshot; + const handleClipDragStart = (trackId, clipId, clickOffset, isDuplicate = false) => { + const track = tracks.find(t => t.id === trackId); + if (!track) return; + const existingClips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ + id: 'default_' + track.id, + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name + }] : []; + const clip = existingClips.find(c => c.id === clipId || clipId === 'default' && c.id === 'default_' + track.id); + if (!clip) return; + const beforeSnap = captureTrackSnapshot(trackId); + let targetClipId = clip.id; + if (isDuplicate) { + const cloneId = clip.id + '_copy_' + Date.now(); + const clone = { + ...clip, + id: cloneId, + name: clip.name + ' (Copy)' + }; + setTracks(prev => prev.map(t => { + if (t.id === trackId) { + const newClips = [...existingClips, clone]; + return { + ...t, + clips: newClips, + buffer: newClips[0].buffer, + startTime: newClips[0].startTime, + name: newClips[0].name + }; + } + return t; + })); + targetClipId = cloneId; + } else { + if (!track.clips || track.clips.length === 0) { + setTracks(prev => prev.map(t => { + if (t.id === trackId) { + return { + ...t, + clips: existingClips + }; + } + return t; + })); + } + } + setDraggedClip({ + trackId: trackId, + clipId: targetClipId, + clickOffset: clickOffset, + buffer: clip.buffer, + name: clip.name, + beforeSnap: beforeSnap + }); + }; + const stretchedClipRef = useRef(null); + stretchedClipRef.current = stretchedClip; + const handleClipStretchStart = (trackId, clipId, clickTime) => { + const track = tracks.find(t => t.id === trackId); + if (!track) return; + const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ + id: 'default_' + track.id, + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name, + speed: track.speed || 1.0 + }] : []; + const clip = clips.find(c => c.id === clipId || clipId === 'default' && c.id === 'default_' + trackId); + if (!clip || !clip.buffer) return; + const beforeSnap = captureTrackSnapshot(trackId); + if (!track.clips || track.clips.length === 0) { + setTracks(prev => prev.map(t => t.id === trackId ? { + ...t, + clips + } : t)); + } + setStretchedClip({ + trackId, + clipId: clip.id === 'default' ? 'default_' + trackId : clip.id, + originalDuration: clip.buffer.duration, + startTime: clip.startTime, + originalSpeed: clip.speed || 1.0, + beforeSnap + }); + }; + const handleSelectionEdgeDragStart = (e, trackId, side) => { + const startX = e.clientX; + const initialLeft = Math.min(localSelectionStart, localSelectionEnd); + const initialRight = Math.max(localSelectionStart, localSelectionEnd); + const handleMouseMove = moveEvent => { + const deltaX = moveEvent.clientX - startX; + const deltaSec = deltaX / zoom; + if (side === 'left') { + const newLeft = Math.max(0, Math.min(initialRight - 0.05, initialLeft + deltaSec)); + setLocalSelectionStart(newLeft); + setLocalSelectionEnd(initialRight); + } else { + const newRight = Math.max(initialLeft + 0.05, Math.min(maxDuration, initialRight + deltaSec)); + setLocalSelectionStart(initialLeft); + setLocalSelectionEnd(newRight); + } + }; + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + const handleTrackResizeMouseDown = (e, trackId) => { + e.preventDefault(); + e.stopPropagation(); + const startY = e.clientY; + const track = tracks.find(t => t.id === trackId); + const startHeight = track ? track.height || 96 : 96; + const handleMouseMove = moveEvent => { + const deltaY = moveEvent.clientY - startY; + const newHeight = Math.max(110, Math.min(300, startHeight + deltaY)); + setTracks(prev => prev.map(t => t.id === trackId ? { + ...t, + height: newHeight + } : t)); + }; + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + const deleteTrack = trackId => { + const beforeSnap = captureTrackSnapshot(trackId); + setTracks(prev => { + const filtered = prev.filter(t => t.id !== trackId); + if (filtered.length > 0) { + setSelectedTrackId(filtered[0].id); + } + return filtered; + }); + showToast('Đã xóa track.', 'info'); + }; + useEffect(() => { + const handleMouseMove = e => { + const drag = draggedClipRef.current; + if (!drag) return; + const wrapper = timelineWrapperRef.current; + if (!wrapper) return; + const rect = wrapper.getBoundingClientRect(); + const scrollLeft = wrapper.scrollLeft; + const mouseX = e.clientX - rect.left + scrollLeft; + const time = mouseX / zoom; + const rawStart = Math.max(0, time - drag.clickOffset); + const newStart = snapTime(rawStart, snapValueRef.current, bpmRef.current); + const targetTrackId = hoveredTrackIdRef.current || drag.trackId; + setTracks(prev => prev.map(t => { + // Clear the clip from its previous track if it moved to a new track + if (t.id === drag.trackId && drag.trackId !== targetTrackId) { + const updatedClips = (t.clips || []).filter(c => c.id !== drag.clipId); + return { + ...t, + clips: updatedClips, + buffer: updatedClips.length > 0 ? updatedClips[0].buffer : null, + startTime: updatedClips.length > 0 ? updatedClips[0].startTime : 0, + name: updatedClips.length > 0 ? updatedClips[0].name : `Track ${t.id}` + }; + } + // Update/set clip on target track + if (t.id === targetTrackId) { + const existingClips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ + id: 'default_' + t.id, + buffer: t.buffer, + startTime: t.startTime || 0, + name: t.name + }] : []; + const hasClip = existingClips.some(c => c.id === drag.clipId); + let updatedClips; + if (hasClip) { + updatedClips = existingClips.map(c => c.id === drag.clipId ? { + ...c, + startTime: newStart + } : c); + } else { + updatedClips = [...existingClips, { + id: drag.clipId, + buffer: drag.buffer, + startTime: newStart, + name: drag.name + }]; + } + return { + ...t, + clips: updatedClips, + buffer: updatedClips[0].buffer, + startTime: updatedClips[0].startTime, + name: updatedClips[0].name + }; + } + return t; + })); + if (drag.trackId !== targetTrackId) { + setDraggedClip(prev => ({ + ...prev, + trackId: targetTrackId + })); + } + }; + const handleMouseUp = () => { + const drag = draggedClipRef.current; + if (!drag) return; + const afterSnap = captureTrackSnapshotRef.current(drag.trackId); + pushAction('MOVE_CLIP', drag.trackId, drag.beforeSnap, afterSnap); + setDraggedClip(null); + showToast('Đã di chuyển clip.', 'success'); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [zoom]); + + // Document-level mousemove/mouseup for clip stretching + useEffect(() => { + const handleMouseMove = e => { + const stretch = stretchedClipRef.current; + if (!stretch) return; + const wrapper = timelineWrapperRef.current; + if (!wrapper) return; + const rect = wrapper.getBoundingClientRect(); + const scrollLeft = wrapper.scrollLeft; + const mouseX = e.clientX - rect.left + scrollLeft; + const time = mouseX / zoom; + const newDuration = Math.max(0.1, time - stretch.startTime); + const speedRatio = stretch.originalDuration / newDuration; + setTracks(prev => prev.map(t => { + if (t.id === stretch.trackId) { + const updatedClips = (t.clips || []).map(c => { + if (c.id === stretch.clipId) { + return { + ...c, + speed: speedRatio + }; + } + return c; + }); + return { + ...t, + clips: updatedClips, + buffer: updatedClips[0]?.buffer, + startTime: updatedClips[0]?.startTime || 0, + speed: updatedClips[0]?.speed || 1.0 + }; + } + return t; + })); + }; + const handleMouseUp = () => { + const stretch = stretchedClipRef.current; + if (!stretch) return; + const afterSnap = captureTrackSnapshotRef.current(stretch.trackId); + pushAction('STRETCH_CLIP', stretch.trackId, stretch.beforeSnap, afterSnap); + setStretchedClip(null); + showToast('Đã giãn thời gian clip.', 'success'); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [zoom]); + const handleSelectRange = (start, end, reset) => { + const maxLen = maxDuration; + const cleanStart = Math.max(0, Math.min(maxLen, start)); + const cleanEnd = Math.max(0, Math.min(maxLen, end)); + if (reset) { + setSelectionStart(cleanStart); + setSelectionEnd(cleanEnd); + } else { + setSelectionEnd(cleanEnd); + } + // LOOP_EDITOR_2.md §4.2: new selection = enable looping + setSelectionCleared(false); + }; + const handleSelectionInputChange = (field, val) => { + const numericVal = Math.max(0, parseFloat(val) || 0); + if (selectionMode === 'local') { + // Editing local selection directly + if (field === 'start') { + setLocalSelectionStart(numericVal); + } else { + setLocalSelectionEnd(numericVal); + } + } else { + if (field === 'start') { + setSelectionStart(numericVal); + } else { + setSelectionEnd(numericVal); + } + } + }; + const selectionStats = useMemo(() => { + if (selLeft === null || selRight === null) { + return { + start: 0, + end: 0, + length: 0 + }; + } + const s = Math.min(selLeft, selRight); + const e = Math.max(selLeft, selRight); + return { + start: parseFloat(s.toFixed(3)), + end: parseFloat(e.toFixed(3)), + length: parseFloat((e - s).toFixed(3)) + }; + }, [selLeft, selRight]); + + // ── Handle Drag (selection resize) ── + const handleHandleDragStart = (e, side) => { + e.preventDefault(); + e.stopPropagation(); + const startX = e.clientX; + const useLocal = selectionMode === 'local'; + const currentStart = useLocal ? localSelectionStart : selectionStart; + const currentEnd = useLocal ? localSelectionEnd : selectionEnd; + const initialLeft = Math.min(currentStart, currentEnd); + const initialRight = Math.max(currentStart, currentEnd); + const setStart = useLocal ? setLocalSelectionStart : setSelectionStart; + const setEnd = useLocal ? setLocalSelectionEnd : setSelectionEnd; + const handleMouseMove = moveEvent => { + const deltaX = moveEvent.clientX - startX; + const deltaSec = deltaX / zoom; + if (side === 'left') { + const newLeft = Math.max(0, Math.min(initialRight - 0.05, initialLeft + deltaSec)); + setStart(newLeft); + setEnd(initialRight); + } else { + const newRight = Math.max(initialLeft + 0.05, Math.min(maxDuration, initialRight + deltaSec)); + setStart(initialLeft); + setEnd(newRight); + } + }; + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + const handleSelectionBodyDragStart = e => { + e.preventDefault(); + e.stopPropagation(); + const startX = e.clientX; + const useLocal = selectionMode === 'local'; + const currentStart = useLocal ? localSelectionStart : selectionStart; + const currentEnd = useLocal ? localSelectionEnd : selectionEnd; + const initialLeft = Math.min(currentStart, currentEnd); + const initialRight = Math.max(currentStart, currentEnd); + const widthSec = initialRight - initialLeft; + const setStart = useLocal ? setLocalSelectionStart : setSelectionStart; + const setEnd = useLocal ? setLocalSelectionEnd : setSelectionEnd; + const handleMouseMove = moveEvent => { + const deltaX = moveEvent.clientX - startX; + const deltaSec = deltaX / zoom; + let newLeft = initialLeft + deltaSec; + let newRight = initialRight + deltaSec; + if (newLeft < 0) { + newLeft = 0; + newRight = widthSec; + } + if (newRight > maxDuration) { + newRight = maxDuration; + newLeft = maxDuration - widthSec; + } + setStart(newLeft); + setEnd(newRight); + }; + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + + // ── Track Controls ── + const toggleTrackSoloEvaluate = trackId => { + const wasPlaying = isPlaying; + stopAllPlayback(); + setTracks(prev => prev.map(t => { + if (t.id === trackId) { + return { + ...t, + solo: !t.solo, + muted: false + }; + } + return { + ...t, + solo: false + }; + })); + setSoloedTrackId(prev => prev === trackId ? null : trackId); + if (wasPlaying) { + setTimeout(() => { + startOffsetTimeRef.current = currentTime; + startAudioTimeRef.current = getAudioContext().currentTime; + setIsPlaying(true); + }, 50); + } + }; + const toggleTrackMute = trackId => { + const beforeSnap = captureTrackSnapshot(trackId); + setTracks(prev => prev.map(t => t.id === trackId ? { + ...t, + muted: !t.muted + } : t)); + setUndoStack(prev => { + const next = [...prev, { + action_type: 'MUTE', + track_id: trackId, + timestamp: Date.now(), + before_state: beforeSnap, + after_state: captureTrackSnapshot(trackId) + }]; + if (next.length > MAX_UNDO) next.shift(); + return next; + }); + }; + const updateTrackVolumeDb = (trackId, val) => { + const beforeSnap = captureTrackSnapshot(trackId); + setTracks(prev => prev.map(t => t.id === trackId ? { + ...t, + volumeDb: val + } : t)); + setUndoStack(prev => { + const next = [...prev, { + action_type: 'VOLUME_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) { + 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) ── + const loadFileOnTrack = async (trackId, file) => { + if (!file) return; + showToast(`Đang nạp file ${file.name}...`, 'info'); + try { + // Upload to server + uploadToServer(file, trackId); + + // Decode locally for playback + analyze channels (stereo/mono) + const { + audioBuffer: decodedBuffer, + channelInfo + } = await window.SonicAudio.decodeAudioFile(file); + setTracks(prev => prev.map(t => t.id === trackId ? { + ...t, + name: file.name, + buffer: decodedBuffer, + channelInfo: channelInfo + } : t)); + showToast(`Nạp file thành công: ${file.name} (${channelInfo.label})`, 'success'); + } catch (err) { + showToast("Lỗi giải mã âm thanh. Định dạng file không tương thích.", 'error'); + } + }; + + // ── Synth Generators ── + const generateSynthToTrack = (trackId, type) => { + const sampleRate = 44100; + const duration = 12.0; + const context = getAudioContext(); + const frameCount = sampleRate * duration; + const newBuffer = context.createBuffer(1, frameCount, sampleRate); + const channelData = newBuffer.getChannelData(0); + if (type === 'kick') { + for (let i = 0; i < frameCount; i++) { + const t = i / sampleRate; + const beatTime = t % 0.5; + const freq = 120 * Math.exp(-35 * beatTime); + channelData[i] = Math.sin(2 * Math.PI * freq * beatTime) * Math.exp(-6 * beatTime); + } + } else { + const notes = [220.00, 261.63, 293.66, 329.63, 392.00]; + for (let i = 0; i < frameCount; i++) { + const t = i / sampleRate; + const noteIdx = Math.floor(t * 2) % notes.length; + const freq = notes[noteIdx]; + channelData[i] = Math.sin(2 * Math.PI * freq * t) * 0.25 * (1.0 - t % 0.5 / 0.5); + } + } + setTracks(prev => prev.map(t => t.id === trackId ? { + ...t, + name: `Demo_${type.toUpperCase()}.wav`, + buffer: newBuffer + } : t)); + showToast(`Đã nạp sóng âm tổng hợp: ${type.toUpperCase()}`, 'success'); + }; + + // ── Add Track ── + const addNewTrack = () => { + const newId = (tracks.length + 1).toString(); + const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; + const selectColor = colors[tracks.length % colors.length]; + setTracks(prev => [...prev, { + id: newId, + name: `Track ${newId}`, + buffer: null, + startTime: 0, + height: 128, + 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; + }; + + // ── Server-side Export ── + const triggerWavExport = async () => { + const activeTracks = tracks.filter(t => t.buffer && !t.muted); + if (activeTracks.length === 0) { + showToast("Không tìm thấy dữ liệu âm thanh hợp lệ để xuất.", "warning"); + return; + } + + // Check if all active tracks have server file IDs + const allOnServer = activeTracks.every(t => serverFileIdMap[t.id]); + if (allOnServer && serverStatus === 'connected') { + // Use server-side export + setIsExporting(true); + showToast("Đang gửi yêu cầu xuất âm thanh đến máy chủ...", "info"); + try { + const sessionId = `session_${Date.now()}`; + const tracksMeta = activeTracks.map(t => ({ + track_id: t.id, + file_id: serverFileIdMap[t.id], + volume_db: t.volumeDb, + muted: false, + clips: [{ + clip_id: `clip_${t.id}`, + start_time_seconds: t.startTime || 0, + end_time_seconds: (t.startTime || 0) + t.buffer.duration, + loop_count: 1, + apply_zero_crossing: true, + fade_in_ms: 0, + fade_out_ms: 0 + }] + })); + const resp = await fetch(`${API_MULTITRACK}/mix`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + session_id: sessionId, + export_settings: { + sample_rate: parseInt(exportSettings.sampleRate), + bit_depth: parseInt(exportSettings.bitDepth), + format: exportSettings.format + }, + tracks: tracksMeta + }) + }); + if (!resp.ok) throw new Error(`Server export failed: ${resp.status}`); + const data = await resp.json(); + showToast("Đang xử lý trên máy chủ...", "info"); + + // Poll for result + const result = await pollTaskResult(data.task_id, 20); + if (result.success) { + // Get the uploaded file from server + const fileId = result.output_file_id || result.output_path?.split('/').pop(); + if (fileId) { + const downloadUrl = `${API_AUDIO}/download/${fileId}`; + const a = document.createElement('a'); + a.href = downloadUrl; + a.download = fileId; + a.click(); + showToast("Xuất bản âm thanh từ máy chủ hoàn tất!", "success"); + } + } else { + throw new Error(result.error || 'Server processing failed'); + } + } catch (err) { + showToast("Lỗi xuất máy chủ: " + err.message + ". Chuyển sang xuất client.", "warning"); + // Fall back to client-side export + clientSideExport(activeTracks); + } finally { + setIsExporting(false); + } + } else { + // Client-side export (existing working code) + clientSideExport(activeTracks); + } + }; + const handleSaveCloud = async () => { + if (!currentUser) { + setIsMandatoryLogin(false); + setAuthMode('login'); + setAuthModalOpen(true); + return; + } + const name = prompt("Nhập tên dự án để lưu lên Cloud:", "Dự án SonicForge"); + if (!name) return; + const serializeSafe = arr => (arr || []).map(t => ({ + id: t.id, + name: t.name, + startTime: t.startTime, + height: t.height, + volumeDb: t.volumeDb, + pan: t.pan, + muted: t.muted, + solo: t.solo, + color: t.color, + markers: t.markers || [], + serverFileId: t.serverFileId || null, + channelInfo: t.channelInfo ? { + channels: t.channelInfo.channels, + isStereo: t.channelInfo.isStereo, + label: t.channelInfo.label + } : null + })); + try { + const dataJson = JSON.stringify({ + id: 'cloud_project', + name, + tracks: serializeSafe(tracks) + }); + await window.SonicAPI.saveCloudProject(name, dataJson); + showToast("Đã lưu dự án lên Cloud thành công!", "success"); + } catch (err) { + showToast(err.message || "Lỗi lưu Cloud", "error"); + } + }; + const handleExportSFS = () => { + const serializeSafe = arr => (arr || []).map(t => ({ + id: t.id, + name: t.name, + startTime: t.startTime, + height: t.height, + volumeDb: t.volumeDb, + pan: t.pan, + muted: t.muted, + solo: t.solo, + color: t.color, + markers: t.markers || [], + serverFileId: t.serverFileId || null, + channelInfo: t.channelInfo ? { + channels: t.channelInfo.channels, + isStereo: t.channelInfo.isStereo, + label: t.channelInfo.label + } : null + })); + window.SonicStorage.exportProjectToSFS({ + id: 'proj_' + Date.now(), + name: 'Dự án SonicForge', + tracks: serializeSafe(tracks) + }); + showToast("Đã xuất dự án (.sfs) thành công!", "success"); + }; + const handleImportSFS = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.sfs,application/json'; + input.onchange = async e => { + if (!e.target.files[0]) return; + try { + const proj = await window.SonicStorage.importProjectFromSFSFile(e.target.files[0]); + const restored = (proj.tracks || []).map(t => ({ + ...t, + buffer: null, + channelInfo: t.channelInfo || null, + clips: t.clips || [], + serverFileId: t.serverFileId || null + })); + if (restored.length > 0) { + setTracks(restored); + showToast(`Đã nạp dự án "${proj.name}" từ tệp .sfs thành công!`, "success"); + } + } catch (err) { + showToast(err.message || "Lỗi mở tệp .sfs", "error"); + } + }; + input.click(); + }; + const clientSideExport = async activeTracks => { + setIsExporting(true); + showToast("Đang trộn âm thanh đa kênh (Offline Mixdown)...", "info"); + try { + const targetRate = parseInt(exportSettings.sampleRate); + const bitDepth = parseInt(exportSettings.bitDepth); + const durationLimit = Math.max(...activeTracks.map(t => { + const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ + id: 'default', + buffer: t.buffer, + startTime: t.startTime || 0, + name: t.name, + speed: t.speed || 1.0 + }] : []; + if (clips.length === 0) return 0; + return Math.max(...clips.map(c => (c.startTime || 0) + c.buffer.duration / (c.speed || 1.0))); + })); + const offlineCtx = new OfflineAudioContext(1, Math.ceil(targetRate * Math.max(0.1, durationLimit)), targetRate); + activeTracks.forEach(t => { + const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ + id: 'default', + buffer: t.buffer, + startTime: t.startTime || 0, + name: t.name, + speed: t.speed || 1.0 + }] : []; + clips.forEach(clip => { + if (!clip.buffer) return; + const source = offlineCtx.createBufferSource(); + source.buffer = clip.buffer; + source.playbackRate.value = clip.speed || 1.0; + const gain = offlineCtx.createGain(); + 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(panner); + panner.connect(offlineCtx.destination); + const clipStart = clip.startTime || 0; + source.start(clipStart); + }); + }); + const renderedBuffer = await offlineCtx.startRendering(); + const monoData = renderedBuffer.getChannelData(0); + const bufferLength = monoData.length; + const bytesPerSample = bitDepth / 8; + const headerSize = 44; + const fileSizeBytes = headerSize + bufferLength * bytesPerSample; + const fileBuffer = new ArrayBuffer(fileSizeBytes); + const view = new DataView(fileBuffer); + const writeString = (offset, string) => { + for (let i = 0; i < string.length; i++) { + view.setUint8(offset + i, string.charCodeAt(i)); + } + }; + writeString(0, 'RIFF'); + view.setUint32(4, fileSizeBytes - 8, true); + writeString(8, 'WAVE'); + writeString(12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, 1, true); + view.setUint32(24, targetRate, true); + view.setUint32(28, targetRate * bytesPerSample, true); + view.setUint16(32, bytesPerSample, true); + view.setUint16(34, bitDepth, true); + writeString(36, 'data'); + view.setUint32(40, bufferLength * bytesPerSample, true); + let offset = 44; + for (let i = 0; i < bufferLength; i++) { + const sample = Math.max(-1, Math.min(1, monoData[i])); + if (bitDepth === 8) { + view.setUint8(offset, Math.floor((sample + 1.0) * 127.5)); + } else if (bitDepth === 16) { + view.setInt16(offset, Math.floor(sample < 0 ? sample * 0x8000 : sample * 0x7FFF), true); + } else if (bitDepth === 24) { + const val24 = Math.floor(sample < 0 ? sample * 0x800000 : sample * 0x7FFFFF); + view.setUint8(offset, val24 & 0xFF); + view.setUint8(offset + 1, val24 >> 8 & 0xFF); + view.setUint8(offset + 2, val24 >> 16 & 0xFF); + } + offset += bytesPerSample; + } + const blob = new Blob([view], { + type: 'audio/wav' + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `sonicforge-session-${targetRate}hz-${bitDepth}bit.wav`; + a.click(); + URL.revokeObjectURL(url); + showToast("Xuất bản âm thanh hoàn tất!", "success"); + } catch (err) { + showToast("Lỗi xuất âm thanh: " + err.message, "error"); + } finally { + setIsExporting(false); + } + }; + + // ── AI Analysis (server-side with fallback) ── + const triggerAIAnalysis = async () => { + setAnalysisState({ + status: 'Connecting to AI Engine...', + data: null, + isRunning: true + }); + + // Check if we have a file on the server to analyze + const activeTrack = tracks.find(t => t.id === selectedTrackId); + const serverFileId = serverFileIdMap[selectedTrackId]; + if (serverFileId && serverStatus === 'connected') { + try { + const resp = await fetch(`${API_AUDIO}/analyze-ai`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + file_id: serverFileId, + api_base_url: aiConfig.baseUrl !== API_BASE_URL ? aiConfig.baseUrl : null, + model: aiConfig.model + }) + }); + if (resp.ok) { + const data = await resp.json(); + showToast("Đang phân tích cấu trúc trên máy chủ...", "info"); + try { + const result = await pollTaskResult(data.task_id, 15); + if (result.bpm) { + if (selLeft !== null && selRight !== null && activeTrack && activeTrack.buffer) { + const snapStart = findZeroCrossing(activeTrack.buffer, selLeft); + const snapEnd = findZeroCrossing(activeTrack.buffer, selRight); + if (selectionMode === 'local') { + setLocalSelectionStart(snapStart); + setLocalSelectionEnd(snapEnd); + } else { + setSelectionStart(snapStart); + setSelectionEnd(snapEnd); + } + } + setAnalysisState({ + status: 'Hoàn thành phân tích (Server)', + data: result, + isRunning: false + }); + showToast(`AI Server: ${result.bpm} BPM | ${result.beats?.length || 0} beats detected (Zero-Crossing Aligned)`, 'success'); + return; + } + } catch (e) { + console.warn('Server AI poll failed, using fallback:', e); + } + } + } catch (err) { + console.warn('Server AI analysis failed, using fallback:', err); + } + } + + // Fallback: client-side analysis with simulated BPM + setAnalysisState({ + status: 'Processing structural detection...', + data: null, + isRunning: true + }); + setTimeout(() => { + let bpm = 120; + // Try to detect BPM from buffer + if (activeTrack && activeTrack.buffer) { + const data = activeTrack.buffer.getChannelData(0); + const sr = activeTrack.buffer.sampleRate; + // Simple autocorrelation for BPM estimation + const windowSize = Math.min(sr * 3, data.length); + if (windowSize > sr) { + let maxCorr = 0; + let bestLag = Math.floor(sr * 0.25); // ~240 BPM max + for (let lag = Math.floor(sr * 0.25); lag < Math.floor(sr * 2); lag++) { + let corr = 0; + const n = Math.floor(windowSize / 2); + for (let i = 0; i < n; i++) { + corr += data[i] * data[i + lag]; + } + corr /= n; + if (corr > maxCorr) { + maxCorr = corr; + bestLag = lag; + } + } + if (bestLag > 0) { + bpm = Math.round(60 * sr / bestLag); + bpm = Math.max(60, Math.min(200, bpm)); + } + } + } + if (selLeft !== null && selRight !== null && activeTrack && activeTrack.buffer) { + const snapStart = findZeroCrossing(activeTrack.buffer, selLeft); + const snapEnd = findZeroCrossing(activeTrack.buffer, selRight); + if (selectionMode === 'local') { + setLocalSelectionStart(snapStart); + setLocalSelectionEnd(snapEnd); + } else { + setSelectionStart(snapStart); + setSelectionEnd(snapEnd); + } + } + setAnalysisState({ + status: 'Hoàn thành phân tích (Client)', + data: { + bpm, + bars: Math.max(4, Math.round(bpm / 30)), + timeSig: '4/4', + detectedKey: 'Am' + }, + isRunning: false + }); + showToast(`Phân tích: ${bpm} BPM (Zero-Crossing Aligned)`, 'success'); + }, 1500); + }; + + // ── AI Analysic Loop: scan track, detect beats, place markers for loop selection ── + const handleAIAnalysicLoop = async () => { + const forcedTrackId = subTabAiTrackIdRef.current; + subTabAiTrackIdRef.current = null; + const activeTrackId = forcedTrackId || selectedTrackId; + const activeTrack = tracks.find(t => t.id === activeTrackId); + if (!activeTrack || !activeTrack.buffer) { + showToast("Vui lòng chọn một Track có âm thanh để AI phân tích.", "warning"); + return; + } + setAnalysisState({ + status: 'AI Analysic Loop: đang phát hiện nhịp...', + data: null, + isRunning: true + }); + showToast("AI Analysic Loop: đang quét cấu trúc nhịp điệu...", "info"); + try { + const buffer = activeTrack.buffer; + const data = buffer.getChannelData(0); + const sr = buffer.sampleRate; + const windowSize = Math.min(sr * 3, data.length); + + // Client-side BPM detection via autocorrelation + let detectedBPM = 120; + if (windowSize > sr) { + let maxCorr = 0; + for (let lag = Math.floor(sr * 0.3); lag <= Math.floor(sr * 2.0); lag++) { + let corr = 0; + const step = 4; + for (let i = 0; i < windowSize && i + lag < data.length; i += step) { + corr += data[i] * data[i + lag]; + } + corr /= windowSize / step; + if (corr > maxCorr) { + maxCorr = corr; + detectedBPM = 60 / (lag / sr); + } + } + } + detectedBPM = Math.round(Math.min(240, Math.max(60, detectedBPM))); + const beatDuration = 60 / detectedBPM; + const barDuration = beatDuration * 4; + const totalDuration = buffer.duration; + + // Place markers at each bar (strong beat) position + const barMarkers = []; + for (let t = 0; t < totalDuration; t += barDuration) { + const zcTime = findZeroCrossing(buffer, t); + barMarkers.push({ + id: 'ai_bar_' + barMarkers.length + '_' + Date.now(), + time: zcTime, + label: `Bar ${barMarkers.length + 1}`, + color: '#06b6d4' + }); + // Add beat markers within each bar + for (let b = 1; b < 4; b++) { + const bt = t + b * beatDuration; + if (bt < totalDuration) { + const zcBt = findZeroCrossing(buffer, bt); + barMarkers.push({ + id: 'ai_beat_' + barMarkers.length + '_' + Date.now(), + time: zcBt, + label: `Beat ${b + 1}`, + color: '#a855f7' + }); + } + } + } + setTracks(prev => prev.map(t => { + if (t.id !== activeTrack.id) return t; + const existingMarkers = t.markers || []; + return { + ...t, + markers: [...existingMarkers, ...barMarkers] + }; + })); + + // Find the first strong beat to set as selection start + const firstBeat = barMarkers.length > 0 ? barMarkers[0].time : 0; + const secondBar = barMarkers.length > 4 ? barMarkers[Math.min(4, barMarkers.length - 1)].time : Math.min(totalDuration, firstBeat + barDuration); + setSelectionStart(firstBeat); + setSelectionEnd(secondBar); + setAnalysisState({ + status: `AI Analysic Loop: ${detectedBPM} BPM, ${barMarkers.length} markers (Bar/Beat)`, + data: { + bpm: detectedBPM, + bars: Math.floor(totalDuration / barDuration) + }, + isRunning: false + }); + showToast(`AI Analysic Loop: ${detectedBPM} BPM - ${Math.floor(totalDuration / barDuration)} bars detected`, "success"); + } catch (err) { + setAnalysisState({ + status: 'Lỗi AI Analysic Loop', + data: null, + isRunning: false + }); + showToast(err.message || 'Lỗi khi phân tích nhịp', 'error'); + } + }; + // ── Mark Selection ── + // ── Helper: set selection range from buffer (used by sub-tab AI) ── + const setSelectionRangeOnBuffer = (buffer, startTime, endTime) => { + setSelectionStart(startTime); + setSelectionEnd(endTime); + }; + + // Ref for forced trackId (used by sub-tab AI buttons, overrides selectedTrackId) + const subTabAiTrackIdRef = useRef(null); + const handleAIScan = async () => { + // Resolve track: prefer sub-tab override, then selectedTrackId + const forcedTrackId = subTabAiTrackIdRef.current; + subTabAiTrackIdRef.current = null; + const activeTrackId = forcedTrackId || selectedTrackId; + const activeTrack = tracks.find(t => t.id === activeTrackId); + if (!activeTrack || !activeTrack.buffer) { + showToast("Vui lòng chọn một Track có âm thanh để AI quét Loop.", "warning"); + return; + } + setAnalysisState({ + status: 'AI Loop Scan đang quét nhịp điệu và phách mạnh...', + data: null, + isRunning: true + }); + showToast("AI Scan đang phân tích tempo và phách mạnh...", "info"); + try { + const buffer = activeTrack.buffer; + const data = buffer.getChannelData(0); + const sr = buffer.sampleRate; + const windowSize = Math.min(sr * 3, data.length); + + // Detect BPM via autocorrelation + let detectedBPM = 120; + if (windowSize > sr) { + let maxCorr = 0; + for (let lag = Math.floor(sr * 0.3); lag <= Math.floor(sr * 2.0); lag++) { + let corr = 0; + const step = 4; + for (let i = 0; i < windowSize && i + lag < data.length; i += step) { + corr += data[i] * data[i + lag]; + } + corr /= windowSize / step; + if (corr > maxCorr) { + maxCorr = corr; + detectedBPM = 60 / (lag / sr); + } + } + } + detectedBPM = Math.round(Math.min(240, Math.max(60, detectedBPM))); + + // Calculate bar grid + const beatDuration = 60 / detectedBPM; + const barDuration = beatDuration * 4; + const totalDuration = buffer.duration; + + // Place markers at bar starts (strong beats / downbeats) + const barMarkers = []; + for (let t = 0; t < totalDuration; t += barDuration) { + const zcTime = findZeroCrossing(buffer, t); + barMarkers.push({ + id: 'ai_bar_' + barMarkers.length + '_' + Date.now(), + time: zcTime, + label: `Downbeat ${barMarkers.length + 1}`, + color: '#06b6d4' + }); + } + setTracks(prev => prev.map(t => { + if (t.id !== activeTrack.id) return t; + const existingMarkers = t.markers || []; + return { + ...t, + markers: [...existingMarkers, ...barMarkers] + }; + })); + + // Set selection to the first downbeat + const firstBarStart = barMarkers.length > 0 ? barMarkers[0].time : 0; + const secondBarStart = barMarkers.length > 1 ? barMarkers[1].time : Math.min(totalDuration, firstBarStart + barDuration); + setSelectionStart(firstBarStart); + setSelectionEnd(secondBarStart); + setAnalysisState({ + status: `AI Scan: ${detectedBPM} BPM, ${barMarkers.length} downbeats (đã snap zero-crossing)`, + data: { + bpm: detectedBPM + }, + isRunning: false + }); + showToast(`AI Scan: ${detectedBPM} BPM - ${barMarkers.length} downbeats detected`, "success"); + } catch (err) { + setAnalysisState({ + status: 'Lỗi khi AI Scan', + data: null, + isRunning: false + }); + showToast(err.message || 'Lỗi khi quét AI Loop', 'error'); + } + }; + const runPythonTool = async toolType => { + const activeTrack = tracks.find(t => t.id === selectedTrackId); + if (!activeTrack || !activeTrack.buffer) { + showToast("Vui lòng chọn một Track để xử lý công cụ Python.", "warning"); + return; + } + try { + if (toolType === 'normalize') { + const channelData = activeTrack.buffer.getChannelData(0); + let maxVal = 0; + for (let i = 0; i < channelData.length; i++) { + maxVal = Math.max(maxVal, Math.abs(channelData[i])); + } + if (maxVal > 0) { + const gain = 1.0 / maxVal; + for (let i = 0; i < channelData.length; i++) { + channelData[i] *= gain; + } + } + showToast("Đã Chuẩn Hóa Peak âm thanh về 0 dB!", "success"); + } else if (toolType === 'invert_phase') { + const channelData = activeTrack.buffer.getChannelData(0); + for (let i = 0; i < channelData.length; i++) { + channelData[i] *= -1; + } + showToast("Đã Đảo Pha (180°) âm thanh thành công!", "success"); + } else if (toolType === 'swap_channels') { + if (activeTrack.buffer.numberOfChannels >= 2) { + const left = activeTrack.buffer.getChannelData(0); + const right = activeTrack.buffer.getChannelData(1); + for (let i = 0; i < left.length; i++) { + const temp = left[i]; + left[i] = right[i]; + right[i] = temp; + } + showToast("Đã Đổi Kênh Left / Right thành công!", "success"); + } else { + showToast("Track hiện tại là Mono. Chỉ áp dụng Đổi Kênh cho Stereo.", "info"); + } + } else if (toolType === 'synth_wave') { + generateSynthToTrack(activeTrack.id, 'synth'); + showToast("Đã tạo Tín Hiệu Sóng Tổng Hợp bằng công cụ Python!", "success"); + } + } catch (err) { + showToast(err.message || "Lỗi khi chạy công cụ Python", "error"); + } + }; + const handleMarkSelection = () => { + if (selLeft === null || selRight === null || selectionStats.length === 0) { + showToast("Vui lòng chọn một khoảng thời gian trên sóng âm trước.", "warning"); + return; + } + const targetTrack = tracks.find(t => t.id === selectedTrackId); + if (!targetTrack || !targetTrack.buffer) { + showToast("Vui lòng nhấp chọn một Track có sóng âm để gán Marker.", "warning"); + return; + } + + // For local selection, force mark on the local-selected track + const markTrackId = selectionMode === 'local' && localSelectionTrackId ? localSelectionTrackId : selectedTrackId; + setTracks(prev => prev.map(t => { + if (t.id !== markTrackId) return t; + const snapStart = findZeroCrossing(t.buffer, selLeft); + const snapEnd = findZeroCrossing(t.buffer, selRight); + const newMarkers = [...t.markers, { + id: Date.now() + '_s', + time: snapStart + }, { + id: Date.now() + '_e', + time: snapEnd + }].sort((a, b) => a.time - b.time); + return { + ...t, + markers: newMarkers + }; + })); + showToast(`Đã tạo 2 Markers tại đầu và cuối dải chọn (Snap Zero-Crossing)`, "success"); + }; + + // ── AI Cut to New Track (Music Theory Loop Detection) ── + const handleAICutToNewTrack = () => { + const forcedTrackId = subTabAiTrackIdRef.current; + subTabAiTrackIdRef.current = null; + const activeTrackId = forcedTrackId || selectedTrackId; + const activeTrack = tracks.find(t => t.id === activeTrackId); + if (!activeTrack || !activeTrack.buffer) { + showToast("Vui lòng chọn một Track có dữ liệu âm thanh trước.", "warning"); + return; + } + if (selectionStart === null || selectionEnd === null || selectionStats.length === 0) { + showToast("Vui lòng kéo chọn một khoảng thời gian trên sóng âm.", "warning"); + return; + } + setAnalysisState({ + status: 'AI Cut: đang phân tích nhịp và tìm loop point...', + data: null, + isRunning: true + }); + showToast("AI Cut: đang phân tích nhịp điệu và tìm điểm loop chính xác...", "info"); + setTimeout(() => { + try { + const buffer = activeTrack.buffer; + const sampleRate = buffer.sampleRate; + const channelData = buffer.getChannelData(0); + const dataLen = channelData.length; + const windowSize = Math.min(sampleRate * 3, dataLen); + + // Detect BPM via autocorrelation + let detectedBPM = 120; + if (windowSize > sampleRate) { + let maxCorr = 0; + for (let lag = Math.floor(sampleRate * 0.3); lag <= Math.floor(sampleRate * 2.0); lag++) { + let corr = 0; + const step = 4; + for (let i = 0; i < windowSize && i + lag < dataLen; i += step) { + corr += channelData[i] * channelData[i + lag]; + } + corr /= windowSize / step; + if (corr > maxCorr) { + maxCorr = corr; + detectedBPM = 60 / (lag / sampleRate); + } + } + } + detectedBPM = Math.round(Math.min(240, Math.max(60, detectedBPM))); + const beatDuration = 60 / detectedBPM; + const barDuration = beatDuration * 4; + + // Use the selection range + const rawStart = selectionStats.start; + const rawEnd = selectionStats.end; + const selDuration = rawEnd - rawStart; + + // Find the nearest bar start (downbeat) for loop start + const barsFromZero = rawStart / barDuration; + const nearestBarStart = Math.round(barsFromZero) * barDuration; + const loopStart = Math.max(0, Math.min(rawStart + barDuration, nearestBarStart)); + + // Find the nearest beat 4 (bar end) for loop end + // In 4/4 time: beat 4 = barStart + 3*beatDuration = barEnd + const barsFromStart = rawEnd / barDuration; + const nearestBarEnd = Math.round(barsFromStart) * barDuration; + // Ensure minimum 1 bar loop + let loopEnd = Math.max(loopStart + barDuration, nearestBarEnd); + if (loopEnd > rawEnd + beatDuration) loopEnd = loopStart + Math.ceil(selDuration / barDuration) * barDuration; + + // Snap to zero-crossing for click-free loop + const snapLoopStart = findZeroCrossing(buffer, loopStart); + const snapLoopEnd = findZeroCrossing(buffer, loopEnd); + + // Place markers for the loop points + setTracks(prev => prev.map(t => { + if (t.id !== activeTrack.id) return t; + const existingMarkers = t.markers || []; + const filtered = existingMarkers.filter(m => !m.id.startsWith('ai_loop_')); + return { + ...t, + markers: [...filtered, { + id: 'ai_loop_start_' + Date.now(), + time: snapLoopStart, + label: 'Loop Start (Bar ' + (Math.floor(snapLoopStart / barDuration) + 1) + ')', + color: '#06b6d4' + }, { + id: 'ai_loop_end_' + Date.now(), + time: snapLoopEnd, + label: 'Loop End (Beat 4)', + color: '#a855f7' + }] + }; + })); + setSelectionStart(snapLoopStart); + setSelectionEnd(snapLoopEnd); + const startSample = Math.max(0, Math.min(dataLen - 1, Math.floor(snapLoopStart * sampleRate))); + const endSample = Math.max(0, Math.min(dataLen, Math.floor(snapLoopEnd * sampleRate))); + const sliceLength = endSample - startSample; + if (sliceLength <= 0) { + showToast("Dải cắt không hợp lệ hoặc khoảng thời gian quá ngắn.", "error"); + setAnalysisState({ + status: 'Thất bại', + data: null, + isRunning: false + }); + return; + } + const context = getAudioContext(); + const numChannels = buffer.numberOfChannels || 1; + const slicedBuffer = context.createBuffer(numChannels, sliceLength, sampleRate); + for (let c = 0; c < numChannels; c++) { + const srcData = buffer.getChannelData(c); + const dstData = slicedBuffer.getChannelData(c); + dstData.set(srcData.subarray(startSample, endSample)); + } + const newId = 'track_ai_cut_' + Date.now(); + const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; + const selectColor = colors[tracks.length % colors.length]; + const barNum = Math.floor(snapLoopStart / barDuration) + 1; + const barsCount = Math.max(1, Math.round((snapLoopEnd - snapLoopStart) / barDuration)); + const newTrack = { + id: newId, + name: `Loop_${barNum}bar_${activeTrack.name.replace('.wav', '').slice(0, 10)}_${snapLoopStart.toFixed(1)}s.wav`, + buffer: slicedBuffer, + channelInfo: activeTrack.channelInfo ? { + ...activeTrack.channelInfo + } : null, + startTime: snapLoopStart, + clips: [{ + id: 'clip_' + newId, + buffer: slicedBuffer, + startTime: snapLoopStart, + name: `Loop_${barNum}bar_${snapLoopStart.toFixed(1)}s` + }], + volumeDb: 0, + pan: 0, + muted: false, + solo: false, + color: selectColor, + markers: [{ + id: Date.now() + '_s', + time: 0 + }, { + id: Date.now() + '_e', + time: snapLoopEnd - snapLoopStart + }], + serverFileId: null + }; + setTracks(prev => { + const idx = prev.findIndex(t => t.id === selectedTrackId); + const updated = [...prev]; + if (idx !== -1) { + updated.splice(idx + 1, 0, newTrack); + } else { + updated.push(newTrack); + } + return updated; + }); + setSelectedTrackId(newId); + setAnalysisState({ + status: `AI Cut: ${detectedBPM} BPM, ${barsCount} bars loop (Zero-Crossing aligned)`, + data: { + bpm: detectedBPM, + bars: barsCount, + timeSig: '4/4' + }, + isRunning: false + }); + showToast(`AI Cut: ${barsCount} bars loop at ${snapLoopStart.toFixed(3)}s - ${snapLoopEnd.toFixed(3)}s [${detectedBPM} BPM]`, "success"); + setTimeout(() => lucide.createIcons(), 200); + } catch (err) { + showToast("Lỗi khi AI Cut: " + err.message, "error"); + setAnalysisState({ + status: 'Lỗi AI Cut', + data: null, + isRunning: false + }); + } + }, 800); + }; + + // ── Split Track at Playhead ── + const handleSplitTrackAtTime = (trackId, clipId, time) => { + const track = tracks.find(t => t.id === trackId); + if (!track) return; + const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ + id: 'default', + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name + }] : []; + const targetClipId = clipId || clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration)?.id; + if (!targetClipId) return; + const clip = clips.find(c => c.id === targetClipId); + if (!clip || !clip.buffer) return; + const relTime = Math.max(0, time - clip.startTime); + const sr = clip.buffer.sampleRate; + const cutSample = Math.floor(relTime * sr); + const originalData = clip.buffer.getChannelData(0); + if (cutSample <= 0 || cutSample >= originalData.length) { + showToast("Vị trí cắt nằm ngoài dải âm thanh của clip.", "warning"); + return; + } + const beforeSnap = captureTrackSnapshot(trackId); + const ctx = getAudioContext(); + const b1 = ctx.createBuffer(1, cutSample, sr); + b1.copyToChannel(originalData.subarray(0, cutSample), 0); + const b2 = ctx.createBuffer(1, originalData.length - cutSample, sr); + b2.copyToChannel(originalData.subarray(cutSample), 0); + const clip1 = { + id: 'clip_' + Date.now() + '_p1', + name: `${clip.name.replace(' (Part 1)', '').replace(' (Part 2)', '')} (Part 1)`, + buffer: b1, + startTime: clip.startTime + }; + const clip2 = { + id: 'clip_' + Date.now() + '_p2', + name: `${clip.name.replace(' (Part 1)', '').replace(' (Part 2)', '')} (Part 2)`, + buffer: b2, + startTime: clip.startTime + cutSample / sr + }; + setTracks(prev => prev.map(t => { + if (t.id === trackId) { + const remainingClips = clips.filter(c => c.id !== targetClipId); + const updatedClips = [...remainingClips, clip1, clip2]; + return { + ...t, + clips: updatedClips, + buffer: updatedClips[0]?.buffer || null, + startTime: updatedClips[0]?.startTime || 0, + name: updatedClips[0]?.name || t.name + }; + } + return t; + })); + setTimeout(() => { + const afterSnap = captureTrackSnapshot(trackId); + pushAction('SPLIT_CLIP', trackId, beforeSnap, afterSnap); + }, 50); + showToast(`Đã chia nhỏ clip tại ${formatTime(time)}.`, "info"); + }; + const handleSplitTrack = trackId => { + handleSplitTrackAtTime(trackId, null, currentTime); + }; + + // ── Glue (Merge) Clips on Selected Track ── + const handleGlueTracks = () => { + const track = tracks.find(t => t.id === selectedTrackId); + if (!track) { + showToast('Vui lòng chọn một track để thực hiện gộp (glue).', 'warning'); + return; + } + const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ + id: 'default', + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name + }] : []; + if (clips.length < 2) { + showToast('Cần ít nhất 2 clip trên track này để gộp (glue).', 'warning'); + return; + } + const beforeSnap = captureTrackSnapshot(track.id); + const ctx = getAudioContext(); + const sr = clips[0].buffer.sampleRate; + let minStart = Infinity; + let maxEnd = -Infinity; + clips.forEach(c => { + const start = c.startTime || 0; + const end = start + c.buffer.duration; + minStart = Math.min(minStart, start); + maxEnd = Math.max(maxEnd, end); + }); + const newDur = maxEnd - minStart; + const newBuffer = ctx.createBuffer(1, Math.ceil(newDur * sr), sr); + const newData = newBuffer.getChannelData(0); + clips.forEach(c => { + const data = c.buffer.getChannelData(0); + const offset = Math.floor(((c.startTime || 0) - minStart) * sr); + for (let i = 0; i < data.length; i++) { + if (offset + i < newData.length) { + newData[offset + i] += data[i]; + } + } + }); + let maxPeak = 0; + for (let i = 0; i < newData.length; i++) { + const abs = Math.abs(newData[i]); + if (abs > maxPeak) maxPeak = abs; + } + if (maxPeak > 1.0) { + for (let i = 0; i < newData.length; i++) newData[i] /= maxPeak; + } + const mergedClip = { + id: 'clip_merged_' + Date.now(), + name: `${track.name.replace(' (Part 1)', '').replace(' (Part 2)', '')} (Glued)`, + buffer: newBuffer, + startTime: minStart + }; + setTracks(prev => prev.map(t => { + if (t.id === track.id) { + return { + ...t, + clips: [mergedClip], + buffer: newBuffer, + startTime: minStart, + name: mergedClip.name + }; + } + return t; + })); + setTimeout(() => { + const afterSnap = captureTrackSnapshot(track.id); + pushAction('GLUE', track.id, beforeSnap, afterSnap); + }, 50); + showToast(`Đã gộp ${clips.length} clips thành công.`, 'success'); + }; + + // ── Save AI config to localStorage ── + useEffect(() => { + localStorage.setItem('ai_base_url', aiConfig.baseUrl); + localStorage.setItem('ai_api_key', aiConfig.apiKey); + localStorage.setItem('ai_model', aiConfig.model); + }, [aiConfig]); + return /*#__PURE__*/React.createElement("div", { + className: "h-full w-full flex flex-col bg-[#1e1e1e]" + }, /*#__PURE__*/React.createElement("header", { + className: "h-9 bg-[#2e2e2e] border-b border-[#181818] flex items-center px-1 shrink-0 select-none" + }, [{ + label: 'File', + items: [{ + label: 'New Project', + icon: 'file-plus', + shortcut: 'Ctrl+N', + action: () => { + setTracks([{ + id: '1', + name: 'Track 01', + buffer: null, + startTime: 0, + height: 128, + volumeDb: 0, + pan: 0, + muted: false, + solo: false, + color: '#0f766e', + markers: [], + serverFileId: null + }, { + id: '2', + name: 'Track 02', + buffer: null, + startTime: 0, + height: 128, + 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: () => handleImportSFS() + }, { + label: 'Save Project', + icon: 'save', + shortcut: 'Ctrl+S', + action: () => handleExportSFS() + }, { + label: 'Save As...', + icon: 'save', + shortcut: 'Ctrl+Alt+S', + action: () => handleExportSFS() + }, { + label: 'Save to Cloud', + icon: 'upload-cloud', + action: () => handleSaveCloud() + }, { + sep: true + }, { + label: 'Config AI Providers...', + icon: 'settings', + action: () => setAiConfigModalOpen(true) + }, { + label: 'Import Audio...', + icon: 'file-input', + shortcut: 'Ctrl+Alt+I', + action: () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'audio/*'; + input.onchange = async e => { + if (e.target.files[0]) { + addNewTrack(); + const newId = (tracks.length + 1).toString(); + setTimeout(() => loadFileOnTrack(newId, e.target.files[0]), 100); + } + }; + input.click(); + showToast('Import audio', 'info'); + } + }, { + label: 'Export Mix...', + icon: 'file-output', + action: () => triggerWavExport() + }, { + sep: true + }, ...(currentUser ? [{ + label: 'Profile', + icon: 'user', + action: () => setProfileModalOpen(true) + }] : []), ...(currentUser && currentUser.role === 'admin' ? [{ + label: 'System Manager', + icon: 'settings', + action: () => setSystemManagerModalOpen(true) + }] : []), { + label: 'Logout', + icon: 'log-out', + action: () => handleLogout() + }] + }, { + label: 'Edit', + items: [{ + label: 'Insert New Track', + icon: 'plus', + shortcut: 'Ctrl+I', + action: addNewTrack + }, { + label: 'Insert Music to Track', + icon: 'music', + shortcut: 'Ctrl+Alt+I', + action: () => showToast('Select music file to insert', 'info') + }, { + sep: true + }, { + label: 'Edit in New Tab', + icon: 'file-edit', + shortcut: 'Ctrl+E', + action: () => openTempTab() + }, { + label: 'Split at Playhead', + icon: 'scissors', + shortcut: 'S', + action: () => handleSplitTrack(selectedTrackId) + }, { + label: 'Merge Tracks', + icon: 'combine', + shortcut: 'Ctrl+M', + action: () => { + handleMergeTracks(); + } + }, { + sep: true + }, { + label: 'Undo', + icon: 'undo', + shortcut: 'Ctrl+Z', + action: () => { + handleUndo(); + } + }, { + label: 'Redo', + icon: 'redo', + shortcut: 'Ctrl+Y', + action: () => { + handleRedo(); + } + }, { + sep: true + }, { + label: 'Copy', + icon: 'copy', + shortcut: 'Ctrl+C', + action: () => { + handleCopyTrack(); + } + }, { + label: 'Cut', + icon: 'scissors', + shortcut: 'Ctrl+X', + action: () => { + handleCutTrack(); + } + }, { + label: 'Paste', + icon: 'clipboard', + shortcut: 'Ctrl+V', + action: handlePasteTrack + }, { + sep: true + }, { + label: 'Delete Track', + icon: 'trash-2', + shortcut: 'Del', + action: () => { + handleDeleteTrack(); + } + }] + }, { + label: 'View', + items: [{ + label: 'Master Track', + icon: 'disc', + action: () => showToast('Master track view', 'info') + }, { + label: 'Maker View', + icon: 'layout', + action: () => showToast('Maker view', 'info') + }, { + label: 'Mixer', + icon: 'sliders', + action: () => showToast('Mixer panel', 'info') + }, { + label: 'Tempo Track', + icon: 'timer', + action: () => showToast('Tempo track', 'info') + }, { + label: 'Video', + icon: 'film', + action: () => showToast('Video panel', 'info') + }, { + label: 'Media Explorer', + icon: 'folder-search', + action: () => showToast('Media explorer', 'info') + }] + }, { + label: 'Tools', + items: [{ + label: 'Config AI Providers...', + icon: 'settings', + action: () => setAiConfigModalOpen(true) + }, { + label: 'Python DSP Tools Panel', + icon: 'wrench', + action: () => openPanel('python_tools') + }] + }, { + label: 'Help', + items: [{ + label: 'About SonicForge', + icon: 'info', + action: () => showToast('SonicForge Studio v1.0 - Professional DAW', 'info') + }] + }].map(menu => /*#__PURE__*/React.createElement("div", { + key: menu.label, + className: "relative" + }, /*#__PURE__*/React.createElement("button", { + onClick: () => setMenuOpen(menuOpen === menu.label ? null : menu.label), + className: `px-3 py-1 text-[11px] font-medium transition rounded ${menuOpen === menu.label ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}` + }, menu.label), menuOpen === menu.label && /*#__PURE__*/React.createElement("div", { + className: "absolute top-full left-0 mt-0.5 bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-52 z-50", + onClick: () => setMenuOpen(null) + }, menu.items.map((item, i) => item.sep ? /*#__PURE__*/React.createElement("div", { + key: i, + className: "h-px bg-zinc-700 my-1" + }) : /*#__PURE__*/React.createElement("button", { + key: item.label, + onClick: e => { + e.stopPropagation(); + item.action(); + setMenuOpen(null); + }, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": item.icon, + className: "w-3.5 h-3.5 text-zinc-500 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, item.label), item.shortcut && /*#__PURE__*/React.createElement("span", { + className: "text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8" + }, item.shortcut)))))), /*#__PURE__*/React.createElement("div", { + className: "flex-1" + }), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-2 px-2" + }, /*#__PURE__*/React.createElement("span", { + className: `text-[9px] font-bold uppercase px-1.5 py-0.5 rounded ${serverStatus === 'connected' ? 'bg-emerald-950 text-emerald-400' : serverStatus === 'checking' ? 'bg-amber-950 text-amber-400' : 'bg-red-950 text-red-400'}` + }, "Server: ", serverStatus), /*#__PURE__*/React.createElement("button", { + onClick: () => setShowAIConfig(!showAIConfig), + className: `px-1.5 py-0.5 rounded text-[10px] border transition ${showAIConfig ? 'bg-purple-900 text-purple-200 border-purple-700' : 'bg-zinc-800 text-zinc-400 border-zinc-700 hover:text-zinc-200'}` + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "cpu", + className: "w-3 h-3" + }))))), menuOpen && /*#__PURE__*/React.createElement("div", { + className: "fixed inset-0 z-40", + onClick: () => setMenuOpen(null) + }), /*#__PURE__*/React.createElement("div", { + className: "h-7 bg-[#222] border-b border-zinc-800 flex items-stretch px-2 gap-0.5 shrink-0 overflow-x-auto" + }, /*#__PURE__*/React.createElement("button", { + onClick: () => setActiveTab('main'), + className: `px-3 text-[10px] font-bold uppercase tracking-wider border-b-2 transition flex items-center gap-1 ${activeTab === 'main' ? 'text-cyan-400 border-cyan-500 bg-zinc-800/50' : 'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'}` + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "layout-dashboard", + className: "w-3 h-3" + })), " Main Session"), subTabs.map(st => /*#__PURE__*/React.createElement("div", { + key: st.id, + className: "flex items-stretch" + }, /*#__PURE__*/React.createElement("button", { + onClick: () => setActiveTab(st.id), + className: `px-2 text-[10px] font-medium border-b-2 transition flex items-center gap-1 ${activeTab === st.id ? 'text-amber-400 border-amber-500 bg-zinc-800/50' : 'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'}` + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "file-edit", + className: "w-3 h-3" + })), /*#__PURE__*/React.createElement("span", { + className: "max-w-[100px] truncate" + }, st.label)), /*#__PURE__*/React.createElement("button", { + onClick: () => closeSubTab(st.id), + className: "px-1 text-zinc-600 hover:text-red-400 transition text-[9px]", + title: "Close tab" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "x", + className: "w-3 h-3" + })))))), showAIConfig && /*#__PURE__*/React.createElement("div", { + className: "bg-zinc-900 border-b border-purple-900 p-3 flex flex-col gap-2 transition-all" + }, /*#__PURE__*/React.createElement("div", { + className: "text-xs font-bold text-purple-400 uppercase tracking-wider flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "cpu", + className: "w-4 h-4" + })), " Cấu hình cổng kết nối API"), /*#__PURE__*/React.createElement("div", { + className: "grid grid-cols-1 md:grid-cols-3 gap-2 text-xs" + }, /*#__PURE__*/React.createElement("div", { + className: "flex flex-col gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[10px] text-zinc-500 font-bold uppercase" + }, "Endpoint Base URL"), /*#__PURE__*/React.createElement("input", { + type: "text", + value: aiConfig.baseUrl, + onChange: e => setAiConfig(prev => ({ + ...prev, + baseUrl: e.target.value + })), + className: "bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-[11px]", + placeholder: "https://api.openai.com/v1" + })), /*#__PURE__*/React.createElement("div", { + className: "flex flex-col gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[10px] text-zinc-500 font-bold uppercase" + }, "API Token Key"), /*#__PURE__*/React.createElement("input", { + type: "password", + value: aiConfig.apiKey, + onChange: e => setAiConfig(prev => ({ + ...prev, + apiKey: e.target.value + })), + className: "bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-[11px]", + placeholder: "sk-..." + })), /*#__PURE__*/React.createElement("div", { + className: "flex flex-col gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[10px] text-zinc-500 font-bold uppercase" + }, "Model Name"), /*#__PURE__*/React.createElement("input", { + type: "text", + value: aiConfig.model, + onChange: e => setAiConfig(prev => ({ + ...prev, + model: e.target.value + })), + className: "bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-[11px]", + placeholder: "gpt-4o-mini" + })))), /*#__PURE__*/React.createElement("div", { + className: "h-9 bg-[#222] border-b border-zinc-800 flex items-center px-3 gap-2 shrink-0 select-none" + }, /*#__PURE__*/React.createElement("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' + } + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-0.5 mr-1 text-zinc-600" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "grip-vertical", + className: "w-3 h-3" + }))), /*#__PURE__*/React.createElement("button", { + onClick: () => { + setActiveTool('select'); + showToast('Select Tool', 'info'); + }, + 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 (V)" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "mouse-pointer", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("button", { + onClick: () => { + setActiveTool('grab'); + showToast('Grab Tool', 'info'); + }, + 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 (H)" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "hand", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-0.5 border border-zinc-700 rounded bg-zinc-850 px-0.5" + }, /*#__PURE__*/React.createElement("button", { + onClick: () => { + setActiveTool('razor'); + showToast('Razor Tool', 'info'); + }, + 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'}`, + title: "Razor Tool (C)" + }, /*#__PURE__*/React.createElement("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" + }, /*#__PURE__*/React.createElement("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" + }), /*#__PURE__*/React.createElement("path", { + d: "M4 9h16l-3 9H7z" + }), /*#__PURE__*/React.createElement("circle", { + cx: "12", + cy: "6", + r: "1" + }))), /*#__PURE__*/React.createElement("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" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "link", + className: "w-3.5 h-3.5" + })))), /*#__PURE__*/React.createElement("button", { + onClick: () => { + setActiveTool('pen'); + showToast('Pen Tool', 'info'); + }, + 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 (P)" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "pen-tool", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("div", { + className: "w-[1px] h-5 bg-zinc-700 mx-0.5" + }), /*#__PURE__*/React.createElement("button", { + onClick: handleCutTrack, + 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)" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "scissors", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("button", { + onClick: handleCopyTrack, + 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)" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "copy", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("button", { + onClick: handlePasteTrack, + 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)" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "clipboard", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("div", { + className: "w-[1px] h-5 bg-zinc-700 mx-0.5" + }), /*#__PURE__*/React.createElement("button", { + onClick: addNewTrack, + className: "px-2 py-1 bg-cyan-600 hover:bg-cyan-500 text-white font-semibold rounded text-xs flex items-center gap-1 shadow transition", + title: "Thêm Track Mới (Ctrl+I)" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "plus", + className: "w-3.5 h-3.5" + })), /*#__PURE__*/React.createElement("span", null, "Track")), /*#__PURE__*/React.createElement("div", { + className: "w-[1px] h-5 bg-zinc-700 mx-0.5" + }), /*#__PURE__*/React.createElement("button", { + onClick: handleUndo, + disabled: undoStack.length === 0, + 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)" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "undo", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("button", { + onClick: handleRedo, + disabled: redoStack.length === 0, + 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)" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "redo", + className: "w-3.5 h-3.5" + })))), /*#__PURE__*/React.createElement("div", { + className: "w-[1px] h-5 bg-zinc-800 mx-0.5" + }), /*#__PURE__*/React.createElement("button", { + onClick: () => { + if (activeTab !== 'main') { + setSubTabs(prev => prev.map(s => s.id === activeTab ? { + ...s, + currentTime: 0 + } : s)); + } else { + setCurrentTime(0); + } + }, + className: "w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", + title: "Quay lại đầu" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "skip-back", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("button", { + onClick: () => { + if (activeTab !== 'main') { + setSubTabs(prev => prev.map(s => { + if (s.id !== activeTab) return s; + const left = s.selectionStart !== null && s.selectionEnd !== null ? Math.min(s.selectionStart, s.selectionEnd) : null; + return left !== null ? { + ...s, + currentTime: left + } : s; + })); + } else { + if (selLeft !== null) setCurrentTime(selLeft); + } + }, + className: "w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", + title: "Đầu vùng chọn" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "step-back", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("button", { + onClick: handlePlayPause, + className: `w-7 h-7 flex items-center justify-center rounded border transition ${isPlaying ? 'bg-emerald-600 text-black border-emerald-500 hover:bg-emerald-500' : 'bg-cyan-600 text-white border-cyan-500 hover:bg-cyan-500'}`, + title: isPlaying ? "Tạm dừng" : "Play" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": isPlaying ? "pause" : "play", + className: "w-3.5 h-3.5 fill-current" + }))), /*#__PURE__*/React.createElement("button", { + onClick: handleStop, + className: "w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", + title: "Stop" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "square", + className: "w-3.5 h-3.5 fill-current" + }))), /*#__PURE__*/React.createElement("button", { + onClick: () => { + if (activeTab !== 'main') { + setSubTabs(prev => prev.map(s => { + if (s.id !== activeTab) return s; + const right = s.selectionStart !== null && s.selectionEnd !== null ? Math.max(s.selectionStart, s.selectionEnd) : null; + return right !== null ? { + ...s, + currentTime: right + } : s; + })); + } else { + if (selRight !== null) setCurrentTime(selRight); + } + }, + className: "w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", + title: "Cuối vùng chọn" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "step-forward", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("button", { + onClick: () => { + if (activeTab !== 'main') { + setSubTabs(prev => prev.map(s => { + if (s.id !== activeTab) return s; + const duration = s.buffer ? s.buffer.duration / (s.speed || 1.0) : 0; + return { + ...s, + currentTime: duration + }; + })); + } else { + setCurrentTime(maxDuration); + } + }, + className: "w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", + title: "Đến cuối" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "skip-forward", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("div", { + className: "w-[1px] h-5 bg-zinc-800 mx-0.5" + }), /*#__PURE__*/React.createElement("button", { + onClick: () => setIsLoopingSelection(prev => !prev), + className: `w-7 h-7 flex items-center justify-center rounded border transition ${isLoopingSelection ? 'bg-amber-600 text-black border-amber-500 hover:bg-amber-500' : 'bg-zinc-800 text-zinc-200 border-zinc-700 hover:bg-zinc-700'}`, + title: "Bật/Tắt Lặp vùng chọn" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "repeat", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("span", { + className: "text-[14px] text-zinc-500 font-bold uppercase ml-2" + }, "Snap"), /*#__PURE__*/React.createElement("select", { + value: snapValue, + onChange: e => setSnapValue(e.target.value), + className: "bg-black text-white text-[14px] px-1.5 py-0.5 rounded border border-zinc-700 focus:outline-none focus:border-cyan-500 font-mono cursor-pointer" + }, /*#__PURE__*/React.createElement("option", { + value: "free" + }, "Free"), /*#__PURE__*/React.createElement("option", { + value: "1" + }, "1"), /*#__PURE__*/React.createElement("option", { + value: "1/2" + }, "1/2"), /*#__PURE__*/React.createElement("option", { + value: "1/4" + }, "1/4"), /*#__PURE__*/React.createElement("option", { + value: "1/8" + }, "1/8"), /*#__PURE__*/React.createElement("option", { + value: "1/16" + }, "1/16"), /*#__PURE__*/React.createElement("option", { + value: "1/32" + }, "1/32")), /*#__PURE__*/React.createElement("div", { + className: "w-[1px] h-6 bg-zinc-800 mx-1.5" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[14px] text-zinc-500 font-bold" + }, "Bars:"), /*#__PURE__*/React.createElement("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; + clearLocalSelection(); + setSelectionMode('global'); + setSelectionStart(t); + setSelectionEnd(t + beatDuration * 4); + }, + className: "w-14 bg-black text-white text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[14px] text-zinc-500" + }, "-"), /*#__PURE__*/React.createElement("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 + beatDuration * 4); + setNumberBar(b - beginBar + 1); + }, + className: "w-14 bg-black text-white text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[14px] text-zinc-500" + }, "#"), /*#__PURE__*/React.createElement("input", { + type: "number", + min: "1", + value: numberBar, + readOnly: true, + className: "w-14 bg-black text-zinc-400 text-[14px] px-1 py-0.5 rounded border border-zinc-800 text-center font-mono" + }), /*#__PURE__*/React.createElement("div", { + className: "w-[1px] h-6 bg-zinc-800 mx-1.5" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[14px] text-zinc-500" + }, "Start:"), /*#__PURE__*/React.createElement("input", { + type: "text", + value: selLeft !== null ? formatTime(selLeft) : '', + onChange: e => { + const parts = e.target.value.split(/[:.]/); + if (parts.length === 3) { + const secs = parseInt(parts[0]) * 60 + parseInt(parts[1]) + parseFloat('0.' + (parts[2] || '0')); + clearLocalSelection(); + setSelectionMode('global'); + setSelectionStart(secs); + } + }, + className: "w-20 bg-black text-zinc-200 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[14px] text-zinc-500" + }, "End:"), /*#__PURE__*/React.createElement("input", { + type: "text", + value: selRight !== null ? formatTime(selRight) : '', + onChange: e => { + const parts = e.target.value.split(/[:.]/); + if (parts.length === 3) { + const secs = parseInt(parts[0]) * 60 + parseInt(parts[1]) + parseFloat('0.' + (parts[2] || '0')); + setSelectionEnd(secs); + } + }, + className: "w-20 bg-black text-zinc-200 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[14px] text-zinc-500" + }, "Len:"), /*#__PURE__*/React.createElement("input", { + type: "text", + value: selLeft !== null && selRight !== null ? formatTime(Math.abs(selRight - selLeft)) : '', + onChange: e => { + const parts = e.target.value.split(/[:.]/); + if (parts.length === 3 && selLeft !== null) { + const secs = parseInt(parts[0]) * 60 + parseInt(parts[1]) + parseFloat('0.' + (parts[2] || '0')); + setSelectionEnd(selLeft + secs); + } + }, + className: "w-20 bg-black text-amber-300 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" + }), /*#__PURE__*/React.createElement("div", { + className: "flex-1" + }), /*#__PURE__*/React.createElement("div", { + className: "text-xs font-bold font-mono text-zinc-100" + }, formatTime(currentTime))), (() => { + const dockPanels = { + top: [], + right: [], + bottom: [], + left: [] + }; + const addPanel = (id, pos, visible) => { + if (visible) dockPanels[pos].push(id); + }; + addPanel('export', panelPositions.export, showExportPanel); + addPanel('ai', panelPositions.ai, showAIPanel); + addPanel('python_tools', panelPositions.python_tools || 'bottom', showPythonToolsPanel); + addPanel('selection', panelPositions.selection, showSelectionPanel); + const closePanel = id => { + if (id === 'export') setShowExportPanel(false);else if (id === 'ai') setShowAIPanel(false);else if (id === 'python_tools') setShowPythonToolsPanel(false);else if (id === 'selection') setShowSelectionPanel(false); + }; + const renderPanelContent = panelId => { + const h = id => e => { + startPanelDrag(id, e); + }; + if (panelId === 'export') return /*#__PURE__*/React.createElement("div", { + className: "flex flex-col h-full gap-1.5" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none", + onMouseDown: e => startPanelDrag('export', e) + }, /*#__PURE__*/React.createElement("h3", { + className: "font-bold text-[10px] text-zinc-200 flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "grip-vertical", + className: "w-3 h-3 text-zinc-500" + })), /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "save", + className: "w-3.5 h-3.5 text-cyan-400" + })), " Export"), /*#__PURE__*/React.createElement("button", { + onClick: () => closePanel('export'), + className: "text-zinc-600 hover:text-zinc-300" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "x", + className: "w-3 h-3" + })))), /*#__PURE__*/React.createElement("div", { + className: "grid grid-cols-3 gap-1" + }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" + }, "SR"), /*#__PURE__*/React.createElement("select", { + value: exportSettings.sampleRate, + onChange: e => setExportSettings(p => ({ + ...p, + sampleRate: e.target.value + })), + className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-[9px] text-zinc-300 focus:outline-none" + }, /*#__PURE__*/React.createElement("option", { + value: "44100" + }, "44.1k"), /*#__PURE__*/React.createElement("option", { + value: "48000" + }, "48k"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" + }, "Bit"), /*#__PURE__*/React.createElement("select", { + value: exportSettings.bitDepth, + onChange: e => setExportSettings(p => ({ + ...p, + bitDepth: e.target.value + })), + className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-[9px] text-zinc-300 focus:outline-none" + }, /*#__PURE__*/React.createElement("option", { + value: "16" + }, "16"), /*#__PURE__*/React.createElement("option", { + value: "24" + }, "24"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { + className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" + }, "Fmt"), /*#__PURE__*/React.createElement("select", { + value: exportSettings.format, + onChange: e => setExportSettings(p => ({ + ...p, + format: e.target.value + })), + className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-[9px] text-zinc-300 focus:outline-none" + }, /*#__PURE__*/React.createElement("option", { + value: "wav" + }, "WAV")))), /*#__PURE__*/React.createElement("button", { + onClick: triggerWavExport, + disabled: isExporting, + className: "w-full py-1 bg-cyan-700 hover:bg-cyan-600 text-zinc-100 font-bold rounded text-[10px] flex items-center justify-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "download-cloud", + className: "w-3 h-3" + })), isExporting ? '...' : 'Export')); + if (panelId === 'ai') return /*#__PURE__*/React.createElement("div", { + className: "flex flex-col h-full gap-1.5" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none", + onMouseDown: e => startPanelDrag('ai', e) + }, /*#__PURE__*/React.createElement("h3", { + className: "font-bold text-[10px] text-zinc-200 flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "grip-vertical", + className: "w-3 h-3 text-zinc-500" + })), /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "cpu", + className: "w-3.5 h-3.5 text-purple-400" + })), " AI"), /*#__PURE__*/React.createElement("button", { + onClick: () => closePanel('ai'), + className: "text-zinc-600 hover:text-zinc-300" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "x", + className: "w-3 h-3" + })))), /*#__PURE__*/React.createElement("div", { + className: "p-1.5 bg-[#141414] rounded border border-zinc-800 text-[9px] font-mono min-h-[24px]" + }, /*#__PURE__*/React.createElement("div", { + className: "text-zinc-500" + }, "// ", /*#__PURE__*/React.createElement("span", { + className: "text-zinc-300" + }, analysisState.status)), analysisState.data && /*#__PURE__*/React.createElement("div", { + className: "text-emerald-500 font-semibold" + }, "BPM: ", analysisState.data.bpm)), /*#__PURE__*/React.createElement("div", { + className: "grid grid-cols-3 gap-1" + }, /*#__PURE__*/React.createElement("button", { + onClick: handleAIScan, + className: "py-1 bg-purple-900 hover:bg-purple-800 text-purple-100 font-bold rounded text-[9px] border border-purple-700 flex items-center justify-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "map-pin", + className: "w-3 h-3" + })), " AI Scan"), /*#__PURE__*/React.createElement("button", { + onClick: handleAICutToNewTrack, + disabled: analysisState.isRunning, + className: "py-1 bg-fuchsia-800 hover:bg-fuchsia-700 text-white font-bold rounded text-[9px] flex items-center justify-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "scissors", + className: "w-3 h-3" + })), " AI Cut"), /*#__PURE__*/React.createElement("button", { + onClick: handleAIAnalysicLoop, + disabled: analysisState.isRunning, + className: "py-1 bg-violet-800 hover:bg-violet-700 text-violet-100 font-bold rounded text-[9px] border border-violet-600 flex items-center justify-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "sparkles", + className: "w-3 h-3" + })), " AI Analysic Loop"))); + if (panelId === 'python_tools') return /*#__PURE__*/React.createElement("div", { + className: "flex flex-col h-full gap-1.5" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none", + onMouseDown: e => startPanelDrag('python_tools', e) + }, /*#__PURE__*/React.createElement("h3", { + className: "font-bold text-[10px] text-amber-300 flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "grip-vertical", + className: "w-3 h-3 text-zinc-500" + })), /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "wrench", + className: "w-3.5 h-3.5 text-amber-400" + })), " Python DSP Tools"), /*#__PURE__*/React.createElement("button", { + onClick: () => closePanel('python_tools'), + className: "text-zinc-600 hover:text-zinc-300" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "x", + className: "w-3 h-3" + })))), /*#__PURE__*/React.createElement("div", { + className: "p-1 bg-[#141414] rounded border border-zinc-800 text-[8px] font-mono text-zinc-400" + }, "// Non-AI Audio Processing Tools"), /*#__PURE__*/React.createElement("div", { + className: "grid grid-cols-2 gap-1 text-[9px]" + }, /*#__PURE__*/React.createElement("button", { + onClick: () => runPythonTool('normalize'), + className: "py-1 bg-amber-950/80 hover:bg-amber-900 text-amber-300 border border-amber-800/80 rounded font-bold flex items-center justify-center gap-1" + }, "⚡ Peak Norm (0dB)"), /*#__PURE__*/React.createElement("button", { + onClick: () => runPythonTool('invert_phase'), + className: "py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 border border-zinc-700 rounded font-bold flex items-center justify-center gap-1" + }, "🔄 Phase Invert"), /*#__PURE__*/React.createElement("button", { + onClick: () => runPythonTool('swap_channels'), + className: "py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 border border-zinc-700 rounded font-bold flex items-center justify-center gap-1" + }, "🔀 Swap L/R"), /*#__PURE__*/React.createElement("button", { + onClick: () => runPythonTool('synth_wave'), + className: "py-1 bg-teal-950/80 hover:bg-teal-900 text-teal-300 border border-teal-800/80 rounded font-bold flex items-center justify-center gap-1" + }, "🎹 Gen Synth Tone"))); + if (panelId === 'selection') return /*#__PURE__*/React.createElement("div", { + className: "flex flex-col h-full gap-1.5" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none", + onMouseDown: e => startPanelDrag('selection', e) + }, /*#__PURE__*/React.createElement("span", { + className: "text-[10px] text-zinc-500 font-bold uppercase flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "grip-vertical", + className: "w-3 h-3 text-zinc-500" + })), " Selection"), /*#__PURE__*/React.createElement("button", { + onClick: () => closePanel('selection'), + className: "text-zinc-600 hover:text-zinc-300" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "x", + className: "w-3 h-3" + })))), /*#__PURE__*/React.createElement("div", { + className: "grid grid-cols-3 gap-1 bg-[#141414] p-1.5 rounded border border-zinc-800" + }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", { + className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" + }, "Start"), /*#__PURE__*/React.createElement("input", { + type: "number", + step: "0.01", + value: selectionStats.start, + onChange: e => handleSelectionInputChange('start', e.target.value), + className: "w-full bg-[#242424] text-amber-400 text-center font-mono text-[9px] rounded py-0.5 border border-zinc-700 focus:outline-none" + })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", { + className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" + }, "End"), /*#__PURE__*/React.createElement("input", { + type: "number", + step: "0.01", + value: selectionStats.end, + onChange: e => handleSelectionInputChange('end', e.target.value), + className: "w-full bg-[#242424] text-amber-400 text-center font-mono text-[9px] rounded py-0.5 border border-zinc-700 focus:outline-none" + })), /*#__PURE__*/React.createElement("div", { + className: "flex flex-col justify-center" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[7px] text-zinc-500 font-bold uppercase" + }, "Len"), /*#__PURE__*/React.createElement("span", { + className: "text-zinc-200 font-mono text-[9px] font-semibold mt-0.5" + }, selectionStats.length, "s"))), /*#__PURE__*/React.createElement("div", { + className: "grid grid-cols-3 gap-1 bg-[#141414] p-1.5 rounded border border-zinc-800 mt-1" + }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", { + className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" + }, "Begin Bar"), /*#__PURE__*/React.createElement("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; + clearLocalSelection(); + setSelectionMode('global'); + setSelectionStart(t); + setSelectionEnd(t + beatDuration * 4); + }, + className: "w-full bg-[#242424] text-white text-center font-mono text-[9px] rounded py-0.5 border border-zinc-700 focus:outline-none" + })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", { + className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" + }, "End Bar"), /*#__PURE__*/React.createElement("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 + beatDuration * 4); + setNumberBar(b - beginBar + 1); + }, + className: "w-full bg-[#242424] text-white text-center font-mono text-[9px] rounded py-0.5 border border-zinc-700 focus:outline-none" + })), /*#__PURE__*/React.createElement("div", { + className: "flex flex-col justify-center" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[7px] text-zinc-500 font-bold uppercase" + }, "# Bars"), /*#__PURE__*/React.createElement("span", { + className: "text-zinc-200 font-mono text-[9px] font-semibold mt-0.5" + }, numberBar)))); + return null; + }; + const renderDock = (pos, title) => { + const panels = dockPanels[pos]; + if (panels.length === 0) return null; + const isSide = pos === 'left' || pos === 'right'; + const sideClass = isSide ? 'w-56 shrink-0 overflow-y-auto border-zinc-800' : 'shrink-0'; + const borderClass = pos === 'left' ? 'border-r' : pos === 'right' ? 'border-l' : pos === 'top' ? 'border-b' : 'border-t'; + const bgClass = 'bg-[#1e1e1e]'; + const highlight = panelDragRef.current && panelDropZone === pos; + return /*#__PURE__*/React.createElement("div", { + className: `${sideClass} ${borderClass} ${bgClass} flex ${isSide ? 'flex-col p-2 gap-2' : 'flex-row p-1.5 gap-3'} select-none ${highlight ? 'ring-2 ring-cyan-500 ring-inset' : ''}` + }, panels.map(p => /*#__PURE__*/React.createElement("div", { + key: p, + className: `${isSide ? 'w-full' : 'flex-1 min-w-0'} border border-zinc-700 rounded-lg bg-[#262626] shadow-sm p-2` + }, renderPanelContent(p)))); + }; + return /*#__PURE__*/React.createElement("div", { + ref: workspaceRef, + className: "flex-1 flex flex-col overflow-hidden select-none daw-bg relative" + }, panelDragRef.current && panelDropZone && /*#__PURE__*/React.createElement("div", { + className: "absolute inset-0 z-50 pointer-events-none" + }, panelDropZone === 'top' && /*#__PURE__*/React.createElement("div", { + className: "absolute top-0 left-0 right-0 h-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]" + }), panelDropZone === 'bottom' && /*#__PURE__*/React.createElement("div", { + className: "absolute bottom-0 left-0 right-0 h-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]" + }), panelDropZone === 'left' && /*#__PURE__*/React.createElement("div", { + className: "absolute top-0 bottom-0 left-0 w-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]" + }), panelDropZone === 'right' && /*#__PURE__*/React.createElement("div", { + className: "absolute top-0 bottom-0 right-0 w-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]" + })), dragGhostPanel && dragGhostPos && /*#__PURE__*/React.createElement("div", { + className: "fixed z-[100] pointer-events-none opacity-80 border border-cyan-500 rounded-lg bg-[#262626] shadow-2xl p-3 w-56", + style: { + left: dragGhostPos.x, + top: dragGhostPos.y + } + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-2 text-[10px] text-zinc-200 font-bold" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "move", + className: "w-3.5 h-3.5 text-cyan-400" + })), dragGhostPanel === 'export' ? 'Export Panel' : dragGhostPanel === 'ai' ? 'AI Panel' : dragGhostPanel === 'python_tools' ? 'Audio Processing Panel' : 'Selection Panel'), /*#__PURE__*/React.createElement("div", { + className: "text-[8px] text-zinc-500 mt-1" + }, "Drop at edge to dock")), renderDock('top', 'Top'), /*#__PURE__*/React.createElement("div", { + className: "flex-1 flex overflow-hidden" + }, renderDock('left', 'Left'), /*#__PURE__*/React.createElement("div", { + className: "flex-1 flex flex-col overflow-hidden min-w-0" + }, /*#__PURE__*/React.createElement("div", { + className: "flex-1 flex overflow-hidden" + }, activeTab === 'main' ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { + ref: tcpContainerRef, + onScroll: handleTCPScroll, + className: "w-[300px] shrink-0 z-20 bg-[#262626] overflow-hidden flex flex-col border-r border-zinc-900", + style: { + scrollbarWidth: 'none', + msOverflowStyle: 'none' + } + }, /*#__PURE__*/React.createElement("div", { + className: "sticky top-0 z-50 flex items-center justify-between h-10 border-b border-zinc-900 bg-[#242424] px-2 shrink-0" + }, /*#__PURE__*/React.createElement("span", { + className: "text-xs font-bold text-zinc-300 flex items-center gap-1.5" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "sliders", + className: "w-3.5 h-3.5 text-cyan-400" + })), "TRACKS (", tracks.length, ")"), /*#__PURE__*/React.createElement("button", { + onClick: addNewTrack, + className: "px-2.5 py-1 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-[11px] font-semibold flex items-center gap-1 shadow transition" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "plus", + className: "w-3 h-3" + })), " Add Track")), /*#__PURE__*/React.createElement("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" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center justify-between w-full" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[10px] font-bold text-purple-400 font-mono" + }, "TM"), /*#__PURE__*/React.createElement("span", { + className: "text-xs font-semibold text-zinc-300" + }, "Tempo")), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1" + }, /*#__PURE__*/React.createElement("input", { + type: "number", + value: bpm, + onChange: e => setBpm(e.target.value), + onBlur: () => localStorage.setItem('studio_bpm', bpm), + className: "w-12 bg-zinc-800 border border-zinc-700 rounded text-[10px] text-zinc-300 text-center font-mono focus:outline-none focus:border-purple-500", + min: "40", + max: "300" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[9px] text-zinc-500" + }, "BPM")))), /*#__PURE__*/React.createElement("div", { + className: "flex-1 flex flex-col divide-y divide-[#141414] bg-[#262626]" + }, tracks.length === 0 ? /*#__PURE__*/React.createElement("div", { + className: "p-6 text-center text-zinc-400 flex flex-col items-center justify-center space-y-3" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "plus-circle", + className: "w-8 h-8 text-cyan-400 opacity-80" + })), /*#__PURE__*/React.createElement("p", { + className: "text-xs font-medium" + }, "Chưa có Track nào trong dự án."), /*#__PURE__*/React.createElement("button", { + onClick: addNewTrack, + className: "px-3 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold flex items-center gap-1.5 shadow" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "plus", + className: "w-3.5 h-3.5" + })), " Thêm Track Mới")) : tracks.map((track, idx) => { + const isSelected = selectedTrackId === track.id; + return /*#__PURE__*/React.createElement("div", { + key: track.id, + style: { + height: `${track.height || 96}px` + }, + className: `shrink-0 relative flex flex-col justify-between p-2.5 bg-[#1e1e1e] border-r border-zinc-900 cursor-pointer border-l-4 border-b border-[#141414] overflow-hidden ${isSelected ? 'border-cyan-500 bg-[#252525]' : 'border-transparent hover:bg-zinc-800/20'}`, + onClick: () => setSelectedTrackId(track.id) + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-start justify-between" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[10px] font-bold text-zinc-500 font-mono" + }, (idx + 1).toString().padStart(2, '0')), /*#__PURE__*/React.createElement("label", { + onClick: e => { + e.stopPropagation(); + const el = e.currentTarget.querySelector('input'); + if (el) el.click(); + }, + className: "cursor-pointer" + }, /*#__PURE__*/React.createElement("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" + }), /*#__PURE__*/React.createElement("div", { + className: "w-3 h-3 rounded-full border border-zinc-600 hover:scale-110 transition-transform", + style: { + backgroundColor: track.color + } + })), editingTrackName === track.id ? /*#__PURE__*/React.createElement("input", { + type: "text", + value: editNameInput, + autoFocus: true, + 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" + }) : /*#__PURE__*/React.createElement("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)), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1" + }, /*#__PURE__*/React.createElement("button", { + onClick: e => { + e.stopPropagation(); + toggleTrackMute(track.id); + }, + className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition ${track.muted ? 'bg-red-950 text-red-400 border-red-700' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}` + }, "M"), /*#__PURE__*/React.createElement("button", { + onClick: e => { + e.stopPropagation(); + toggleTrackSoloEvaluate(track.id); + }, + className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition ${soloedTrackId === track.id || track.solo ? 'bg-amber-950 text-amber-400 border-amber-600' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}` + }, "S"), /*#__PURE__*/React.createElement("button", { + onClick: e => { + e.stopPropagation(); + deleteTrack(track.id); + }, + className: "p-0.5 rounded text-red-500 hover:bg-red-950/40 hover:text-red-400 transition" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "trash-2", + className: "w-3.5 h-3.5" + }))))), /*#__PURE__*/React.createElement("div", { + className: "flex flex-col gap-0.5 text-[10px]", + onClick: e => e.stopPropagation() + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "w-8 text-right text-zinc-500 text-[9px]" + }, "Vol:"), /*#__PURE__*/React.createElement("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' + } + }), /*#__PURE__*/React.createElement("span", { + className: "w-12 text-right font-mono text-zinc-300 text-[9px]" + }, track.volumeDb ?? 0, "dB")), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "w-8 text-right text-zinc-500 text-[9px]" + }, "Pan:"), /*#__PURE__*/React.createElement("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' + } + }), /*#__PURE__*/React.createElement("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'))), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1.5 mt-1", + onClick: e => e.stopPropagation() + }, /*#__PURE__*/React.createElement("input", { + type: "file", + id: `upload-${track.id}`, + accept: "audio/*", + className: "hidden", + onChange: e => loadFileOnTrack(track.id, e.target.files[0]) + }), /*#__PURE__*/React.createElement("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" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "upload", + className: "w-3 h-3" + })), " File"), /*#__PURE__*/React.createElement("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" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "wand-2", + className: "w-3 h-3" + })), " FX: ", /*#__PURE__*/React.createElement("span", { + className: "text-zinc-500 font-normal" + }, "None")), /*#__PURE__*/React.createElement("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" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "music", + className: "w-3 h-3" + })), " Synth: ", /*#__PURE__*/React.createElement("span", { + className: "text-zinc-500 font-normal" + }, "None"))), /*#__PURE__*/React.createElement("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() + })); + })), /*#__PURE__*/React.createElement("div", { + className: "h-[48px] border-r border-zinc-900 bg-[#1e1e1e]/50 shrink-0" + })), /*#__PURE__*/React.createElement("div", { + ref: timelineWrapperRef, + onScroll: handleTimelineScroll, + className: "flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0" + }, /*#__PURE__*/React.createElement("div", { + style: { + width: `${timelineWidth}px` + }, + className: "relative flex flex-col min-h-full" + }, /*#__PURE__*/React.createElement("div", { + className: "sticky top-0 z-45 flex h-10 border-b border-zinc-900 bg-[#242424] shrink-0" + }, /*#__PURE__*/React.createElement("div", { + ref: rulerRef, + className: "flex-1 relative h-full flex items-center select-none cursor-ew-resize overflow-hidden", + onMouseDown: handleRulerMouseDown + }, Array.from({ + length: Math.ceil(maxDuration) + }).map((_, i) => { + const sec = i; + const x = sec * zoom; + return /*#__PURE__*/React.createElement("div", { + key: i, + className: "absolute h-full border-l border-zinc-700 pl-1 pt-1 text-[14px] font-mono text-zinc-300 pointer-events-none", + style: { + left: `${x}px` + } + }, formatTimeSimple(sec)); + }))), selectionMode === 'global' && selLeft !== null && selRight !== null && selRight > selLeft && /*#__PURE__*/React.createElement("div", { + className: "absolute inset-0 pointer-events-none z-20", + style: { + left: `${selLeft * zoom}px`, + width: `${(selRight - selLeft) * zoom}px`, + top: '40px' + } + }, /*#__PURE__*/React.createElement("div", { + className: "w-full h-full bg-amber-500/10", + style: { + borderLeft: '1px solid #f59e0b', + borderRight: '1px solid #f59e0b' + } + })), /*#__PURE__*/React.createElement("div", { + className: "sticky top-10 z-35 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] shrink-0" + }, /*#__PURE__*/React.createElement("div", { + className: "flex-1 relative h-full overflow-hidden bg-[#1a1a2e]" + }, /*#__PURE__*/React.createElement(TempoTrackLane, { + bpm: parseInt(bpm) || 120, + zoom: zoom, + timelineWidth: timelineWidth, + viewportWidth: viewportWidth, + onPlayheadSet: handlePlayheadSet, + snapValue: snapValue, + onRulerMouseDown: handleRulerMouseDown + }))), /*#__PURE__*/React.createElement("div", { + className: "flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full" + }, tracks.map((track, idx) => { + const isSelected = selectedTrackId === track.id; + return /*#__PURE__*/React.createElement("div", { + key: track.id, + style: { + height: `${track.height || 96}px` + }, + className: `shrink-0 relative overflow-hidden border-b border-[#141414] hover:bg-zinc-850/5 transition-colors ${isSelected ? 'bg-zinc-800/10' : ''}`, + onDragOver: e => e.preventDefault(), + onDrop: e => { + e.preventDefault(); + if (e.dataTransfer.files[0]) loadFileOnTrack(track.id, e.dataTransfer.files[0]); + }, + onMouseEnter: () => setHoveredTrackId(track.id) + }, /*#__PURE__*/React.createElement(WaveformLane, { + track: track, + zoom: zoom, + timelineWidth: timelineWidth, + onSelectRange: handleSelectRange, + onPlayheadSet: handlePlayheadSet, + isSelected: isSelected, + onSelectTrack: setSelectedTrackId, + markers: track.markers, + onTrackLaneMouseDown: handleTrackLaneMouseDown, + onContextMenu: handleContextMenu, + onClipDragStart: handleClipDragStart, + onClipStretchStart: handleClipStretchStart, + onSelectionEdgeDragStart: handleSelectionEdgeDragStart, + setSelectedClipId: setSelectedClipId, + selectedClipId: selectedClipId, + activeTool: activeTool, + onSplitTrackAtTime: handleSplitTrackAtTime, + onEditClipInSubTab: handleEditClipInSubTab, + snapValue: snapValue, + bpm: bpm, + selectionMode: selectionMode, + localSelectionTrackId: localSelectionTrackId, + localSelectionStart: localSelectionStart, + currentTime: currentTime, + getLocalAnchor: () => localSelectionAnchorRef.current, + onClearLocalSelection: clearLocalSelection, + onSetSelectionMode: setSelectionMode, + onSetSelectionStart: setSelectionStart, + onSetSelectionEnd: setSelectionEnd, + onSetCurrentTime: setCurrentTime, + onSetLocalSelectionTrackId: setLocalSelectionTrackId, + onSetLocalSelectionStart: setLocalSelectionStart, + onSetLocalSelectionEnd: setLocalSelectionEnd, + localSelLeft: localSelectionStart !== null && localSelectionEnd !== null ? Math.min(localSelectionStart, localSelectionEnd) : null, + localSelRight: localSelectionStart !== null && localSelectionEnd !== null ? Math.max(localSelectionStart, localSelectionEnd) : null + }), selectionMode === 'local' && localSelectionTrackId === track.id && localSelectionStart !== null && localSelectionEnd !== null && Math.abs(localSelectionEnd - localSelectionStart) > 0 && /*#__PURE__*/React.createElement("div", { + className: "absolute top-0 bottom-0 border-l border-r border-amber-400 bg-amber-500/20 z-20 pointer-events-none", + style: { + left: `${Math.min(localSelectionStart, localSelectionEnd) * zoom}px`, + width: `${Math.abs(localSelectionEnd - localSelectionStart) * zoom}px` + } + }, /*#__PURE__*/React.createElement("div", { + className: "absolute -left-[1px] top-0 bottom-0 w-[2px] bg-amber-400 z-30 pointer-events-auto cursor-ew-resize", + onMouseDown: e => handleHandleDragStart(e, 'left') + }), /*#__PURE__*/React.createElement("div", { + className: "absolute -right-[1px] top-0 bottom-0 w-[2px] bg-amber-400 z-30 pointer-events-auto cursor-ew-resize", + onMouseDown: e => handleHandleDragStart(e, 'right') + })), track.buffer && /*#__PURE__*/React.createElement("div", { + className: "absolute right-2 top-2 flex items-center gap-1 z-10 opacity-70 hover:opacity-100" + }, /*#__PURE__*/React.createElement("button", { + onClick: () => handleSplitTrack(track.id), + className: "px-1.5 py-0.5 bg-zinc-900/95 text-zinc-300 rounded text-[9px] flex items-center gap-1 border border-zinc-700/50" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "scissors", + className: "w-2.5 h-2.5 text-cyan-400" + })), " Cắt")), /*#__PURE__*/React.createElement("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() + })); + }), /*#__PURE__*/React.createElement("div", { + className: "h-[48px] flex items-center justify-center text-xs text-zinc-500 hover:bg-zinc-900/20 cursor-pointer select-none border-t border-dashed border-zinc-800", + onMouseEnter: () => { + if (draggedClipRef.current) { + setHoveredTrackId(addNewTrack()); + } + }, + onClick: addNewTrack + }, /*#__PURE__*/React.createElement("span", { + className: "flex items-center gap-1 text-zinc-400" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "plus", + className: "w-3.5 h-3.5" + })), " Kéo clip xuống hoặc Click tạo Track")), selectionMode === 'global' && selLeft !== null && selRight !== null && selRight > selLeft && /*#__PURE__*/React.createElement("div", { + className: "absolute top-0 bottom-0 border-l border-r border-amber-500 bg-amber-500/10 z-20 selection-interactive-box cursor-grab active:cursor-grabbing", + style: { + left: `${selLeft * zoom}px`, + width: `${(selRight - selLeft) * zoom}px` + }, + onMouseDown: handleSelectionBodyDragStart + }, /*#__PURE__*/React.createElement("div", { + className: "absolute -left-[1px] top-0 bottom-0 w-[1px] bg-amber-500 hover:bg-amber-400 cursor-ew-resize z-30", + onMouseDown: e => handleHandleDragStart(e, 'left') + }), /*#__PURE__*/React.createElement("div", { + className: "absolute -right-[1px] top-0 bottom-0 w-[1px] bg-amber-500 hover:bg-amber-400 cursor-ew-resize z-30", + onMouseDown: e => handleHandleDragStart(e, 'right') + })), /*#__PURE__*/React.createElement("div", { + className: "absolute top-0 bottom-0 w-[2px] bg-[#ef4444] z-20 pointer-events-none", + style: { + left: `${playheadLeftPos}px` + } + }, /*#__PURE__*/React.createElement("div", { + className: "w-3 h-3 bg-[#ef4444] rotate-45 transform -translate-x-1/2 -translate-y-1/2 absolute top-0" + })))))) : (() => { + 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 /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { + className: "w-[320px] shrink-0 z-30 bg-[#262626] overflow-y-scroll no-scrollbar flex flex-col border-r border-zinc-900", + style: { + scrollbarWidth: 'none', + msOverflowStyle: 'none' + } + }, /*#__PURE__*/React.createElement("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" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[10px] font-bold text-zinc-500 uppercase" + }, "Sub-Tab"), /*#__PURE__*/React.createElement("button", { + onClick: () => closeSubTab(st.id), + className: "px-1.5 py-0.5 text-[9px] bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded border border-zinc-700 flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "x", + className: "w-3 h-3" + })), " Close")), vTrack ? /*#__PURE__*/React.createElement("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" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center justify-between mb-2" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-2" + }, /*#__PURE__*/React.createElement("label", { + onClick: e => { + e.stopPropagation(); + const el = e.currentTarget.querySelector('input'); + if (el) el.click(); + }, + className: "cursor-pointer" + }, /*#__PURE__*/React.createElement("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" + }), /*#__PURE__*/React.createElement("div", { + className: "w-3 h-3 rounded-full border border-zinc-600 hover:scale-110 transition-transform", + style: { + backgroundColor: vTrack.color + } + })), editingTrackName === vTrack.id ? /*#__PURE__*/React.createElement("input", { + type: "text", + value: editNameInput, + autoFocus: true, + 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" + }) : /*#__PURE__*/React.createElement("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)), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1" + }, /*#__PURE__*/React.createElement("button", { + onClick: e => { + e.stopPropagation(); + toggleTrackMute(vTrack.id); + }, + className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border ${vTrack.muted ? 'bg-red-950 text-red-400 border-red-700' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}` + }, "M"), /*#__PURE__*/React.createElement("button", { + onClick: e => { + e.stopPropagation(); + toggleTrackSoloEvaluate(vTrack.id); + }, + 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"))), /*#__PURE__*/React.createElement("div", { + className: "flex flex-col gap-2.5 text-[14px] mb-3" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "w-10 text-right text-zinc-500 text-[14px]" + }, "Vol:"), /*#__PURE__*/React.createElement("input", { + type: "range", + min: "-50", + max: "7", + step: "0.5", + id: `tcp-vol-${st.id}`, + 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' + } + }), /*#__PURE__*/React.createElement("span", { + className: "w-16 text-right font-mono text-zinc-300 text-[14px]", + id: `tcp-vol-label-${st.id}` + }, vTrack.volumeDb ?? 0, "dB")), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "w-10 text-right text-zinc-500 text-[14px]" + }, "Pan:"), /*#__PURE__*/React.createElement("input", { + type: "range", + min: "-100", + max: "100", + step: "1", + id: `tcp-pan-${st.id}`, + 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' + } + }), /*#__PURE__*/React.createElement("span", { + className: "w-16 text-right font-mono text-zinc-300 text-[14px]", + id: `tcp-pan-label-detailed-${st.id}` + }, vTrack.pan > 0 ? 'R' + vTrack.pan : vTrack.pan < 0 ? 'L' + Math.abs(vTrack.pan) : 'C')), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-1.5 text-[14px]" + }, /*#__PURE__*/React.createElement("button", { + onClick: () => setSubTabs(prev => prev.map(s => s.id === st.id ? { + ...s, + isLooping: !s.isLooping + } : s)), + className: `px-2 py-0.5 text-[14px] rounded border font-bold ${st.isLooping ? 'bg-amber-700 text-white border-amber-500' : 'bg-zinc-800 text-zinc-400 border-zinc-700'}` + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "repeat", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("button", { + onClick: () => updateSubTabEffects(st.id, { + reverse: !(st.effects || {}).reverse + }), + className: `px-2 py-0.5 rounded border text-[14px] font-bold ${(st.effects || {}).reverse ? 'bg-zinc-600 text-white border-zinc-500' : 'bg-zinc-800 text-zinc-400 border-zinc-700'} flex items-center justify-center`, + title: "Reverse" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "arrow-left-right", + className: "w-3.5 h-3.5" + }))), /*#__PURE__*/React.createElement("span", { + className: "w-10 text-right text-zinc-500 text-[14px]" + }, "Loop:"), /*#__PURE__*/React.createElement("input", { + type: "number", + min: "0", + max: "999", + value: st.loopCount || 0, + onChange: e => setSubTabs(prev => prev.map(s => s.id === st.id ? { + ...s, + loopCount: Math.max(0, parseInt(e.target.value) || 0) + } : s)), + className: "w-12 bg-black text-amber-300 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono", + title: "Loop count" + }))), /*#__PURE__*/React.createElement("div", { + className: "flex gap-1 justify-between my-2.5" + }, /*#__PURE__*/React.createElement("div", { + className: "flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[14px] font-bold text-zinc-400 uppercase tracking-tighter", + title: "Normalize" + }, "Norm"), /*#__PURE__*/React.createElement("input", { + type: "range", + min: "-12", + max: "0", + step: "0.1", + value: subTabNormVal, + onChange: e => setSubTabNormVal(parseFloat(e.target.value)), + style: { + writingMode: 'vertical-lr', + direction: 'rtl', + height: '120px', + width: '20px', + accentColor: '#a1a1aa' + }, + className: "my-2 cursor-pointer" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[14px] font-mono text-zinc-300 font-bold" + }, subTabNormVal, "dB"), /*#__PURE__*/React.createElement("button", { + onClick: () => applySubTabEffect(st.id, 'normalize', subTabNormVal), + className: "mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650" + }, "Apply")), /*#__PURE__*/React.createElement("div", { + className: "flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[14px] font-bold text-zinc-400 uppercase tracking-tighter", + title: "Pitch Shift" + }, "Pitch"), /*#__PURE__*/React.createElement("input", { + type: "range", + min: "-12", + max: "12", + step: "0.5", + value: subTabPitchVal, + onChange: e => setSubTabPitchVal(parseFloat(e.target.value)), + style: { + writingMode: 'vertical-lr', + direction: 'rtl', + height: '120px', + width: '20px', + accentColor: '#a1a1aa' + }, + className: "my-2 cursor-pointer" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[14px] font-mono text-zinc-300 font-bold" + }, subTabPitchVal > 0 ? '+' : '', subTabPitchVal, "st"), /*#__PURE__*/React.createElement("button", { + onClick: () => applySubTabEffect(st.id, 'pitch', subTabPitchVal), + className: "mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650" + }, "Apply")), /*#__PURE__*/React.createElement("div", { + className: "flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1" + }, /*#__PURE__*/React.createElement("span", { + className: "text-[14px] font-bold text-zinc-400 uppercase tracking-tighter", + title: "Gain Multiplier" + }, "Gain"), /*#__PURE__*/React.createElement("input", { + type: "range", + min: "0", + max: "150", + step: "1", + value: subTabGainVal, + onChange: e => setSubTabGainVal(parseInt(e.target.value)), + style: { + writingMode: 'vertical-lr', + direction: 'rtl', + height: '120px', + width: '20px', + accentColor: '#a1a1aa' + }, + className: "my-2 cursor-pointer" + }), /*#__PURE__*/React.createElement("span", { + className: "text-[14px] font-mono text-zinc-300 font-bold" + }, subTabGainVal, "%"), /*#__PURE__*/React.createElement("button", { + onClick: () => applySubTabEffect(st.id, 'gain', subTabGainVal), + className: "mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650" + }, "Apply"))), /*#__PURE__*/React.createElement("div", { + className: "flex-1" + }), /*#__PURE__*/React.createElement("div", { + className: "mt-auto pt-2.5 border-t border-zinc-800" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center justify-between text-[14px] text-zinc-500 mb-1 font-semibold uppercase" + }, /*#__PURE__*/React.createElement("span", null, "Duration:"), /*#__PURE__*/React.createElement("span", { + className: "font-mono text-zinc-300" + }, st.buffer ? formatTime(st.buffer.duration) : '0s')), /*#__PURE__*/React.createElement("div", { + className: "flex items-center justify-between text-[14px] text-zinc-500 mb-2.5 font-semibold uppercase" + }, /*#__PURE__*/React.createElement("span", null, "SR:"), /*#__PURE__*/React.createElement("span", { + className: "font-mono text-zinc-300" + }, st.buffer ? st.buffer.sampleRate : 0, " Hz")), /*#__PURE__*/React.createElement("div", { + className: "grid grid-cols-2 gap-1 mb-2" + }, /*#__PURE__*/React.createElement("button", { + onClick: async e => { + subTabAiTrackIdRef.current = st.trackId; + handleAIScan(); + }, + disabled: analysisState.isRunning, + className: "py-1 bg-purple-900 hover:bg-purple-800 text-purple-100 font-bold rounded text-[12px] border border-purple-700 flex items-center justify-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "map-pin", + className: "w-3 h-3" + })), " Scan"), /*#__PURE__*/React.createElement("button", { + onClick: async e => { + subTabAiTrackIdRef.current = st.trackId; + handleAICutToNewTrack(); + }, + disabled: analysisState.isRunning, + className: "py-1 bg-fuchsia-800 hover:bg-fuchsia-700 text-white font-bold rounded text-[12px] flex items-center justify-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "scissors", + className: "w-3 h-3" + })), " Cut"), /*#__PURE__*/React.createElement("button", { + onClick: async e => { + subTabAiTrackIdRef.current = st.trackId; + if (st.buffer) { + setSelectionRangeOnBuffer(st.buffer, st.selectionStart || 0, st.selectionEnd || st.buffer.duration); + } + handleAIAnalysicLoop(); + }, + disabled: analysisState.isRunning, + className: "py-1 bg-violet-800 hover:bg-violet-700 text-violet-100 font-bold rounded text-[12px] border border-violet-600 flex items-center justify-center gap-1 col-span-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "sparkles", + className: "w-3 h-3" + })), " AI Analysic Loop")), /*#__PURE__*/React.createElement("button", { + onClick: () => exportSubTabBuffer(st.id), + className: "w-full py-1.5 mb-1.5 bg-blue-700 hover:bg-blue-600 text-white font-bold rounded text-[14px] flex items-center justify-center gap-1 transition" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "download", + className: "w-4 h-4" + })), " Export"), /*#__PURE__*/React.createElement("button", { + onClick: () => applySubTab(st.id), + className: "w-full py-1.5 bg-emerald-700 hover:bg-emerald-600 text-white font-bold rounded text-[14px] flex items-center justify-center gap-1 transition" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "save", + className: "w-4 h-4" + })), " Save"))) : /*#__PURE__*/React.createElement("div", { + className: "flex-1 flex items-center justify-center text-[10px] text-zinc-500" + }, "Track not found")), /*#__PURE__*/React.createElement("div", { + ref: timelineWrapperRef, + className: "flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0" + }, /*#__PURE__*/React.createElement("div", { + style: { + width: `${subTabTimelineWidth}px` + }, + className: "relative flex flex-col min-h-full" + }, /*#__PURE__*/React.createElement("div", { + className: "sticky top-0 z-45 flex h-10 border-b border-zinc-900 bg-[#242424] shrink-0" + }, /*#__PURE__*/React.createElement("div", { + className: "flex-1 relative h-full flex items-center select-none cursor-ew-resize overflow-hidden", + onMouseDown: handleRulerMouseDown + }, Array.from({ + length: Math.ceil(st.buffer ? st.buffer.duration : 0) + }).map((_, i) => { + const sec = i; + const x = sec * zoom; + return /*#__PURE__*/React.createElement("div", { + key: i, + className: "absolute h-full border-l border-zinc-700 pl-1 pt-1 text-[14px] font-mono text-zinc-300 pointer-events-none", + style: { + left: `${x}px` + } + }, formatTimeSimple(sec)); + }))), /*#__PURE__*/React.createElement("div", { + className: "sticky top-10 z-35 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] shrink-0" + }, /*#__PURE__*/React.createElement("div", { + className: "flex-1 relative h-full overflow-hidden bg-[#1a1a2e]" + }, /*#__PURE__*/React.createElement(TempoTrackLane, { + bpm: parseInt(bpm) || 120, + zoom: zoom, + timelineWidth: subTabTimelineWidth, + onPlayheadSet: setCurrentTime, + snapValue: snapValue + }))), /*#__PURE__*/React.createElement("div", { + className: "flex-1 flex flex-col relative bg-[#111111] min-h-full" + }, vTrack && /*#__PURE__*/React.createElement("div", { + style: { + height: `${subTabHeight}px` + }, + className: "relative overflow-hidden border-2 border-purple-500/40 rounded bg-[#151515] mx-1 mt-1 shrink-0" + }, /*#__PURE__*/React.createElement(SubTabWaveform, { + buffer: st.buffer, + subTabId: st.id, + activeTab: activeTab, + activeTool: activeTool, + currentTime: st.currentTime, + selectionStart: st.selectionStart, + selectionEnd: st.selectionEnd, + 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, + color: vTrack.color, + name: vTrack.name, + speed: st.speed || 1.0, + volumeNodes: st.volumeNodes || [], + panningNodes: st.panningNodes || [], + fadeInLen: st.fadeInLen || 0, + fadeOutLen: st.fadeOutLen || 0, + graphMode: st.graphMode, + channelInfo: st.channelInfo, + selectedNodeTime: subTabSelectedNodeTime, + setSelectedNodeTime: setSubTabSelectedNodeTime, + onUpdateNodes: nodes => setSubTabs(prev => prev.map(s => s.id === st.id ? { + ...s, + [s.graphMode === 'pan' ? 'panningNodes' : 'volumeNodes']: nodes + } : s)), + onUpdateFade: fade => setSubTabs(prev => prev.map(s => s.id === st.id ? { + ...s, + fadeInLen: fade.fadeInLen ?? s.fadeInLen, + fadeOutLen: fade.fadeOutLen ?? s.fadeOutLen + } : s)), + onModeToggle: () => setSubTabs(prev => prev.map(s => s.id === st.id ? { + ...s, + graphMode: s.graphMode === 'pan' ? null : 'pan' + } : s)), + onSpeedChange: newSpeed => { + setSubTabs(prev => prev.map(s => { + if (s.id !== st.id) return s; + const oldSpeed = s.speed || 1.0; + const ratio = oldSpeed / newSpeed; + const newVolumeNodes = (s.volumeNodes || []).map(n => ({ + ...n, + time: n.time * ratio + })); + const newPanningNodes = (s.panningNodes || []).map(n => ({ + ...n, + time: n.time * ratio + })); + return { + ...s, + speed: newSpeed, + volumeNodes: newVolumeNodes, + panningNodes: newPanningNodes, + fadeInLen: (s.fadeInLen || 0) * ratio, + fadeOutLen: (s.fadeOutLen || 0) * ratio, + currentTime: (s.currentTime || 0) * ratio, + label: s.label.replace(/\s\(\d+%\)$/, '') + ` (${Math.round(newSpeed * 100)}%)` + }; + })); + const n = activeTrackNodesRef.current[st.trackId]; + if (n && n.source) n.source.playbackRate.value = newSpeed; + // Reset time refs to prevent playhead jump when speed changes mid-playback + const ctx = getAudioContext(); + const elapsed = ctx.currentTime - startAudioTimeRef.current; + const oldSpeed = activePlaybackSpeedRef.current; + const ratio = oldSpeed / newSpeed; + startBufferOffsetRef.current = startBufferOffsetRef.current + elapsed * oldSpeed; + startOffsetTimeRef.current = (startOffsetTimeRef.current + elapsed) * ratio; + startAudioTimeRef.current = ctx.currentTime; + activePlaybackSpeedRef.current = newSpeed; + } + }), /*#__PURE__*/React.createElement("div", { + onMouseDown: handleSubTabResizeMouseDown, + className: "absolute bottom-0 left-0 right-0 h-1.5 cursor-ns-resize z-30 hover:bg-purple-500/50 transition-colors" + })), st.selectionStart !== null && st.selectionEnd !== null && st.selectionEnd > st.selectionStart && /*#__PURE__*/React.createElement("div", { + className: "absolute top-0 bottom-0 border-l border-r border-amber-500 bg-amber-500/10 z-20 pointer-events-none", + style: { + left: `${Math.min(st.selectionStart, st.selectionEnd) * zoom}px`, + width: `${Math.abs(st.selectionEnd - st.selectionStart) * zoom}px` + } + }))))); + })())), renderDock('right', 'Right')), renderDock('bottom', 'Bottom')); + })(), /*#__PURE__*/React.createElement("div", { + className: "h-6 bg-[#141414] border-t border-zinc-900 px-4 flex items-center justify-between text-[10px] text-zinc-500 select-none shrink-0" + }, /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-4" + }, /*#__PURE__*/React.createElement("span", null, "Status: ", isPlaying ? 'Playing' : 'Stopped'), activeTab !== 'main' && /*#__PURE__*/React.createElement("span", { + className: "text-amber-400 font-semibold uppercase" + }, "Sub-Tab"), activeTab === 'main' && /*#__PURE__*/React.createElement("span", { + className: "text-cyan-400 font-semibold uppercase" + }, "Track: ID ", selectedTrackId), selectionMode === 'local' && /*#__PURE__*/React.createElement("span", { + className: "text-amber-400 font-semibold uppercase text-[9px]" + }, "Local Sel"), selectionMode === 'global' && /*#__PURE__*/React.createElement("span", { + className: "text-purple-400 font-semibold uppercase text-[9px]" + }, "Global Sel"), soloedTrackId && /*#__PURE__*/React.createElement("span", { + className: "text-amber-500 font-semibold" + }, "Solo: ID ", soloedTrackId), isLoopingSelection && selectionMode === 'local' && /*#__PURE__*/React.createElement("span", { + className: "text-emerald-400 font-semibold uppercase text-[9px]" + }, "Solo Loop"), isLoopingSelection && selectionMode !== 'local' && /*#__PURE__*/React.createElement("span", { + className: "text-cyan-400 font-semibold uppercase text-[9px]" + }, "Master Loop")), /*#__PURE__*/React.createElement("div", { + className: "flex items-center gap-2" + }, /*#__PURE__*/React.createElement("button", { + onClick: () => setShowExportPanel(p => !p), + className: `px-1.5 py-0.5 rounded text-[9px] font-semibold transition flex items-center gap-1 ${showExportPanel ? 'bg-cyan-900 text-cyan-300' : 'text-zinc-500 hover:text-zinc-300'}`, + title: `Export Panel (${panelPositions.export})` + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "save", + className: "w-3 h-3" + })), /*#__PURE__*/React.createElement("span", { + className: "text-[7px] opacity-60" + }, showExportPanel ? panelPositions.export[0].toUpperCase() : '')), /*#__PURE__*/React.createElement("button", { + onClick: () => setShowSelectionPanel(p => !p), + className: `px-1.5 py-0.5 rounded text-[9px] font-semibold transition flex items-center gap-1 ${showSelectionPanel ? 'bg-amber-900 text-amber-300' : 'text-zinc-500 hover:text-zinc-300'}`, + title: `Selection Panel (${panelPositions.selection})` + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "sliders", + className: "w-3 h-3" + })), /*#__PURE__*/React.createElement("span", { + className: "text-[7px] opacity-60" + }, showSelectionPanel ? panelPositions.selection[0].toUpperCase() : '')), /*#__PURE__*/React.createElement("button", { + onClick: () => setShowAIPanel(p => !p), + className: `px-1.5 py-0.5 rounded text-[9px] font-semibold transition flex items-center gap-1 ${showAIPanel ? 'bg-purple-900 text-purple-300' : 'text-zinc-500 hover:text-zinc-300'}`, + title: `AI Panel (${panelPositions.ai})` + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "cpu", + className: "w-3 h-3" + })), /*#__PURE__*/React.createElement("span", { + className: "text-[7px] opacity-60" + }, showAIPanel ? panelPositions.ai[0].toUpperCase() : '')), /*#__PURE__*/React.createElement("span", { + className: "w-[1px] h-3 bg-zinc-800 mx-1" + }), /*#__PURE__*/React.createElement("span", { + className: "flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "info", + className: "w-3 h-3 text-zinc-600" + })), " Scroll: Zoom"), /*#__PURE__*/React.createElement("span", null, "|"), /*#__PURE__*/React.createElement("span", { + className: "flex items-center gap-1" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "keyboard", + className: "w-3 h-3 text-zinc-600" + })), " Ctrl+Scroll: Playhead"))), contextMenu && (contextMenu.isSubTab ? /*#__PURE__*/React.createElement("div", { + className: "fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64", + style: { + left: contextMenu.x, + top: contextMenu.y + }, + onClick: e => e.stopPropagation() + }, /*#__PURE__*/React.createElement("div", { + className: "px-3 py-1 text-[10px] text-zinc-500 font-bold uppercase border-b border-zinc-800 pb-1 mb-1" + }, "Selection: ", formatTime(subTabs.find(s => s.id === contextMenu.subTabId)?.selectionStart || 0), " - ", formatTime(subTabs.find(s => s.id === contextMenu.subTabId)?.selectionEnd || 0)), /*#__PURE__*/React.createElement("div", { + className: "h-px bg-zinc-700 my-1" + }), /*#__PURE__*/React.createElement("button", { + onClick: () => { + handleSubTabCut(contextMenu.subTabId); + closeContextMenu(); + }, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "scissors", + className: "w-3.5 h-3.5 text-rose-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Cut"), /*#__PURE__*/React.createElement("span", { + className: "text-purple-400 text-[12px] font-semibold font-mono ml-auto" + }, "Ctrl+X")), /*#__PURE__*/React.createElement("button", { + onClick: () => { + handleSubTabCopy(contextMenu.subTabId); + closeContextMenu(); + }, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "copy", + className: "w-3.5 h-3.5 text-zinc-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Copy"), /*#__PURE__*/React.createElement("span", { + className: "text-purple-400 text-[12px] font-semibold font-mono ml-auto" + }, "Ctrl+C")), /*#__PURE__*/React.createElement("button", { + onClick: () => { + handleSubTabPaste(contextMenu.subTabId); + closeContextMenu(); + }, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "clipboard", + className: "w-3.5 h-3.5 text-emerald-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Paste"), /*#__PURE__*/React.createElement("span", { + className: "text-purple-400 text-[12px] font-semibold font-mono ml-auto" + }, "Ctrl+V")), /*#__PURE__*/React.createElement("button", { + onClick: () => { + handleSubTabDelete(contextMenu.subTabId); + closeContextMenu(); + }, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "trash-2", + className: "w-3.5 h-3.5 text-red-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Delete Selected Segment"), /*#__PURE__*/React.createElement("span", { + className: "text-amber-400 text-[12px] font-semibold font-mono ml-auto" + }, "Del")), /*#__PURE__*/React.createElement("div", { + className: "h-px bg-zinc-700 my-1" + }), /*#__PURE__*/React.createElement("button", { + onClick: () => { + handleSubTabLoop(contextMenu.subTabId, 4); + closeContextMenu(); + }, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "repeat", + className: "w-3.5 h-3.5 text-cyan-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Loop Selection 4 times"), /*#__PURE__*/React.createElement("span", { + className: "text-purple-400 text-[12px] font-semibold font-mono ml-auto" + }, "Ctrl+L"))) : /*#__PURE__*/React.createElement("div", { + className: "fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64", + style: { + left: contextMenu.x, + top: contextMenu.y + }, + onClick: e => e.stopPropagation() + }, /*#__PURE__*/React.createElement("button", { + onClick: contextMenuEdit, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "file-edit", + className: "w-3.5 h-3.5 text-amber-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Edit"), /*#__PURE__*/React.createElement("span", { + className: "text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8" + }, "Ctrl+E")), /*#__PURE__*/React.createElement("button", { + onClick: contextMenuSplit, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "scissors", + className: "w-3.5 h-3.5 text-cyan-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Split"), /*#__PURE__*/React.createElement("span", { + className: "text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8" + }, "S")), /*#__PURE__*/React.createElement("button", { + onClick: contextMenuMerge, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "combine", + className: "w-3.5 h-3.5 text-purple-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Merge"), /*#__PURE__*/React.createElement("span", { + className: "text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8" + }, "Ctrl+M")), /*#__PURE__*/React.createElement("div", { + className: "h-px bg-zinc-700 my-1" + }), /*#__PURE__*/React.createElement("button", { + onClick: contextMenuCopy, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "copy", + className: "w-3.5 h-3.5 text-zinc-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Copy"), /*#__PURE__*/React.createElement("span", { + className: "text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8" + }, "Ctrl+C")), /*#__PURE__*/React.createElement("button", { + onClick: contextMenuCut, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "scissors", + className: "w-3.5 h-3.5 text-rose-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Cut"), /*#__PURE__*/React.createElement("span", { + className: "text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8" + }, "Ctrl+X")), /*#__PURE__*/React.createElement("button", { + onClick: contextMenuPaste, + className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "clipboard", + className: "w-3.5 h-3.5 text-emerald-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Paste"), /*#__PURE__*/React.createElement("span", { + className: "text-purple-400 text-[12px] font-semibold font-mono ml-auto pl-8" + }, "Ctrl+V")), /*#__PURE__*/React.createElement("div", { + className: "h-px bg-zinc-700 my-1" + }), /*#__PURE__*/React.createElement("button", { + onClick: contextMenuDelete, + className: "w-full px-3 py-1.5 text-xs text-red-300 hover:bg-red-950 text-left flex items-center gap-2" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "trash-2", + className: "w-3.5 h-3.5 text-red-400 shrink-0" + })), /*#__PURE__*/React.createElement("span", { + className: "flex-1" + }, "Delete"), /*#__PURE__*/React.createElement("span", { + className: "text-amber-400 text-[12px] font-semibold font-mono ml-auto pl-8" + }, "Del")))), toastMessage && /*#__PURE__*/React.createElement("div", { + className: "absolute top-14 right-4 z-50 px-4 py-2.5 rounded shadow-lg text-xs font-semibold flex items-center gap-2 border bg-zinc-900 text-zinc-100 border-zinc-800" + }, /*#__PURE__*/React.createElement("span", { + className: "inline-flex items-center shrink-0" + }, /*#__PURE__*/React.createElement("i", { + "data-lucide": "bell", + className: `w-4 h-4 ${toastMessage.type === 'success' ? 'text-emerald-400' : toastMessage.type === 'error' ? 'text-rose-400' : toastMessage.type === 'warning' ? 'text-amber-400' : 'text-cyan-400'}` + })), toastMessage.text), /*#__PURE__*/React.createElement(AuthModal, { + isOpen: authModalOpen, + mode: authMode, + forceMandatory: isMandatoryLogin, + onClose: () => setAuthModalOpen(false), + onSuccess: handleAuthSuccess + }), /*#__PURE__*/React.createElement(ProfileModal, { + isOpen: profileModalOpen, + onClose: () => setProfileModalOpen(false) + }), /*#__PURE__*/React.createElement(AIConfigModal, { + isOpen: aiConfigModalOpen, + onClose: () => setAiConfigModalOpen(false) + }), /*#__PURE__*/React.createElement(SystemManagerModal, { + isOpen: systemManagerModalOpen, + onClose: () => setSystemManagerModalOpen(false) + })); +}; +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render(/*#__PURE__*/React.createElement(App, null)); +setTimeout(() => lucide.createIcons(), 300); diff --git a/app/storage/sonicforge.db b/app/storage/sonicforge.db index dfd890a..79493fe 100644 Binary files a/app/storage/sonicforge.db and b/app/storage/sonicforge.db differ diff --git a/app/templates/index.html b/app/templates/index.html index f38af16..f67c219 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -9,10 +9,10 @@ - +