From 83dc97e788ce18fa5fa545e7258e937456642fa7 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Mon, 20 Jul 2026 20:22:48 +0700 Subject: [PATCH] fix: some bugs --- app/static/js/app.jsx | 7636 ++++++++++++++++++++++++++++++++++++ app/storage/sonicforge.db | Bin 45056 -> 45056 bytes app/templates/index.html | 7637 +------------------------------------ 3 files changed, 7637 insertions(+), 7636 deletions(-) create mode 100644 app/static/js/app.jsx diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx new file mode 100644 index 0000000..b3f3f19 --- /dev/null +++ b/app/static/js/app.jsx @@ -0,0 +1,7636 @@ + + 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 ( +
+ + + + + + +
+ ); + }; + + 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 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 ( +
+ { + 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; + 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 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', + }); + 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); + }; + + // ── 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); + 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 ( +
+ {/* ── 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)} + /> +
+ ); + }; + + const root = ReactDOM.createRoot(document.getElementById('root')); + root.render(); + setTimeout(() => lucide.createIcons(), 300); + \ No newline at end of file diff --git a/app/storage/sonicforge.db b/app/storage/sonicforge.db index fb4231a83bbbc91b59c03ade5082ec65a4977a42..dfd890a0f7a6d5d32176b9289e1620da6104fa8b 100644 GIT binary patch delta 1490 zcmbtUJ#W)c6ir;D(pHsQmI?)x$WVbq65jK-!$7N2q@t#kXfhyFg=hOYF?DRqak^Ad z+Nte;0A=70Fd|hnFtPL>z|7PMF?A^Y0bIL@ngJvW7Tf3Ddynru@4UBj!t9(dZ!&52 z?Ty7W`|*W8ds|ivUKUK87Y(B3MWV=gmGC%E6+sat-n4XH?4HlMorAfBFS|48@yg7D z-N~7&qhma#NE0NSR}oL4(n5I+%OsC=qHC50Bo-oO!*=7xeA9Dn!}5X}Ei}WHX?s}0 zRFO2%l2lniI;MgqsJdYByr7v1qEwTSWyri{V9V5RVr=P#DIlcsR7HxYSuyXz_jh~y zpS!C^*&bU;G7NKPq>`xjBlO{ZISFI>eCl5MN`*-$QRHrFK5F$@65_PJliWWW7W#TSL_%w8q$VeP10MEobIg{$Qm2%kp;M?_Qju1esvE!rKy9tukXjBGs_;Q a*kkszH;q2Ah5Sf0o%pOWz0=p%vcCbGcDlv@ delta 517 zcma)&KTE?v0L61ruoh~pO9!QP5ER1cU2@6Y;ZkvwZU$HHZ^>XIR_C^pqm>Tsf}2om zzKu}m;GC%+K$=gGpxodI)OoN7tbCbN|>rVF6{>1Q;sIC?q15P-rZm z#UzrJQfUmDt=of
- + \ No newline at end of file