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ùng Email Vai Trò Dung Lượng Sử Dụng Hạn Mức Quota Thao 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);