const { useState, useRef, useEffect, useMemo } = React; // ── FastAPI Backend Configuration ── const API_BASE_URL = window.location.origin; const API_AUDIO = `${API_BASE_URL}/api/v1/audio`; const API_MULTITRACK = `${API_BASE_URL}/api/v1/multitrack`; const API_TASKS = `${API_BASE_URL}/api/v1/audio/tasks`; // Handle ?sfs= from double-clicking a .sfs file (opens domain -> loads project) (function handleSfsDeepLink() { try { const params = new URLSearchParams(window.location.search); const sfsParam = params.get('sfs'); if (!sfsParam) return; const decoded = JSON.parse(decodeURIComponent(sfsParam)); window.__pendingSfsProject = decoded; // consumed after auth in App if (window.history.replaceState) { window.history.replaceState({}, document.title, window.location.pathname); } } catch (e) { window.__pendingSfsProject = null; } })(); // Storage for server-side file IDs mapped to track IDs let serverFileIdMap = {}; let audioCtx; function getAudioContext() { if (!audioCtx) { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); if (window.SonicAudio && window.SonicAudio.initAudioWorklet) { window.SonicAudio.initAudioWorklet(); } } if (audioCtx.state === 'suspended') { audioCtx.resume(); } return audioCtx; } const formatTime = secs => { if (isNaN(secs) || secs < 0) return "0:00.000"; const m = Math.floor(secs / 60); const s = Math.floor(secs % 60); const ms = Math.floor(secs % 1 * 1000).toString().padStart(3, '0'); return `${m}:${s.toString().padStart(2, '0')}.${ms}`; }; const formatTimeSimple = secs => { if (isNaN(secs) || secs < 0) return "0.00s"; return `${secs.toFixed(2)}s`; }; const formatBeat = (secs, bpmVal) => { if (isNaN(secs) || secs < 0) return "1.1.1"; const beatDuration = 60 / bpmVal; const barDuration = beatDuration * 4; const bar = Math.floor(secs / barDuration) + 1; const beat = Math.floor(secs % barDuration / beatDuration) + 1; const sub = Math.floor(secs % beatDuration / (beatDuration / 4)) + 1; return `${bar}.${beat}.${sub}`; }; const getBeatMarkers = (maxDur, bpmVal) => { const beatDuration = 60 / bpmVal; const barDuration = beatDuration * 4; const markers = []; for (let t = 0; t <= maxDur; t += beatDuration) { const isBar = Math.abs(t % barDuration) < 0.001 || Math.abs(t % barDuration - barDuration) < 0.001; markers.push({ time: t, isBar, beatNum: Math.floor(t / beatDuration) + 1 }); } return markers; }; const findZeroCrossing = (buffer, targetTime) => { if (!buffer) return targetTime; const sampleRate = buffer.sampleRate; const data = buffer.getChannelData(0); const targetSample = Math.floor(targetTime * sampleRate); const windowSize = Math.floor(0.04 * sampleRate); const start = Math.max(0, targetSample - windowSize); const end = Math.min(data.length - 2, targetSample + windowSize); let bestSample = targetSample; let minDistance = Infinity; for (let i = start; i <= end; i++) { if (data[i] >= 0 && data[i + 1] <= 0 || data[i] <= 0 && data[i + 1] >= 0) { const dist = Math.abs(i - targetSample); if (dist < minDistance) { minDistance = dist; bestSample = i; } } } return bestSample / sampleRate; }; const VolumeKnob = ({ value, onChange, min = 0, max = 1 }) => { const [isDragging, setIsDragging] = useState(false); const startY = useRef(0); const startValue = useRef(0); const rotation = useMemo(() => { const percent = (value - min) / (max - min); return -135 + percent * 270; }, [value, min, max]); const handleMouseDown = e => { setIsDragging(true); startY.current = e.clientY; startValue.current = value; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }; const handleMouseMove = e => { const deltaY = startY.current - e.clientY; const sensitivity = 0.005; const newValue = Math.max(min, Math.min(max, startValue.current + deltaY * sensitivity)); onChange(parseFloat(newValue.toFixed(2))); }; const handleMouseUp = () => { setIsDragging(false); document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; return /*#__PURE__*/React.createElement("div", { className: "knob-container cursor-ns-resize flex flex-col items-center", onMouseDown: handleMouseDown, title: `Volume: ${Math.round(value * 100)}%` }, /*#__PURE__*/React.createElement("svg", { className: "w-7 h-7", viewBox: "0 0 40 40" }, /*#__PURE__*/React.createElement("circle", { cx: "20", cy: "20", r: "16", fill: "#141414", stroke: "#444", strokeWidth: "2" }), /*#__PURE__*/React.createElement("g", { transform: `rotate(${rotation} 20 20)`, className: "knob-dial" }, /*#__PURE__*/React.createElement("line", { x1: "20", y1: "20", x2: "20", y2: "6", stroke: "#ef4444", strokeWidth: "3", strokeLinecap: "round" })))); }; const WaveformLane = ({ track, zoom, timelineWidth, viewportWidth, onSelectRange, onPlayheadSet, isSelected, onSelectTrack, markers, selectionMode, localSelectionTrackId, localSelectionStart, currentTime, getLocalAnchor, onClearLocalSelection, onSetSelectionMode, onSetSelectionStart, onSetSelectionEnd, onSetCurrentTime, onSetLocalSelectionTrackId, onSetLocalSelectionStart, onSetLocalSelectionEnd, localSelLeft, localSelRight, onTrackLaneMouseDown, onContextMenu, onClipDragStart, onClipStretchStart, onSectionItemDragStart, onSectionItemResizeStart, onEditSectionInTab, onSelectionEdgeDragStart, setSelectedClipId, selectedClipId, activeTool, onSplitTrackAtTime, onEditClipInSubTab, snapValue, bpm, scrollLeft }) => { const canvasRef = useRef(null); const drawWidth = Math.min(timelineWidth, viewportWidth); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; const scrollLeftVal = scrollLeft || 0; const vWidth = viewportWidth || 1200; const height = canvas.parentElement ? canvas.parentElement.clientHeight : 96; canvas.width = Math.min(Math.round(drawWidth * dpr), 32768); canvas.height = Math.min(Math.round(height * dpr), 32768); ctx.scale(dpr, dpr); ctx.imageSmoothingEnabled = false; 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); } // Draw sections const sections = track.sections || []; sections.forEach(sec => { const secStartLocal = sec.start * zoom - scrollLeft; const secWidth = sec.duration * zoom; if (secStartLocal + secWidth < 0 || secStartLocal > drawWidth) return; ctx.fillStyle = sec.color ? sec.color + '44' : 'rgba(6, 182, 212, 0.25)'; ctx.fillRect(secStartLocal, 2, secWidth, height - 4); ctx.strokeStyle = sec.color || '#06b6d4'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); ctx.strokeRect(secStartLocal, 2, secWidth, height - 4); ctx.setLineDash([]); ctx.fillStyle = '#e4e4e7'; ctx.font = 'bold 9px sans-serif'; ctx.fillText(sec.name || 'Section', Math.max(secStartLocal + 4, 4), 14); }); // Draw MIDI items const midiItems = track.midiItems || []; midiItems.forEach(midi => { const midiStartLocal = midi.startTime * zoom - scrollLeft; const midiWidth = midi.duration * zoom; if (midiStartLocal + midiWidth < 0 || midiStartLocal > drawWidth) return; ctx.fillStyle = '#a78bfa33'; ctx.fillRect(midiStartLocal, 2, midiWidth, height - 4); ctx.strokeStyle = '#a78bfa'; ctx.lineWidth = 1.5; ctx.strokeRect(midiStartLocal, 2, midiWidth, height - 4); ctx.fillStyle = '#c4b5fd'; ctx.font = 'bold 9px sans-serif'; ctx.fillText(midi.name || 'MIDI', Math.max(midiStartLocal + 4, 4), 14); }); // 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, scrollLeft]); return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { key: "virtual-spacer", style: { width: `${timelineWidth}px`, height: '1px', pointerEvents: 'none' } }), /*#__PURE__*/React.createElement("canvas", { ref: canvasRef, style: { position: 'sticky', left: 0, imageRendering: 'pixelated' }, className: "cursor-crosshair", onMouseMove: e => { if (!canvasRef.current) return; const rect = canvasRef.current.getBoundingClientRect(); const x = e.clientX - rect.left + (scrollLeft || 0); 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; } // Check section/MIDI item hover for resize or drag const allSections = track.sections || []; const allMidiItems = track.midiItems || []; const sectionTolerance = 8 / zoom; let foundSectionItem = null; let sectionItemEdge = null; const checkEdge = (item, startTime, dur) => { const leftEdge = Math.abs(time - startTime) <= sectionTolerance; const rightEdge = Math.abs(time - (startTime + dur)) <= sectionTolerance; if (leftEdge || rightEdge) return leftEdge ? 'left' : 'right'; return null; }; for (const sec of allSections) { const edge = checkEdge(sec, sec.start, sec.duration); if (edge) { foundSectionItem = { type: 'section', item: sec }; sectionItemEdge = edge; break; } } if (!foundSectionItem) { for (const midi of allMidiItems) { const edge = checkEdge(midi, midi.startTime, midi.duration); if (edge) { foundSectionItem = { type: 'midiItem', item: midi }; sectionItemEdge = edge; break; } } } if (foundSectionItem && sectionItemEdge) { canvasRef.current.style.cursor = 'ew-resize'; return; } // Check body hover for drag if (!foundSectionItem) { for (const sec of allSections) { if (time >= sec.start && time < sec.start + sec.duration) { foundSectionItem = { type: 'section', item: sec }; break; } } } if (!foundSectionItem) { for (const midi of allMidiItems) { if (time >= midi.startTime && time < midi.startTime + midi.duration) { foundSectionItem = { type: 'midiItem', item: midi }; break; } } } if (foundSectionItem) { canvasRef.current.style.cursor = 'grab'; 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 x = e.clientX - rect.left + (scrollLeft || 0); 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 }] : []; // Check if Ctrl+Click to exit selection if (e.ctrlKey && selectionMode) { e.preventDefault(); e.stopPropagation(); if (onClearLocalSelection) onClearLocalSelection(); if (onSetSelectionMode) onSetSelectionMode(null); if (onSetSelectionStart) onSetSelectionStart(null); if (onSetSelectionEnd) onSetSelectionEnd(null); return; } // 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; } // Check section/MIDI item edge for resize, then body for drag const secItems = track.sections || []; const midiItemsArray = track.midiItems || []; const secTol = 8 / zoom; let hitItem = null; let hitEdge = null; for (const sec of secItems) { if (Math.abs(time - sec.start) <= secTol) { hitItem = { type: 'section', id: sec.id, start: sec.start, dur: sec.duration }; hitEdge = 'left'; break; } if (Math.abs(time - (sec.start + sec.duration)) <= secTol) { hitItem = { type: 'section', id: sec.id, start: sec.start, dur: sec.duration }; hitEdge = 'right'; break; } } if (!hitItem) { for (const midi of midiItemsArray) { if (Math.abs(time - midi.startTime) <= secTol) { hitItem = { type: 'midiItem', id: midi.id, start: midi.startTime, dur: midi.duration }; hitEdge = 'left'; break; } if (Math.abs(time - (midi.startTime + midi.duration)) <= secTol) { hitItem = { type: 'midiItem', id: midi.id, start: midi.startTime, dur: midi.duration }; hitEdge = 'right'; break; } } } if (hitItem && hitEdge) { e.preventDefault(); e.stopPropagation(); if (onSectionItemResizeStart) onSectionItemResizeStart(track.id, hitItem.type, hitItem.id, hitEdge, time); return; } if (!hitItem) { for (const sec of secItems) { if (time >= sec.start && time < sec.start + sec.duration) { hitItem = { type: 'section', id: sec.id, start: sec.start }; break; } } } if (!hitItem) { for (const midi of midiItemsArray) { if (time >= midi.startTime && time < midi.startTime + midi.duration) { hitItem = { type: 'midiItem', id: midi.id, start: midi.startTime }; break; } } } if (hitItem && !e.altKey && !e.ctrlKey && !e.shiftKey) { e.preventDefault(); e.stopPropagation(); if (onSectionItemDragStart) onSectionItemDragStart(track.id, hitItem.type, hitItem.id, time - hitItem.start); 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 x = e.clientX - rect.left + (scrollLeft || 0); 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 }] : []; // Check double-click on section first const dblSecItems = track.sections || []; let dblSecHit = null; for (const sec of dblSecItems) { if (time >= sec.start && time < sec.start + sec.duration) { dblSecHit = sec; break; } } if (dblSecHit) { e.preventDefault(); e.stopPropagation(); if (onEditSectionInTab) onEditSectionInTab(track.id, dblSecHit.id); return; } 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 x = e.clientX - rect.left + (scrollLeft || 0); const time = Math.max(0, x / zoom); const secList = track.sections || []; let hitSectionId = null; for (const sec of secList) { if (time >= sec.start && time < sec.start + sec.duration) { hitSectionId = sec.id; break; } } if (onContextMenu) onContextMenu(e, track.id, time, hitSectionId); } })); }; const TempoTrackLane = ({ bpm, zoom, timelineWidth, viewportWidth, onPlayheadSet, snapValue, onRulerMouseDown, scrollLeft }) => { const canvasRef = useRef(null); const drawWidth = Math.min(timelineWidth, viewportWidth); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; const scrollLeftVal = scrollLeft || 0; const height = canvas.parentElement ? canvas.parentElement.clientHeight : 40; canvas.width = Math.min(Math.round(drawWidth * dpr), 32768); canvas.height = Math.min(Math.round(height * dpr), 32768); ctx.scale(dpr, dpr); ctx.imageSmoothingEnabled = false; canvas.style.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 if (zoom >= 2) { ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(localX, 0); ctx.lineTo(localX, height); ctx.stroke(); if (zoom >= 5) { ctx.fillStyle = 'rgba(255, 255, 255, 0.4)'; ctx.font = '8px Inter, sans-serif'; ctx.textAlign = 'left'; const bar = Math.ceil(beatNum / 4); const beat = (beatNum - 1) % 4 + 1; ctx.fillText(`${bar}:${beat}`, localX + 2, 9); } } 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, scrollLeft]); return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { key: "virtual-spacer-tempo", style: { width: `${timelineWidth}px`, height: '1px', pointerEvents: 'none' } }), /*#__PURE__*/React.createElement("canvas", { ref: canvasRef, style: { position: 'sticky', left: 0, imageRendering: 'pixelated' }, className: "cursor-crosshair", onMouseDown: e => { const rect = canvasRef.current.getBoundingClientRect(); const x = e.clientX - rect.left + (scrollLeft || 0); const time = Math.max(0, x / zoom); if (e.shiftKey) { e.preventDefault(); e.stopPropagation(); } if (onRulerMouseDown) { onRulerMouseDown(e); } else { onPlayheadSet(time, e.shiftKey); } } })); }; // ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2 & SUB_EDITOR.md) ── const SubTabWaveform = ({ buffer, subTabId, activeTab, currentTime, selectionStart, selectionEnd, onSelectRange, onPlayheadSet, onContextMenu, activeTool, zoom, timelineWidth, color, name, speed = 1.0, onSpeedChange, volumeNodes = [], panningNodes = [], fadeInLen = 0, fadeOutLen = 0, graphMode = null, onUpdateNodes, onUpdateFade, onModeToggle, selectedNodeTime, setSelectedNodeTime, channelInfo = null }) => { const canvasRef = useRef(null); const isStretchingRef = useRef(false); const stretchStartRef = useRef({ mouseX: 0, originalDuration: 0, originalSpeed: 1.0 }); const subTabAnchorRef = useRef(null); const isStereo = channelInfo ? channelInfo.isStereo : buffer && buffer.numberOfChannels >= 2; const channelLabel = channelInfo ? channelInfo.label : isStereo ? 'STEREO' : 'MONO'; // Mono: force volume mode (panning not applicable) const effectiveGraphMode = !isStereo && graphMode === 'pan' ? null : graphMode; useEffect(() => { const canvas = canvasRef.current; if (!canvas || !buffer) return; const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null; const scrollLeft = wrapper ? wrapper.scrollLeft : 0; const vWidth = wrapper ? wrapper.clientWidth : 1200; const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200)); const h = canvas.parentElement ? canvas.parentElement.clientHeight : 200; canvas.width = Math.round(drawWidth * dpr); canvas.height = Math.round(h * dpr); ctx.scale(dpr, dpr); ctx.imageSmoothingEnabled = false; canvas.style.position = 'absolute'; canvas.style.left = `${scrollLeft}px`; canvas.style.width = `${drawWidth}px`; canvas.style.height = `${h}px`; ctx.fillStyle = '#181818'; ctx.fillRect(0, 0, drawWidth, h); const data = buffer.getChannelData(0); const len = data.length; if (len === 0) return; // Helper to compute volume gain at a specific time in clip using Monotone Cubic Hermite Spline const computeHermiteTangents = pts => { const n = pts.length; if (n < 2) return []; const m = new Array(n); for (let i = 1; i < n - 1; i++) { const hP = pts[i].time - pts[i - 1].time; const hN = pts[i + 1].time - pts[i].time; const sP = (pts[i].db - pts[i - 1].db) / hP; const sN = (pts[i + 1].db - pts[i].db) / hN; m[i] = (sP + sN) / 2; } m[0] = n > 1 ? (pts[1].db - pts[0].db) / (pts[1].time - pts[0].time) : 0; m[n - 1] = n > 1 ? (pts[n - 1].db - pts[n - 2].db) / (pts[n - 1].time - pts[n - 2].time) : 0; return m; }; const getVolumeDbAtTime = t => { const volNodes = volumeNodes || []; if (volNodes.length === 0) return 0.0; const sortedNodes = [...volNodes].sort((a, b) => a.time - b.time); if (sortedNodes.length === 1) return sortedNodes[0].db; if (t <= sortedNodes[0].time) return sortedNodes[0].db; if (t >= sortedNodes[sortedNodes.length - 1].time) return sortedNodes[sortedNodes.length - 1].db; const tangents = computeHermiteTangents(sortedNodes); for (let i = 0; i < sortedNodes.length - 1; i++) { const n1 = sortedNodes[i]; const n2 = sortedNodes[i + 1]; if (t >= n1.time && t <= n2.time) { const h = n2.time - n1.time; if (h <= 0) return n1.db; const frac = (t - n1.time) / h; const frac2 = frac * frac, frac3 = frac2 * frac; return (2 * frac3 - 3 * frac2 + 1) * n1.db + (frac3 - 2 * frac2 + frac) * h * tangents[i] + (-2 * frac3 + 3 * frac2) * n2.db + (frac3 - frac2) * h * tangents[i + 1]; } } return 0.0; }; const getVolumeGainAtTime = t => { return Math.pow(10, getVolumeDbAtTime(t) / 20); }; const computeHermiteTangentsForPan = pts => { const n = pts.length; if (n < 2) return []; const m = new Array(n); for (let i = 1; i < n - 1; i++) { const hP = pts[i].time - pts[i - 1].time; const hN = pts[i + 1].time - pts[i].time; const sP = (pts[i].pan - pts[i - 1].pan) / hP; const sN = (pts[i + 1].pan - pts[i].pan) / hN; m[i] = (sP + sN) / 2; } m[0] = n > 1 ? (pts[1].pan - pts[0].pan) / (pts[1].time - pts[0].time) : 0; m[n - 1] = n > 1 ? (pts[n - 1].pan - pts[n - 2].pan) / (pts[n - 1].time - pts[n - 2].time) : 0; return m; }; const getPanningValueAtTime = t => { const panNodes = panningNodes || []; if (panNodes.length === 0) return 0; const sortedNodes = [...panNodes].sort((a, b) => a.time - b.time); if (sortedNodes.length === 1) return Math.round(sortedNodes[0].pan * 100); if (t <= sortedNodes[0].time) return Math.round(sortedNodes[0].pan * 100); if (t >= sortedNodes[sortedNodes.length - 1].time) return Math.round(sortedNodes[sortedNodes.length - 1].pan * 100); const tangents = computeHermiteTangentsForPan(sortedNodes); for (let i = 0; i < sortedNodes.length - 1; i++) { const n1 = sortedNodes[i]; const n2 = sortedNodes[i + 1]; if (t >= n1.time && t <= n2.time) { const h = n2.time - n1.time; if (h <= 0) return Math.round(n1.pan * 100); const frac = (t - n1.time) / h; const frac2 = frac * frac, frac3 = frac2 * frac; const val = (2 * frac3 - 3 * frac2 + 1) * n1.pan + (frac3 - 2 * frac2 + frac) * h * tangents[i] + (-2 * frac3 + 3 * frac2) * n2.pan + (frac3 - frac2) * h * tangents[i + 1]; return Math.round(val * 100); } } return 0; }; // Draw clip container (like main session clips) const xStart = 0; const wClip = buffer.duration / speed * zoom; // speed-adjusted width const xEnd = xStart + wClip; const clipTop = 8; const clipHeight = h - 16; const clipColor = color || '#06b6d4'; ctx.fillStyle = clipColor + '22'; ctx.strokeStyle = clipColor; ctx.lineWidth = 1.5; if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(xStart, clipTop, wClip, clipHeight, 4); ctx.fill(); ctx.stroke(); } else { ctx.fillRect(xStart, clipTop, wClip, clipHeight); ctx.strokeRect(xStart, clipTop, wClip, clipHeight); } // Draw clip label with speed % ctx.fillStyle = '#e4e4e7'; ctx.font = 'bold 10px sans-serif'; let displayName = name || 'Audio Clip'; if (speed !== 1.0) { displayName += ` (${Math.round(speed * 100)}%)`; } ctx.fillText(displayName, xStart + 8, clipTop + 14); // ── Graph Grid & Axes ── const drawGrid = true; if (drawGrid) { // Background fill ctx.fillStyle = '#181818'; ctx.fillRect(xStart, clipTop, wClip, clipHeight); // Vertical grid lines (time markers) ctx.strokeStyle = '#2a2a2a'; ctx.lineWidth = 0.5; ctx.setLineDash([]); const timeStep = Math.max(0.1, Math.ceil(buffer.duration / 20 * 10) / 10); for (let t = 0; t <= buffer.duration; t += timeStep) { const px = t * zoom; ctx.beginPath(); ctx.moveTo(px, clipTop); ctx.lineTo(px, clipTop + clipHeight); ctx.stroke(); } // Always draw the reference Volume 0dB Axis (White) and Panning Center Axis (Brown) const volZeroY = clipTop + 1 / 3 * clipHeight; const panZeroY = clipTop + 1 / 2 * clipHeight; // White line for volume 0dB ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 1.2; ctx.beginPath(); ctx.moveTo(xStart, volZeroY); ctx.lineTo(xStart + wClip, volZeroY); ctx.stroke(); // Brown line for panning center ctx.strokeStyle = '#854d0e'; ctx.lineWidth = 1.2; ctx.beginPath(); ctx.moveTo(xStart, panZeroY); ctx.lineTo(xStart + wClip, panZeroY); ctx.stroke(); // Horizontal grid lines (other value markers) const isPanMode = effectiveGraphMode === 'pan'; if (isPanMode) { for (let p = -100; p <= 100; p += 20) { if (p === 0) continue; const y = clipTop + clipHeight * (1 - (p / 100 + 1) / 2); ctx.strokeStyle = '#2a2a2a'; ctx.lineWidth = 0.5; ctx.beginPath(); ctx.moveTo(xStart, y); ctx.lineTo(xStart + wClip, y); ctx.stroke(); } } else { for (let db = -30; db <= 3; db += 3) { if (db === 0) continue; const y = db >= 0 ? volZeroY - db / 3 * (2 / 3 * clipHeight) : volZeroY + -db / 30 * (1 / 3 * clipHeight); ctx.strokeStyle = '#2a2a2a'; ctx.lineWidth = 0.5; ctx.beginPath(); ctx.moveTo(xStart, y); ctx.lineTo(xStart + wClip, y); ctx.stroke(); } } // Y-axis labels (left side) ctx.fillStyle = '#71717a'; ctx.font = '7px monospace'; ctx.textAlign = 'right'; if (isPanMode) { ctx.fillText('L100', xStart - 2, clipTop + 8); ctx.fillText('R50', xStart - 2, clipTop + clipHeight * 0.25 + 2); ctx.fillStyle = '#854d0e'; // Brown label for active Panning Center ctx.fillText('C (Pan)', xStart - 2, clipTop + clipHeight * 0.5 + 2); ctx.fillStyle = '#71717a'; ctx.fillText('L50', xStart - 2, clipTop + clipHeight * 0.75 + 2); ctx.fillText('R100', xStart - 2, clipTop + clipHeight - 2); } else { ctx.fillText('+3dB', xStart - 2, clipTop + 8); ctx.fillStyle = '#ffffff'; // White label for active Volume 0dB ctx.fillText('0dB (Vol)', xStart - 2, volZeroY + 2); ctx.fillStyle = '#71717a'; ctx.fillText('-15dB', xStart - 2, volZeroY + clipHeight / 6 + 2); ctx.fillText('-30dB', xStart - 2, clipTop + clipHeight - 2); } ctx.textAlign = 'start'; } // Drag hint labels for fade endpoints ctx.fillStyle = '#71717a'; ctx.font = '8px sans-serif'; ctx.fillText('Kéo FI/FO trên đường cong để điều chỉnh', 8, clipTop + clipHeight + 12); // Display speed percentage in the bottom left corner of the waveform area ctx.fillStyle = '#e4e4e7'; ctx.font = 'bold 9px sans-serif'; ctx.fillText(`Tốc độ: ${Math.round(speed * 100)}%`, xStart + 8, clipTop + clipHeight - 8); // Mode toggle button on waveform (bottom-right) const modeBtnW = 28; const modeBtnH = 14; const modeBtnX = wClip - modeBtnW - 4; const modeBtnY = clipTop + clipHeight - modeBtnH - 2; const isPanMode = effectiveGraphMode === 'pan'; ctx.fillStyle = isPanMode ? 'rgba(168, 85, 247, 0.5)' : 'rgba(6, 182, 212, 0.5)'; ctx.beginPath(); ctx.roundRect(modeBtnX, modeBtnY, modeBtnW, modeBtnH, 3); ctx.fill(); ctx.fillStyle = '#fff'; ctx.font = 'bold 7px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(isPanMode ? 'PAN' : 'VOL', modeBtnX + modeBtnW / 2, modeBtnY + 10); ctx.textAlign = 'start'; // Channel label (L / R for stereo, M for mono) ctx.fillStyle = '#a1a1aa'; ctx.font = 'bold 8px monospace'; if (isStereo) { ctx.fillText('L', xStart + 2, clipTop + clipHeight * 0.28); ctx.fillText('R', xStart + 2, clipTop + clipHeight * 0.72); } else { ctx.fillText('M', xStart + 2, clipTop + clipHeight / 2); } // Draw waveform inside clip (speed-adjusted) with fade envelope applied const drawXStart = Math.max(0, Math.floor(xStart)); const drawXEnd = Math.min(drawWidth, Math.ceil(xEnd)); const samplesPerPixel = buffer.sampleRate / zoom * speed; const bufDur = buffer.duration; const mid = h / 2; const peakRatio = clipHeight * 0.45; const getFadeGainAtTime = t => { if (fadeInLen > 0 && t < fadeInLen) { return (1 - Math.cos(Math.PI * t / fadeInLen)) / 2; } else if (fadeOutLen > 0 && t > bufDur - fadeOutLen) { const ratio = (t - (bufDur - fadeOutLen)) / fadeOutLen; return (1 + Math.cos(Math.PI * ratio)) / 2; } return 1; }; ctx.strokeStyle = '#5bc0be'; ctx.lineWidth = 1.2; if (samplesPerPixel < 4) { // High-zoom continuous vector line rendering (#5bc0be Cornflower Blue) const startSample = Math.max(0, Math.floor((drawXStart - xStart) / zoom * speed * buffer.sampleRate)); const endSample = Math.min(len, Math.ceil((drawXEnd - xStart) / zoom * speed * buffer.sampleRate)); ctx.beginPath(); let first = true; const maxSamples = 50000; const vectorStep = Math.max(1, Math.floor((endSample - startSample) / maxSamples)); for (let i = startSample; i < endSample; i += vectorStep) { const timeInClip = i / buffer.sampleRate / speed; const px = xStart + timeInClip * zoom; const fadeGain = getFadeGainAtTime(timeInClip); const volGain = getVolumeGainAtTime(px / zoom); const totalGain = fadeGain * volGain; const y = mid - data[i] * totalGain * peakRatio; if (first) { ctx.moveTo(px, y); first = false; } else { ctx.lineTo(px, y); } } ctx.stroke(); // Draw granular sample nodes only at extreme zoom (samplesPerPixel < 0.3) if (samplesPerPixel < 0.3) { const maxNodes = 5000; const step = Math.max(1, Math.floor((endSample - startSample) / maxNodes)); ctx.fillStyle = '#6ee7b7'; let drawn = 0; for (let i = startSample; i < endSample && drawn < maxNodes; i += step) { const timeInClip = i / buffer.sampleRate / speed; const px = xStart + timeInClip * zoom; const fadeGain = getFadeGainAtTime(timeInClip); const volGain = getVolumeGainAtTime(px / zoom); const totalGain = fadeGain * volGain; const y = mid - data[i] * totalGain * peakRatio; ctx.fillRect(px - 1, y - 1, 2, 2); drawn++; } } } else { // Coarse view vertical peak min/max bars for (let px = drawXStart; px < drawXEnd; px++) { const timeInClip = px / zoom * speed; const sampleIdx = Math.floor(timeInClip * buffer.sampleRate); const chunkSize = Math.max(1, Math.floor(samplesPerPixel)); const chunkStart = Math.max(0, sampleIdx - Math.floor(chunkSize / 2)); const chunkEnd = Math.min(len, chunkStart + chunkSize); let fadeGain = getFadeGainAtTime(timeInClip); const volGain = getVolumeGainAtTime(px / zoom); const totalGain = fadeGain * volGain; let maxVal = 0; let minVal = 0; for (let i = chunkStart; i < chunkEnd; i++) { const val = data[i]; if (val > maxVal) maxVal = val; if (val < minVal) minVal = val; } maxVal *= totalGain; minVal *= totalGain; const yTop = mid + minVal * peakRatio; const yBottom = mid + maxVal * peakRatio; ctx.beginPath(); ctx.moveTo(px, yTop); ctx.lineTo(px, yBottom); ctx.stroke(); } } // Volume: 0dB at 1/3 from top const autoY = node => { const db = typeof node === 'number' ? node : node.db; const zeroY = clipTop + 1 / 3 * clipHeight; return db >= 0 ? zeroY - db / 3 * (1 / 3 * clipHeight) : zeroY + -db / 30 * (2 / 3 * clipHeight); }; // Panning: 0 at center const autoPanY = node => { const pan = typeof node === 'number' ? node : node.pan; return clipTop + (1 - (pan + 1) / 2) * clipHeight; }; // Helper: compute tangents for monotone Hermite spline const computeTangents = (pts, yFn) => { const n = pts.length; if (n < 2) return []; const m = new Array(n); for (let i = 1; i < n - 1; i++) { const hP = pts[i].time - pts[i - 1].time; const hN = pts[i + 1].time - pts[i].time; const sP = (yFn(pts[i]) - yFn(pts[i - 1])) / hP; const sN = (yFn(pts[i + 1]) - yFn(pts[i])) / hN; m[i] = (sP + sN) / 2; } m[0] = n > 1 ? (yFn(pts[1]) - yFn(pts[0])) / (pts[1].time - pts[0].time) : 0; m[n - 1] = n > 1 ? (yFn(pts[n - 1]) - yFn(pts[n - 2])) / (pts[n - 1].time - pts[n - 2].time) : 0; return m; }; // Helper: evaluate Hermite at pixel position px const hermiteY = (px, x0, y0, m0, x1, y1, m1) => { const h = x1 - x0; if (h <= 0) return y0; const t = (px - x0) / h; const t2 = t * t, t3 = t2 * t; return (2 * t3 - 3 * t2 + 1) * y0 + (t3 - 2 * t2 + t) * h * m0 + (-2 * t3 + 3 * t2) * y1 + (t3 - t2) * h * m1; }; // Draw automation curve with Hermite spline const drawSpline = (nodes, yFn, color, lineDash) => { if (nodes.length < 2) { if (nodes.length === 1) { const px = nodes[0].time * zoom; const y = yFn(nodes[0]); ctx.fillStyle = color; ctx.beginPath(); ctx.arc(px, y, 4, 0, Math.PI * 2); ctx.fill(); } return; } const tangents = computeTangents(nodes, yFn); ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.setLineDash(lineDash || []); ctx.beginPath(); for (let i = 0; i < nodes.length - 1; i++) { const x0 = nodes[i].time * zoom, y0 = yFn(nodes[i]); const x1 = nodes[i + 1].time * zoom, y1 = yFn(nodes[i + 1]); const t0 = nodes[i].time, t1 = nodes[i + 1].time; const m0 = tangents[i], m1 = tangents[i + 1]; for (let px = Math.floor(x0); px < Math.ceil(x1); px++) { const t = px / zoom; const y = hermiteY(t, t0, y0, m0, t1, y1, m1); if (px === Math.floor(x0) && i === 0) ctx.moveTo(px, y);else ctx.lineTo(px, y); } } ctx.stroke(); ctx.setLineDash([]); // Draw node handles nodes.forEach(n => { const px = n.time * zoom, y = yFn(n); const isSelected = n.time === selectedNodeTime; ctx.fillStyle = isSelected ? '#ffebb3' : '#fff'; ctx.beginPath(); ctx.arc(px, y, isSelected ? 6 : 4, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = isSelected ? '#fbbf24' : color; ctx.lineWidth = isSelected ? 2.5 : 1.5; ctx.beginPath(); ctx.arc(px, y, isSelected ? 6 : 4, 0, Math.PI * 2); ctx.stroke(); // Display value at node ctx.fillStyle = '#ffffff'; ctx.font = 'bold 12px sans-serif'; const labelText = isPanMode ? n.pan > 0 ? 'R' + Math.round(n.pan * 100) : n.pan < 0 ? 'L' + Math.round(Math.abs(n.pan) * 100) : 'C' : `${n.db >= 0 ? '+' : ''}${n.db.toFixed(1)}dB`; ctx.fillText(labelText, px + 8, y + 4); }); }; drawSpline(volumeNodes, autoY, '#f43f5e'); drawSpline(panningNodes, autoPanY, '#a855f7', [4, 4]); // ── Fade Curves (transparent, only curve lines + endpoint handles) ── const FADE_COLOR = '#b91c1c'; const HANDLE_RADIUS = 5; if (fadeInLen >= 0) { const fiPx = fadeInLen * zoom; if (fadeInLen > 0) { ctx.strokeStyle = FADE_COLOR; ctx.lineWidth = 1.8; ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(0, clipTop + clipHeight); for (let px = 0; px <= fiPx; px++) { const ratio = px / fiPx; const amp = (1 - Math.cos(Math.PI * ratio)) / 2; const y = clipTop + clipHeight - amp * clipHeight; ctx.lineTo(px, y); } ctx.stroke(); } // Endpoint handle (draggable) const endX = fiPx, endY = clipTop; ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(endX, endY, HANDLE_RADIUS, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = FADE_COLOR; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(endX, endY, HANDLE_RADIUS, 0, Math.PI * 2); ctx.stroke(); // Label ctx.fillStyle = '#b91c1c'; ctx.font = 'bold 7px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('FI', endX, endY - HANDLE_RADIUS - 3); ctx.textAlign = 'start'; } if (fadeOutLen >= 0) { const foPx = fadeOutLen * zoom; const startX = wClip - foPx; if (fadeOutLen > 0) { ctx.strokeStyle = FADE_COLOR; ctx.lineWidth = 1.8; ctx.setLineDash([]); ctx.beginPath(); ctx.moveTo(startX, clipTop); for (let px = 0; px <= foPx; px++) { const ratio = px / foPx; const amp = (1 + Math.cos(Math.PI * ratio)) / 2; const y = clipTop + clipHeight - amp * clipHeight; ctx.lineTo(startX + px, y); } ctx.stroke(); } // Endpoint handle (draggable) const handleX = startX, handleY = clipTop; ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(handleX, handleY, HANDLE_RADIUS, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = FADE_COLOR; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(handleX, handleY, HANDLE_RADIUS, 0, Math.PI * 2); ctx.stroke(); ctx.fillStyle = '#b91c1c'; ctx.font = 'bold 7px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('FO', handleX, handleY - HANDLE_RADIUS - 3); ctx.textAlign = 'start'; } // Draw right-edge stretch handle indicator if (wClip > 0 && wClip < drawWidth) { ctx.strokeStyle = clipColor; ctx.lineWidth = 1.5; ctx.setLineDash([3, 3]); ctx.beginPath(); ctx.moveTo(wClip + xStart, clipTop); ctx.lineTo(wClip + xStart, clipTop + clipHeight); ctx.stroke(); ctx.setLineDash([]); } // Highlight selection if active if (selectionStart !== null && selectionEnd !== null && selectionStart !== selectionEnd) { const left = Math.min(selectionStart, selectionEnd); const right = Math.max(selectionStart, selectionEnd); const leftPx = left * zoom; const rightPx = right * zoom; ctx.fillStyle = 'rgba(245, 158, 11, 0.15)'; ctx.fillRect(leftPx, 0, rightPx - leftPx, h); ctx.strokeStyle = '#f59e0b'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(leftPx, 0); ctx.lineTo(leftPx, h); ctx.moveTo(rightPx, 0); ctx.lineTo(rightPx, h); ctx.stroke(); } // Draw playhead if (currentTime !== null && currentTime >= 0 && currentTime <= buffer.duration / speed) { const playheadPx = currentTime * zoom; ctx.strokeStyle = '#ef4444'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(playheadPx, 0); ctx.lineTo(playheadPx, h); ctx.stroke(); } // Update TCP slider values in real-time to match the curve at currentTime const volInput = document.getElementById(`tcp-vol-${subTabId}`); const volLabel = document.getElementById(`tcp-vol-label-${subTabId}`); if (volInput && volLabel) { const dbVal = getVolumeDbAtTime(currentTime || 0); volInput.value = dbVal.toFixed(1); volLabel.textContent = `${dbVal.toFixed(1)}dB`; } const panInput = document.getElementById(`tcp-pan-${subTabId}`); const panLabel = document.getElementById(`tcp-pan-label-${subTabId}`); if (panInput && panLabel) { const panVal = getPanningValueAtTime(currentTime || 0); panInput.value = panVal; panLabel.textContent = panVal > 0 ? 'R' : panVal < 0 ? 'L' : 'C'; const panLabelDetailed = document.getElementById(`tcp-pan-label-detailed-${subTabId}`); if (panLabelDetailed) { panLabelDetailed.textContent = panVal > 0 ? 'R' + panVal : panVal < 0 ? 'L' + Math.abs(panVal) : 'C'; } } }, [buffer, currentTime, selectionStart, selectionEnd, zoom, timelineWidth, color, name, speed, volumeNodes, panningNodes, fadeInLen, fadeOutLen, graphMode, selectedNodeTime, subTabId]); const handleMouseDown = e => { if (e.button === 2) return; const canvas = canvasRef.current; const rect = canvas.getBoundingClientRect(); const parent = canvas.parentElement; const scrollContainer = parent ? parent.parentElement : null; const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; const bufDuration = buffer.duration; const wallDuration = bufDuration / (speed || 1.0); const mouseX = e.clientX - rect.left + scrollLeft; const startTime = Math.max(0, Math.min(wallDuration, mouseX / zoom)); // Shift + Click range selection in SubTab Waveform if (e.shiftKey) { e.preventDefault(); e.stopPropagation(); const anchor = subTabAnchorRef.current !== null && subTabAnchorRef.current !== undefined ? subTabAnchorRef.current : selectionStart !== null && selectionStart !== undefined ? selectionStart : currentTime; const selS = Math.min(anchor, startTime); const selE = Math.max(anchor, startTime); onSelectRange(selS, selE); onPlayheadSet(startTime); return; } // Alt+Click near right edge → speed stretch if (e.altKey && onSpeedChange) { const clipRightEdge = bufDuration / (speed || 1.0) * zoom; const tolerance = 8; if (Math.abs(mouseX - clipRightEdge) <= tolerance) { isStretchingRef.current = true; stretchStartRef.current = { mouseX, originalDuration: bufDuration, originalSpeed: speed }; canvas.style.cursor = 'ew-resize'; const handleMouseMove = moveEvent => { const currentX = moveEvent.clientX - rect.left + scrollLeft; const wClipPx = stretchStartRef.current.originalDuration / stretchStartRef.current.originalSpeed * zoom; const deltaX = currentX - stretchStartRef.current.mouseX; const newWClip = Math.max(10, wClipPx + deltaX); const newSpeed = stretchStartRef.current.originalDuration / (newWClip / zoom); if (onSpeedChange) onSpeedChange(Math.max(0.05, Math.min(10, newSpeed))); }; const handleMouseUp = () => { isStretchingRef.current = false; canvas.style.cursor = 'default'; document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return; } } // Mode toggle button click (bottom-right VOL/PAN) const wClipPx = bufDuration / (speed || 1.0) * zoom; const cTop = 8; const cSize = 16; const cHeight = rect.height - 16; const modeBtnX = wClipPx - 28 - 4; const modeBtnY = cTop + cHeight - 14 - 2; if (mouseX >= modeBtnX && mouseX <= modeBtnX + 28 && e.clientY - rect.top >= modeBtnY && e.clientY - rect.top <= modeBtnY + 14) { if (onModeToggle) onModeToggle(); return; } // Fade endpoint handle drag (click on FI/FO handle circles) const HANDLE_R = 5; const fiEndPx = fadeInLen * zoom; const foStartPx = wClipPx - fadeOutLen * zoom; const distToFiHandle = Math.abs(mouseX - fiEndPx) + Math.abs(e.clientY - rect.top - cTop); const distToFoHandle = Math.abs(mouseX - foStartPx) + Math.abs(e.clientY - rect.top - cTop); if (distToFiHandle <= HANDLE_R + 6) { const handleMouseMove = moveEvent => { const x = moveEvent.clientX - rect.left + scrollLeft; const t = Math.max(0, Math.min(wallDuration, x / zoom)); if (onUpdateFade) onUpdateFade({ fadeInLen: t }); }; const handleMouseUp = () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return; } if (distToFoHandle <= HANDLE_R + 6) { const handleMouseMove = moveEvent => { const x = moveEvent.clientX - rect.left + scrollLeft; const t = Math.max(0, Math.min(wallDuration, (wClipPx - x) / zoom)); if (onUpdateFade) onUpdateFade({ fadeOutLen: t }); }; const handleMouseUp = () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return; } // Tool-specific behavior if (activeTool === 'grab') { setSelectedNodeTime(null); onPlayheadSet(startTime); const handleMouseMove = moveEvent => { const currentX = moveEvent.clientX - rect.left + scrollLeft; const ct = Math.max(0, Math.min(wallDuration, currentX / zoom)); onPlayheadSet(ct); }; const handleMouseUp = () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return; } if (activeTool === 'razor') { setSelectedNodeTime(null); onPlayheadSet(startTime); showToast(`Cut point at ${formatTime(startTime)}`, 'info'); return; } if (activeTool === 'pen') { // Deduplicate by time: keep last occurrence per time key const mergeNodes = (existing, incoming) => { const map = new Map(); existing.forEach(n => map.set(n.time, n)); incoming.forEach(n => map.set(n.time, n)); return Array.from(map.values()).sort((a, b) => a.time - b.time); }; onPlayheadSet(startTime); canvas.style.cursor = 'crosshair'; const isPan = (graphMode || 'volume') === 'pan'; const isCtrl = e.ctrlKey || e.metaKey; const cTop = 8; const cHeight = rect.height - 16; const curNodes = isPan ? panningNodes : volumeNodes; const valFromY = y => { if (isPan) return Math.max(-1, Math.min(1, -(y - cTop) / cHeight * 2 + 1)); const yOff = (y - cTop) / cHeight; return yOff <= 1 / 3 ? Math.max(0, Math.min(3, 3 * (1 - yOff * 3))) : Math.max(-30, Math.min(0, -30 * (yOff - 1 / 3) * (3 / 2))); }; const snapVal = v => isPan ? Math.round(v * 20) / 20 : Math.round(v * 2) / 2; const snapTime = t => Math.round(t * 10) / 10; // Check if clicking near existing node (any mode) const volNodeY = v => { const z = cTop + 1 / 3 * cHeight; return v >= 0 ? z - v / 3 * (1 / 3 * cHeight) : z + -v / 30 * (2 / 3 * cHeight); }; const nearNode = curNodes.findIndex(n => { const t = Math.abs(n.time - startTime); const val = isPan ? n.pan : n.db; const ny = isPan ? cTop + (1 - (val + 1) / 2) * cHeight : volNodeY(val); const dy = Math.abs(e.clientY - rect.top - ny); return t < 0.1 / (speed || 1) && dy < 10; }); if (nearNode >= 0) { // Drag existing node setSelectedNodeTime(curNodes[nearNode].time); let working = [...curNodes]; let dragIdx = nearNode; const handleMouseMove = moveEvent => { const currentX = moveEvent.clientX - rect.left + scrollLeft; const ct = Math.max(0, Math.min(wallDuration, currentX / zoom)); const val = snapVal(valFromY(moveEvent.clientY - rect.top)); const updated = isPan ? { time: Math.min(snapTime(ct), wallDuration), pan: val } : { time: Math.min(snapTime(ct), wallDuration), db: val }; const cleaned = mergeNodes(working.filter((_, i) => i !== dragIdx), [updated]); if (onUpdateNodes) onUpdateNodes(cleaned); working = [...cleaned]; dragIdx = working.findIndex(n => n.time === updated.time && (isPan ? n.pan === updated.pan : n.db === updated.db)); // Follow the selected node during dragging setSelectedNodeTime(updated.time); }; const handleMouseUp = () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); canvas.style.cursor = 'crosshair'; }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return; } if (isCtrl) { setSelectedNodeTime(null); const pts = []; let lastKey = ''; const handleMouseMove = moveEvent => { const currentX = moveEvent.clientX - rect.left + scrollLeft; const ct = Math.max(0, Math.min(wallDuration, currentX / zoom)); const t = +snapTime(ct).toFixed(3); const v = isPan ? +snapVal(valFromY(moveEvent.clientY - rect.top)).toFixed(2) : +snapVal(valFromY(moveEvent.clientY - rect.top)).toFixed(1); const key = t + '|' + v; if (key !== lastKey) { pts.push({ time: t, [isPan ? 'pan' : 'db']: v }); lastKey = key; } }; const handleMouseUp = () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); canvas.style.cursor = 'crosshair'; const merged = mergeNodes(curNodes, pts); if (onUpdateNodes) onUpdateNodes(merged); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); } else { const newNode = isPan ? { time: +snapTime(startTime).toFixed(3), pan: +snapVal(valFromY(e.clientY - rect.top)).toFixed(2) } : { time: +snapTime(startTime).toFixed(3), db: +snapVal(valFromY(e.clientY - rect.top)).toFixed(1) }; setSelectedNodeTime(newNode.time); const merged = mergeNodes(curNodes, [newNode]); if (onUpdateNodes) onUpdateNodes(merged); // Now drag this newly created node let working = [...merged]; let dragIdx = working.findIndex(n => n.time === newNode.time && (isPan ? n.pan === newNode.pan : n.db === newNode.db)); const handleMouseMove = moveEvent => { const currentX = moveEvent.clientX - rect.left + scrollLeft; const ct = Math.max(0, Math.min(wallDuration, currentX / zoom)); const val = snapVal(valFromY(moveEvent.clientY - rect.top)); const updated = isPan ? { time: Math.min(snapTime(ct), wallDuration), pan: val } : { time: Math.min(snapTime(ct), wallDuration), db: val }; const cleaned = mergeNodes(working.filter((_, i) => i !== dragIdx), [updated]); if (onUpdateNodes) onUpdateNodes(cleaned); working = [...cleaned]; dragIdx = working.findIndex(n => n.time === updated.time && (isPan ? n.pan === updated.pan : n.db === updated.db)); setSelectedNodeTime(updated.time); }; const handleMouseUp = () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); canvas.style.cursor = 'crosshair'; }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); } return; } // Select tool (default): drag to select range subTabAnchorRef.current = startTime; setSelectedNodeTime(null); onSelectRange(startTime, startTime); onPlayheadSet(startTime); const handleMouseMove = moveEvent => { const currentX = moveEvent.clientX - rect.left + scrollLeft; const ct = Math.max(0, Math.min(wallDuration, currentX / zoom)); const anchor = subTabAnchorRef.current ?? startTime; onSelectRange(Math.min(anchor, ct), Math.max(anchor, ct)); }; const handleMouseUp = () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }; const handleContextMenuInternal = e => { e.preventDefault(); e.stopPropagation(); const canvas = canvasRef.current; const rect = canvas.getBoundingClientRect(); const parent = canvas.parentElement; const scrollContainer = parent ? parent.parentElement : null; const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; const x = e.clientX - rect.left + scrollLeft; const clickTime = Math.max(0, Math.min(buffer.duration / (speed || 1.0), x / zoom)); onContextMenu(e, clickTime); }; // Double-click on automation curve to create a new node const handleDoubleClick = e => { const canvas = canvasRef.current; if (!canvas || !buffer) return; if (activeTool !== 'select' && activeTool !== 'pen') return; const rect = canvas.getBoundingClientRect(); const parent = canvas.parentElement; const scrollContainer = parent ? parent.parentElement : null; const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; const x = e.clientX - rect.left + scrollLeft; const clickTime = Math.max(0, Math.min(buffer.duration / (speed || 1.0), x / zoom)); const isPan = (graphMode || 'volume') === 'pan'; const curNodes = isPan ? panningNodes : volumeNodes; const cTop = 8; const cHeight = rect.height - 16; const valFromY = y => { if (isPan) return Math.max(-1, Math.min(1, -(y - cTop) / cHeight * 2 + 1)); const yOff = (y - cTop) / cHeight; return yOff <= 2 / 3 ? Math.max(0, Math.min(3, 3 * (1 - yOff * 3 / 2))) : Math.max(-30, Math.min(0, -30 * (yOff - 2 / 3) * 3)); }; // Interpolate value at click position from existing curve let interpolatedVal = valFromY(e.clientY - rect.top); if (curNodes.length >= 2) { const sorted = [...curNodes].sort((a, b) => a.time - b.time); for (let i = 0; i < sorted.length - 1; i++) { if (clickTime >= sorted[i].time && clickTime <= sorted[i + 1].time) { const t = (clickTime - sorted[i].time) / (sorted[i + 1].time - sorted[i].time); const v1 = isPan ? sorted[i].pan : sorted[i].db; const v2 = isPan ? sorted[i + 1].pan : sorted[i + 1].db; interpolatedVal = v1 + t * (v2 - v1); break; } } } const newNode = isPan ? { time: +Math.round(clickTime * 10) / 10, pan: +Math.round(interpolatedVal * 20) / 20 } : { time: +Math.round(clickTime * 10) / 10, db: +Math.round(interpolatedVal * 2) / 2 }; const merged = (() => { const map = new Map(); curNodes.forEach(n => map.set(n.time, n)); map.set(newNode.time, newNode); return Array.from(map.values()).sort((a, b) => a.time - b.time); })(); if (onUpdateNodes) onUpdateNodes(merged); }; return /*#__PURE__*/React.createElement("div", { style: { width: `${timelineWidth}px`, height: '100%', position: 'relative', overflow: 'hidden' } }, /*#__PURE__*/React.createElement("canvas", { ref: canvasRef, style: { position: 'absolute', top: 0, left: 0, imageRendering: 'pixelated' }, className: "cursor-crosshair rounded border border-zinc-800", onMouseDown: handleMouseDown, onDoubleClick: handleDoubleClick, onContextMenu: handleContextMenuInternal, onMouseMove: e => { if (canvasRef.current && e.altKey && onSpeedChange) { const rect = canvasRef.current.getBoundingClientRect(); const parent = canvasRef.current.parentElement; const scrollContainer = parent ? parent.parentElement : null; const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; const mx = e.clientX - rect.left + scrollLeft; const wClip = buffer.duration / speed * zoom; const tolerance = 8; canvasRef.current.style.cursor = Math.abs(mx - wClip) <= tolerance && !isStretchingRef.current ? 'ew-resize' : 'crosshair'; } else if (canvasRef.current && !isStretchingRef.current) { const rect = canvasRef.current.getBoundingClientRect(); const parent = canvasRef.current.parentElement; const scrollContainer = parent ? parent.parentElement : null; const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; const mx = e.clientX - rect.left + scrollLeft; const wClipPx = buffer.duration / (speed || 1.0) * zoom; const cH = canvasRef.current.height / (window.devicePixelRatio || 1) - 16; const btnX = wClipPx - 28 - 4; const btnY = 8 + cH - 14 - 2; const overBtn = mx >= btnX && mx <= btnX + 28 && e.clientY - rect.top >= btnY && e.clientY - rect.top <= btnY + 14; canvasRef.current.style.cursor = overBtn ? 'pointer' : 'crosshair'; } } })); }; const SubTabToolbar = ({ st, activeTool, setActiveTool, handleSubTabNormalizeWithValue, handleSubTabGainWithValue, handleSubTabPitch, handleSubTabStretch, handleSubTabFade, onPlayPause, onStop, onRewind, onForward, onLoop, onRecord, onRateChange, onCut, onCopy, onPaste, onGlue, snapValue, onSnapChange }) => { const isPlaying = st?.isPlaying || false; const isLooping = st?.isLooping || false; const isRecording = st?.isRecording || false; return /*#__PURE__*/React.createElement("div", { className: "daw-header flex h-16 items-center px-4 border-b daw-border gap-3 bg-zinc-800" }, /*#__PURE__*/React.createElement("div", { className: "flex items-center space-x-1 border-r border-zinc-700 pr-3" }, /*#__PURE__*/React.createElement("button", { className: `p-1 rounded ${activeTool === 'select' ? 'bg-cyan-700' : 'bg-zinc-700'}`, onClick: () => setActiveTool('select'), title: "Select Tool" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "mouse-pointer", className: "w-4 h-4" }))), /*#__PURE__*/React.createElement("button", { className: `p-1 rounded ${activeTool === 'grab' ? 'bg-cyan-700' : 'bg-zinc-700'}`, onClick: () => setActiveTool('grab'), title: "Grab Tool" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "hand", className: "w-4 h-4" }))), /*#__PURE__*/React.createElement("button", { className: `p-1 rounded ${activeTool === 'razor' ? 'bg-cyan-700' : 'bg-zinc-700'}`, onClick: () => setActiveTool('razor'), title: "Razor Tool" }, /*#__PURE__*/React.createElement("svg", { className: "w-4 h-4 text-orange-400", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round" }, /*#__PURE__*/React.createElement("path", { d: "M6 3h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z" }), /*#__PURE__*/React.createElement("path", { d: "M4 9h16l-3 9H7z" }), /*#__PURE__*/React.createElement("circle", { cx: "12", cy: "6", r: "1" }))), /*#__PURE__*/React.createElement("button", { className: `p-1 rounded ${activeTool === 'pen' ? 'bg-cyan-700' : 'bg-zinc-700'}`, onClick: () => setActiveTool('pen'), title: "Pen Tool" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "pen-tool", className: "w-4 h-4" }))), /*#__PURE__*/React.createElement("div", { className: "w-[1px] h-4 bg-zinc-800 mx-0.5" }), /*#__PURE__*/React.createElement("button", { onClick: onGlue, className: "p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-purple-400 transition", title: "Glue Clips" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "link", className: "w-3.5 h-3.5" }))), /*#__PURE__*/React.createElement("button", { onClick: onCut, className: "p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-red-400 transition", title: "Cut (Ctrl+X)" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "scissors", className: "w-3.5 h-3.5" }))), /*#__PURE__*/React.createElement("button", { onClick: onCopy, className: "p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-blue-400 transition", title: "Copy (Ctrl+C)" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "copy", className: "w-3.5 h-3.5" }))), /*#__PURE__*/React.createElement("button", { onClick: onPaste, className: "p-0.5 rounded text-zinc-400 hover:bg-zinc-800 hover:text-emerald-400 transition", title: "Paste (Ctrl+V)" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "clipboard", className: "w-3.5 h-3.5" })))), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1 border-r border-zinc-700 pr-3" }, /*#__PURE__*/React.createElement("span", { className: "text-xs text-zinc-500 font-bold uppercase" }, "Snap"), /*#__PURE__*/React.createElement("select", { value: snapValue || 'free', onChange: e => onSnapChange(e.target.value), className: "bg-zinc-850 text-zinc-300 text-xs px-1 py-0.5 rounded border border-zinc-800 focus:outline-none font-mono" }, /*#__PURE__*/React.createElement("option", { value: "free" }, "Free"), /*#__PURE__*/React.createElement("option", { value: "1" }, "1"), /*#__PURE__*/React.createElement("option", { value: "1/2" }, "1/2"), /*#__PURE__*/React.createElement("option", { value: "1/4" }, "1/4"), /*#__PURE__*/React.createElement("option", { value: "1/8" }, "1/8"), /*#__PURE__*/React.createElement("option", { value: "1/16" }, "1/16"), /*#__PURE__*/React.createElement("option", { value: "1/32" }, "1/32"))), /*#__PURE__*/React.createElement("div", { className: "flex items-center space-x-2 flex-1 overflow-x-auto no-scrollbar" }, /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1" }, /*#__PURE__*/React.createElement("span", { className: "text-xs text-zinc-400" }, "Normalize:"), /*#__PURE__*/React.createElement("input", { type: "range", min: "-12", max: "0", value: st.effects?.normalizeDb || 0, step: "0.1", onChange: e => handleSubTabNormalizeWithValue(st.id, parseFloat(e.target.value)), className: "w-20 h-1.5" }), /*#__PURE__*/React.createElement("span", { className: "text-xs text-zinc-500 w-10 text-right" }, st.effects?.normalizeDb || 0, " dB")), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1" }, /*#__PURE__*/React.createElement("span", { className: "text-xs text-zinc-400" }, "Gain:"), /*#__PURE__*/React.createElement("input", { type: "range", min: "-40", max: "24", value: st.effects?.gainDb || 0, step: "0.1", onChange: e => handleSubTabGainWithValue(st.id, parseFloat(e.target.value)), className: "w-20 h-1.5" }), /*#__PURE__*/React.createElement("span", { className: "text-xs text-zinc-500 w-10 text-right" }, st.effects?.gainDb || 0, " dB")), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1" }, /*#__PURE__*/React.createElement("span", { className: "text-xs text-zinc-400" }, "Pitch:"), /*#__PURE__*/React.createElement("input", { type: "range", min: "-12", max: "12", value: st.effects?.pitch || 0, step: "0.1", onChange: e => handleSubTabPitch(st.id, parseFloat(e.target.value)), className: "w-20 h-1.5" }), /*#__PURE__*/React.createElement("span", { className: "text-xs text-zinc-500 w-10 text-right" }, st.effects?.pitch || 0, " st")), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1" }, /*#__PURE__*/React.createElement("span", { className: "text-xs text-zinc-400" }, "Stretch:"), /*#__PURE__*/React.createElement("input", { type: "range", min: "50", max: "200", value: st.effects?.speedStretch || 100, step: "1", onChange: e => handleSubTabStretch(st.id, parseInt(e.target.value)), className: "w-20 h-1.5" }), /*#__PURE__*/React.createElement("span", { className: "text-xs text-zinc-500 w-10 text-right" }, st.effects?.speedStretch || 100, "%"))), /*#__PURE__*/React.createElement("div", { className: "flex items-center space-x-1 border-r border-zinc-700 pr-3" }, /*#__PURE__*/React.createElement("button", { onClick: () => handleSubTabFade(st.id, 'in'), className: "px-2 py-1 text-xs rounded hover:bg-zinc-700" }, "Fade In"), /*#__PURE__*/React.createElement("button", { onClick: () => handleSubTabFade(st.id, 'out'), className: "px-2 py-1 text-xs rounded hover:bg-zinc-700" }, "Fade Out")), /*#__PURE__*/React.createElement("div", { className: "flex items-center space-x-1 border-r border-zinc-700 pr-3" }, /*#__PURE__*/React.createElement("button", { onClick: onRewind, className: "w-8 h-8 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", title: "Rewind" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "skip-back", className: "w-4 h-4" }))), /*#__PURE__*/React.createElement("button", { onClick: onPlayPause, className: `w-8 h-8 flex items-center justify-center rounded border transition ${isPlaying ? 'bg-emerald-600 text-black border-emerald-500 hover:bg-emerald-500' : 'bg-cyan-600 text-white border-cyan-500 hover:bg-cyan-500'}`, title: isPlaying ? "Pause" : "Play" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": isPlaying ? 'pause' : 'play', className: "w-4 h-4 fill-current" }))), /*#__PURE__*/React.createElement("button", { onClick: onStop, className: "w-8 h-8 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", title: "Stop" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "square", className: "w-4 h-4 fill-current" }))), /*#__PURE__*/React.createElement("button", { onClick: onForward, className: "w-8 h-8 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", title: "Forward" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "skip-forward", className: "w-4 h-4" }))), /*#__PURE__*/React.createElement("button", { onClick: onLoop, className: `w-8 h-8 flex items-center justify-center rounded border transition ${isLooping ? 'bg-amber-600 text-black border-amber-500 hover:bg-amber-500' : 'bg-zinc-800 text-zinc-200 border-zinc-700 hover:bg-zinc-700'}`, title: isLooping ? "Loop On" : "Loop Off" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "repeat", className: "w-4 h-4" }))), /*#__PURE__*/React.createElement("button", { onClick: onRecord, className: `w-8 h-8 flex items-center justify-center rounded border transition ${isRecording ? 'bg-red-600 text-white border-red-500' : 'bg-zinc-800 text-zinc-200 border-zinc-700 hover:bg-zinc-700'}`, title: isRecording ? "Recording" : "Record" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "circle", className: "w-4 h-4" })))), /*#__PURE__*/React.createElement("div", { className: "flex items-center space-x-1" }, /*#__PURE__*/React.createElement("span", { className: "text-xs text-zinc-400" }, "Rate:"), /*#__PURE__*/React.createElement("select", { value: st.playbackRate || 1, onChange: e => onRateChange(parseFloat(e.target.value)), className: "bg-zinc-700 text-zinc-100 text-xs px-1 py-0.5 rounded border border-zinc-600" }, /*#__PURE__*/React.createElement("option", { value: "0.5" }, "0.5x"), /*#__PURE__*/React.createElement("option", { value: "0.75" }, "0.75x"), /*#__PURE__*/React.createElement("option", { value: "1" }, "1x"), /*#__PURE__*/React.createElement("option", { value: "1.25" }, "1.25x"), /*#__PURE__*/React.createElement("option", { value: "1.5" }, "1.5x"), /*#__PURE__*/React.createElement("option", { value: "2" }, "2x")))); }; // ── Graph Editor Canvas for Volume/Pan/Fade Automation ── const GraphEditorCanvas = ({ buffer, zoom, timelineWidth, volumeNodes, panningNodes, fadeInLen, fadeOutLen, onUpdateNodes, graphMode }) => { const canvasRef = useRef(null); const isDraggingNode = useRef(false); const dragNodeIdx = useRef(-1); const isCreatingNode = useRef(false); const getNodes = () => graphMode === 'pan' ? panningNodes : volumeNodes; const nodeLabel = n => graphMode === 'pan' ? `${n.pan.toFixed(2)}` : `${n.db.toFixed(1)}dB`; const nodeY = (n, h) => { if (graphMode === 'pan') return (1 - (n.pan + 1) / 2) * h; const zeroY = 2 / 3 * h; return n.db >= 0 ? zeroY - n.db / 3 * (2 / 3 * h) : zeroY + -n.db / 30 * (1 / 3 * h); }; const nodeValFromY = (y, h) => { if (graphMode === 'pan') return -(y / h * 2 - 1); const yOff = y / h; return yOff <= 2 / 3 ? 3 * (1 - yOff * 3 / 2) : -30 * (yOff - 2 / 3) * 3; }; useEffect(() => { const canvas = canvasRef.current; if (!canvas || !buffer) return; const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null; const scrollLeft = wrapper ? wrapper.scrollLeft : 0; const vWidth = wrapper ? wrapper.clientWidth : 1200; const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200)); const h = canvas.parentElement ? canvas.parentElement.clientHeight : 200; canvas.width = Math.round(drawWidth * dpr); canvas.height = Math.round(h * dpr); ctx.scale(dpr, dpr); ctx.imageSmoothingEnabled = false; canvas.style.position = 'absolute'; canvas.style.left = `${scrollLeft}px`; canvas.style.width = `${drawWidth}px`; canvas.style.height = `${h}px`; ctx.fillStyle = '#1a1a2e'; ctx.fillRect(0, 0, drawWidth, h); ctx.strokeStyle = '#2a2a4e'; ctx.lineWidth = 0.5; for (let t = 0; t <= buffer.duration; t += 0.5) { const x = t / buffer.duration * drawWidth; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); } for (let i = 0; i <= 10; i++) { const y = i / 10 * h; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(drawWidth, y); ctx.stroke(); } if (fadeInLen > 0) { const fadeX = fadeInLen / buffer.duration * drawWidth; ctx.fillStyle = 'rgba(16, 185, 129, 0.12)'; ctx.fillRect(0, 0, fadeX, h); } if (fadeOutLen > 0) { const fadeX = (buffer.duration - fadeOutLen) / buffer.duration * drawWidth; const fadeW = fadeOutLen / buffer.duration * drawWidth; ctx.fillStyle = 'rgba(239, 68, 68, 0.12)'; ctx.fillRect(fadeX, 0, fadeW, h); } const nodes = getNodes(); if (nodes.length > 0) { ctx.strokeStyle = graphMode === 'pan' ? '#a855f7' : '#06b6d4'; ctx.lineWidth = 2; ctx.beginPath(); nodes.forEach((n, i) => { const x = n.time / buffer.duration * drawWidth; const y = nodeY(n, h); if (i === 0) ctx.moveTo(x, y);else ctx.lineTo(x, y); }); ctx.stroke(); nodes.forEach((n, i) => { const x = n.time / buffer.duration * drawWidth; const y = nodeY(n, h); ctx.fillStyle = graphMode === 'pan' ? '#a855f7' : '#06b6d4'; ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#e4e4e7'; ctx.font = '9px monospace'; ctx.fillText(nodeLabel(n), x + 8, y + 3); }); } else { ctx.fillStyle = '#52525b'; ctx.font = '11px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(graphMode === 'pan' ? 'Click to add Pan points' : 'Click to add Volume points', drawWidth / 2, h / 2); ctx.textAlign = 'start'; } const zeroY = graphMode === 'pan' ? h / 2 : nodeY({ db: 0 }, h); ctx.strokeStyle = graphMode === 'pan' ? '#a855f744' : '#06b6d444'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(0, zeroY); ctx.lineTo(drawWidth, zeroY); ctx.stroke(); ctx.setLineDash([]); }, [buffer, zoom, timelineWidth, volumeNodes, panningNodes, fadeInLen, fadeOutLen, graphMode]); const handleMouseDown = e => { const canvas = canvasRef.current; if (!canvas || !buffer) return; const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const time = x / rect.width * buffer.duration; const val = nodeValFromY(y, rect.height); const nodes = getNodes(); const snapped = Math.max(-30, Math.min(3, val)); const snappedPan = Math.max(-1, Math.min(1, val)); const threshold = 12 / rect.width * buffer.duration; const nearIdx = nodes.findIndex(n => Math.abs(n.time - time) < threshold); if (nearIdx >= 0) { isDraggingNode.current = true; dragNodeIdx.current = nearIdx; return; } const newNode = graphMode === 'pan' ? { time: +time.toFixed(3), pan: +snappedPan.toFixed(2) } : { time: +time.toFixed(3), db: +snapped.toFixed(1) }; const sorted = [...nodes, newNode].sort((a, b) => a.time - b.time); onUpdateNodes(sorted); const newIdx = sorted.findIndex(n => n.time === newNode.time && (graphMode === 'pan' ? n.pan : n.db) === (graphMode === 'pan' ? newNode.pan : newNode.db)); isDraggingNode.current = true; dragNodeIdx.current = newIdx; isCreatingNode.current = true; }; const handleMouseMove = e => { if (!isDraggingNode.current || dragNodeIdx.current < 0) return; const canvas = canvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const time = Math.max(0, Math.min(buffer.duration, x / rect.width * buffer.duration)); const val = nodeValFromY(y, rect.height); const nodes = [...getNodes()]; nodes[dragNodeIdx.current] = graphMode === 'pan' ? { time: +time.toFixed(3), pan: +Math.max(-1, Math.min(1, val)).toFixed(2) } : { time: +time.toFixed(3), db: +Math.max(-30, Math.min(3, val)).toFixed(1) }; onUpdateNodes(nodes.sort((a, b) => a.time - b.time)); }; const handleMouseUp = () => { isDraggingNode.current = false; dragNodeIdx.current = -1; isCreatingNode.current = false; }; const handleContextMenu = e => { e.preventDefault(); const canvas = canvasRef.current; if (!canvas || !buffer) return; const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const time = x / rect.width * buffer.duration; const threshold = 12 / rect.width * buffer.duration; const nodes = getNodes(); const nearIdx = nodes.findIndex(n => Math.abs(n.time - time) < threshold); if (nearIdx >= 0) onUpdateNodes(nodes.filter((_, i) => i !== nearIdx)); }; return /*#__PURE__*/React.createElement("div", { style: { width: `${timelineWidth}px`, height: '100%', position: 'relative', overflow: 'hidden' } }, /*#__PURE__*/React.createElement("canvas", { ref: canvasRef, style: { position: 'absolute', top: 0, left: 0, imageRendering: 'pixelated' }, className: "cursor-crosshair rounded border border-zinc-700", onMouseDown: handleMouseDown, onMouseMove: handleMouseMove, onMouseUp: handleMouseUp, onMouseLeave: handleMouseUp, onContextMenu: handleContextMenu })); }; const AuthModal = ({ isOpen, mode, forceMandatory, onClose, onSuccess }) => { if (!isOpen) return null; const [activeTab, setActiveTab] = useState(mode || 'login'); const [username, setUsername] = useState('admin'); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [oldPassword, setOldPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); useEffect(() => { if (mode) setActiveTab(mode); if (mode === 'force_change' && !oldPassword) { setOldPassword('admin123'); } }, [mode]); const handleSubmit = async e => { e.preventDefault(); setError(''); setLoading(true); try { if (activeTab === 'login') { const targetUsername = username.trim() || 'admin'; const targetPwd = password.trim() || 'admin123'; const res = await window.SonicAPI.login(targetUsername, targetPwd); localStorage.setItem('sonic_token', res.access_token); localStorage.setItem('sonic_user', JSON.stringify(res.user)); if (res.user && res.user.must_change_password) { setOldPassword(targetPwd); } onSuccess(res.user, res.access_token); } else if (activeTab === 'register') { const res = await window.SonicAPI.register(username.trim(), email.trim(), password.trim()); localStorage.setItem('sonic_token', res.access_token); localStorage.setItem('sonic_user', JSON.stringify(res.user)); onSuccess(res.user, res.access_token); } else if (activeTab === 'force_change') { const res = await window.SonicAPI.changePassword(oldPassword.trim(), newPassword.trim()); localStorage.setItem('sonic_token', res.access_token); const user = JSON.parse(localStorage.getItem('sonic_user') || '{}'); user.must_change_password = false; localStorage.setItem('sonic_user', JSON.stringify(user)); onSuccess(user, res.access_token); } } catch (err) { setError(err.message || (activeTab === 'login' ? 'Tài khoản hoặc mật khẩu không chính xác. (Nếu bạn đã đổi mật khẩu trước đó, vui lòng nhập mật khẩu mới mà bạn đã tạo)' : 'Thao tác không thành công')); } finally { setLoading(false); } }; const isForceMode = activeTab === 'force_change'; const canClose = !forceMandatory && !isForceMode; return /*#__PURE__*/React.createElement("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md" }, /*#__PURE__*/React.createElement("div", { className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-md p-6 text-slate-200" }, /*#__PURE__*/React.createElement("div", { className: "flex justify-between items-center pb-4 border-b border-[#383838]" }, /*#__PURE__*/React.createElement("h3", { className: "text-lg font-bold text-teal-400" }, isForceMode ? '⚠️ Bắt Buộc Đổi Mật Khẩu Khởi Tạo' : activeTab === 'login' ? '🔐 Đăng Nhập Hệ Thống' : '📝 Đăng Ký Tài Khoản'), canClose && /*#__PURE__*/React.createElement("button", { onClick: onClose, className: "text-slate-400 hover:text-slate-200" }, "✕")), error && /*#__PURE__*/React.createElement("div", { className: "mt-4 p-3 bg-red-900/40 border border-red-700 rounded-lg text-red-200 text-sm" }, error), /*#__PURE__*/React.createElement("form", { onSubmit: handleSubmit, className: "mt-4 space-y-4" }, isForceMode ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("p", { className: "text-xs text-amber-400 bg-amber-950/60 p-2.5 border border-amber-800/80 rounded leading-relaxed" }, "🔒 Tài khoản của bạn đang dùng mật khẩu khởi tạo mặc định. Để bảo mật hệ thống, bạn phải đổi mật khẩu mới trước khi tiếp tục."), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { className: "block text-xs font-semibold mb-1 text-slate-400" }, "Mật khẩu hiện tại (Mặc định: admin123)"), /*#__PURE__*/React.createElement("input", { type: "password", autoComplete: "current-password", required: true, value: oldPassword, onChange: e => setOldPassword(e.target.value), className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { className: "block text-xs font-semibold mb-1 text-slate-400" }, "Mật khẩu mới"), /*#__PURE__*/React.createElement("input", { type: "password", autoComplete: "new-password", required: true, value: newPassword, onChange: e => setNewPassword(e.target.value), className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" }))) : /*#__PURE__*/React.createElement(React.Fragment, null, activeTab === 'login' ? /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { className: "block text-xs font-semibold mb-1 text-slate-400" }, "Tên đăng nhập ", /*#__PURE__*/React.createElement("span", { className: "text-teal-400 font-normal" }, "(Tùy chọn - Admin có thể bỏ trống)")), /*#__PURE__*/React.createElement("input", { type: "text", placeholder: "Mặc định: admin", value: username, onChange: e => setUsername(e.target.value), className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" })) : /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { className: "block text-xs font-semibold mb-1 text-slate-400" }, "Tên đăng nhập"), /*#__PURE__*/React.createElement("input", { type: "text", required: true, value: username, onChange: e => setUsername(e.target.value), className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" })), activeTab === 'register' && /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { className: "block text-xs font-semibold mb-1 text-slate-400" }, "Email"), /*#__PURE__*/React.createElement("input", { type: "email", required: true, value: email, onChange: e => setEmail(e.target.value), className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { className: "block text-xs font-semibold mb-1 text-slate-400" }, "Mật khẩu ", activeTab === 'login' && /*#__PURE__*/React.createElement("span", { className: "text-amber-400 font-normal" }, "(Lần đầu: admin123)")), /*#__PURE__*/React.createElement("input", { type: "password", autoComplete: activeTab === 'login' ? 'current-password' : 'new-password', required: true, value: password, onChange: e => setPassword(e.target.value), className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500" }))), /*#__PURE__*/React.createElement("button", { type: "submit", disabled: loading, className: "w-full py-2 bg-teal-600 hover:bg-teal-500 text-white font-semibold rounded-lg shadow transition duration-150" }, loading ? 'Đang xác thực...' : isForceMode ? 'Đổi Mật Khẩu Ngay' : activeTab === 'login' ? 'Đăng Nhập System' : 'Tạo Tài Khoản Mới'), activeTab === 'login' && !isForceMode && /*#__PURE__*/React.createElement("button", { type: "button", onClick: () => { setUsername('admin'); setPassword('admin123'); setError(''); }, className: "w-full mt-2 py-1.5 bg-amber-950/60 hover:bg-amber-900/80 text-amber-300 border border-amber-800/60 text-xs font-semibold rounded-lg transition flex items-center justify-center gap-1.5" }, "🔑 Điền Nhanh Tài Khoản Admin (admin / admin123)")), !isForceMode && /*#__PURE__*/React.createElement("div", { className: "mt-4 pt-4 border-t border-[#383838] text-center text-xs text-slate-400" }, activeTab === 'login' ? /*#__PURE__*/React.createElement("span", null, "Chưa có tài khoản? ", /*#__PURE__*/React.createElement("button", { onClick: () => setActiveTab('register'), className: "text-teal-400 hover:underline" }, "Đăng ký ngay")) : /*#__PURE__*/React.createElement("span", null, "Đã có tài khoản? ", /*#__PURE__*/React.createElement("button", { onClick: () => setActiveTab('login'), className: "text-teal-400 hover:underline" }, "Đăng nhập"))))); }; const AIConfigModal = ({ isOpen, onClose, onConfigSaved }) => { 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!'); if (onConfigSaved) onConfigSaved(providers); } catch (err) { setError(err.message || 'Lỗi khi lưu cấu hình AI'); } finally { setLoading(false); } }; const updateProviderField = (id, field, value) => { setProviders(prev => prev.map(p => p.id === id ? { ...p, [field]: value } : p)); }; const activeProvider = providers.find(p => p.id === selectedId) || providers[0]; return /*#__PURE__*/React.createElement("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm select-none" }, /*#__PURE__*/React.createElement("div", { className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-2xl p-6 text-slate-200" }, /*#__PURE__*/React.createElement("div", { className: "flex justify-between items-center pb-4 border-b border-[#383838]" }, /*#__PURE__*/React.createElement("h3", { className: "text-lg font-bold text-cyan-400 flex items-center gap-2" }, "🤖 Quản Lý & Cấu Hình AI Providers"), /*#__PURE__*/React.createElement("button", { onClick: onClose, className: "text-slate-400 hover:text-slate-200" }, "✕")), msg && /*#__PURE__*/React.createElement("div", { className: "mt-3 p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs" }, msg), error && /*#__PURE__*/React.createElement("div", { className: "mt-3 p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs" }, error), /*#__PURE__*/React.createElement("div", { className: "mt-4 grid grid-cols-3 gap-4" }, /*#__PURE__*/React.createElement("div", { className: "space-y-1.5 border-r border-[#383838] pr-3" }, /*#__PURE__*/React.createElement("span", { className: "text-xs uppercase font-bold text-slate-400 block mb-2" }, "Providers"), providers.map(p => /*#__PURE__*/React.createElement("button", { key: p.id, tabIndex: 0, onClick: () => setSelectedId(p.id), onKeyDown: e => { if (e.key === ' ') { e.preventDefault(); updateProviderField(p.id, 'is_active', !p.is_active); } }, className: `w-full text-left px-3 py-2 rounded-lg text-xs font-semibold flex items-center justify-between transition ${selectedId === p.id ? 'bg-cyan-600 text-white shadow' : 'bg-[#1e1e1e] text-slate-300 hover:bg-[#2e2e2e]'}` }, /*#__PURE__*/React.createElement("span", { className: "truncate" }, p.name), p.is_active && /*#__PURE__*/React.createElement("span", { className: "w-2 h-2 rounded-full bg-emerald-400" }))), /*#__PURE__*/React.createElement("div", { className: "flex gap-1 mt-2" }, /*#__PURE__*/React.createElement("button", { onClick: e => { e.stopPropagation(); const newId = 'provider_' + Date.now(); setProviders(prev => [...prev, { id: newId, name: 'New Provider', provider_type: 'openai_compatible', api_base_url: 'https://api.openai.com/v1', api_key: '', model_name: 'gpt-4o-mini', temperature: 0.7, is_active: false }]); setSelectedId(newId); }, className: "flex-1 px-2 py-1 bg-emerald-700 hover:bg-emerald-600 text-white text-xs font-bold rounded" }, "+ Thêm"), providers.length > 1 && /*#__PURE__*/React.createElement("button", { onClick: e => { e.stopPropagation(); if (confirm(`Xóa provider "${providers.find(p => p.id === selectedId)?.name}"?`)) { setProviders(prev => { const filtered = prev.filter(p => p.id !== selectedId); if (filtered.length > 0) setSelectedId(filtered[0].id); return filtered; }); } }, className: "px-2 py-1 bg-red-800 hover:bg-red-700 text-white text-xs font-bold rounded" }, "Xóa"), providers.length > 1 && /*#__PURE__*/React.createElement("button", { onClick: function (e) { e.stopPropagation(); var provs = providers.slice(); var pos = -1; for (var pi = 0; pi < provs.length; pi++) { if (provs[pi].id === selectedId) { pos = pi; break; } } if (pos > 0) { var tmp = provs[pos]; provs[pos] = provs[pos - 1]; provs[pos - 1] = tmp; setProviders(provs); } }, className: "px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-white text-xs font-bold rounded", title: "Di chuyển lên" }, "▲"), providers.length > 1 && /*#__PURE__*/React.createElement("button", { onClick: function (e) { e.stopPropagation(); var provs = providers.slice(); var pos = -1; for (var pi = 0; pi < provs.length; pi++) { if (provs[pi].id === selectedId) { pos = pi; break; } } if (pos < provs.length - 1) { var tmp = provs[pos]; provs[pos] = provs[pos + 1]; provs[pos + 1] = tmp; setProviders(provs); } }, className: "px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-white text-xs font-bold rounded", title: "Di chuyển xuống" }, "▼"))), activeProvider && /*#__PURE__*/React.createElement("form", { onSubmit: handleSave, className: "col-span-2 space-y-3.5" }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { className: "block text-xs font-semibold text-slate-400 mb-1" }, "Tên Provider"), /*#__PURE__*/React.createElement("input", { type: "text", value: activeProvider.name, onChange: e => updateProviderField(activeProvider.id, 'name', e.target.value), className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-cyan-500" })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { className: "block text-xs font-semibold text-slate-400 mb-1" }, "API Base URL (Endpoint)"), /*#__PURE__*/React.createElement("input", { type: "text", value: activeProvider.api_base_url || '', onChange: e => updateProviderField(activeProvider.id, 'api_base_url', e.target.value), className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-cyan-300 focus:outline-none focus:border-cyan-500", placeholder: "https://api.openai.com/v1" })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { className: "block text-xs font-semibold text-slate-400 mb-1" }, "API Key Cá Nhân"), /*#__PURE__*/React.createElement("input", { type: "password", value: activeProvider.api_key || '', onChange: e => updateProviderField(activeProvider.id, 'api_key', e.target.value), className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-slate-200 focus:outline-none focus:border-cyan-500", placeholder: "sk-..." })), /*#__PURE__*/React.createElement("div", { className: "grid grid-cols-2 gap-3" }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { className: "block text-xs font-semibold text-slate-400 mb-1" }, "Model Name"), /*#__PURE__*/React.createElement("input", { type: "text", value: activeProvider.model_name || '', onChange: e => updateProviderField(activeProvider.id, 'model_name', e.target.value), className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-amber-300 focus:outline-none focus:border-cyan-500" })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { className: "block text-xs font-semibold text-slate-400 mb-1" }, "Temperature"), /*#__PURE__*/React.createElement("input", { type: "number", step: "0.1", min: "0", max: "2", value: activeProvider.temperature ?? 0.7, onChange: e => updateProviderField(activeProvider.id, 'temperature', parseFloat(e.target.value)), className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-cyan-500" }))), /*#__PURE__*/React.createElement("div", { className: "pt-2 flex items-center justify-between" }, /*#__PURE__*/React.createElement("label", { className: "flex items-center gap-2 cursor-pointer text-xs text-slate-300" }, /*#__PURE__*/React.createElement("input", { type: "checkbox", checked: activeProvider.is_active, onChange: e => updateProviderField(activeProvider.id, 'is_active', e.target.checked), className: "rounded accent-cyan-500" }), "Kích hoạt Provider này"), /*#__PURE__*/React.createElement("button", { type: "submit", disabled: loading, className: "px-4 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-white font-semibold text-xs rounded-lg shadow transition" }, loading ? 'Đang lưu...' : 'Lưu Cấu Hình AI')))))); }; const ProfileModal = ({ isOpen, onClose, tracks, setTracks, setSelectedTrackId, projectName, setProjectName, currentProjectId, setCurrentProjectId, showToast }) => { if (!isOpen) return null; const [activeTab, setActiveTab] = useState('account'); 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); const [dragOfs, setDragOfs] = useState({ x: 0, y: 0 }); const dragRef = useRef({ active: false, startX: 0, startY: 0, ofsX: 0, ofsY: 0 }); // Projects list state const [projectsList, setProjectsList] = useState([]); const [loadingProjects, setLoadingProjects] = useState(false); // Files list state const [filesList, setFilesList] = useState([]); const [loadingFiles, setLoadingFiles] = useState(false); // Confirmation modal state const [confirmModal, setConfirmModal] = useState(null); const handleDragStart = e => { const r = dragRef.current; r.active = true; r.startX = e.clientX; r.startY = e.clientY; r.ofsX = dragOfs.x; r.ofsY = dragOfs.y; const onMove = ev => { if (!r.active) return; setDragOfs({ x: r.ofsX + ev.clientX - r.startX, y: r.ofsY + ev.clientY - r.startY }); }; const onUp = () => { r.active = false; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }; useEffect(() => { if (isOpen) { fetchProfile(); if (activeTab === 'projects') fetchProjects(); if (activeTab === 'files') fetchFiles(); } }, [isOpen, activeTab]); 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 fetchProjects = async () => { setLoadingProjects(true); try { const data = await window.SonicAPI.listCloudProjects(); setProjectsList(data || []); } catch (e) { showToast(e.message || 'Không thể tải danh sách dự án', 'error'); } finally { setLoadingProjects(false); } }; const fetchFiles = async () => { setLoadingFiles(true); try { const activeFileIds = tracks.map(t => t.serverFileId).filter(Boolean); const data = await window.SonicAPI.listMyFiles(activeFileIds); setFilesList(data || []); } catch (e) { showToast(e.message || 'Không thể tải danh sách tệp tin', 'error'); } finally { setLoadingFiles(false); } }; const handleOpenProject = async projectId => { if (!confirm("Bạn có muốn mở dự án này? Các thay đổi chưa lưu trên workspace hiện tại sẽ bị mất.")) return; try { const proj = await window.SonicAPI.getCloudProject(projectId); const parsed = JSON.parse(proj.data_json); const restored = (parsed.tracks || []).map(t => ({ ...t, buffer: null, channelInfo: t.channelInfo || null, clips: t.clips || [], serverFileId: t.serverFileId || null })); setTracks(restored); setSelectedTrackId(restored[0]?.id || '1'); setProjectName(proj.name); setCurrentProjectId(proj.id); localStorage.setItem('sonic_project_name', proj.name); localStorage.setItem('sonic_project_id', proj.id); showToast(`Đã nạp dự án "${proj.name}" thành công!`, "success"); onClose(); } catch (e) { showToast(e.message || "Lỗi khi nạp dự án", "error"); } }; const handleDeleteProject = async (projectId, e) => { e.stopPropagation(); setConfirmModal({ title: "Xóa dự án Cloud", message: "Bạn có chắc chắn muốn xóa dự án này khỏi Cloud? Hành động này không thể hoàn tác.", onConfirm: async () => { try { await window.SonicAPI.deleteCloudProject(projectId); showToast("Đã xóa dự án thành công!", "success"); fetchProjects(); fetchProfile(); } catch (err) { showToast(err.message || "Lỗi khi xóa dự án", "error"); } } }); }; const handleDeleteFile = async fileId => { if (!confirm(`Bạn có chắc chắn muốn xóa tệp tin ${fileId}?`)) return; try { await window.SonicAPI.deleteMyFile(fileId); showToast("Đã xóa tệp tin thành công!", "success"); fetchFiles(); fetchProfile(); } catch (err) { showToast(err.message || "Lỗi khi xóa tệp tin", "error"); } }; const handleCleanUnusedFiles = async () => { const unusedFiles = filesList.filter(f => !f.is_in_use); if (unusedFiles.length === 0) { showToast("Không có tập tin rác nào để dọn dẹp.", "info"); return; } if (!confirm(`Bạn có chắc chắn muốn xóa tất cả ${unusedFiles.length} tập tin rác để giải phóng dung lượng?`)) return; let successCount = 0; for (const file of unusedFiles) { try { await window.SonicAPI.deleteMyFile(file.file_id); successCount++; } catch (e) { console.error("Lỗi xóa file rác: ", file.file_id, e); } } showToast(`Đã dọn dẹp thành công ${successCount}/${unusedFiles.length} tập tin rác!`, "success"); fetchFiles(); fetchProfile(); }; 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); } }; const modalStyle = { left: `calc(50% + ${dragOfs.x}px)`, top: `calc(50% + ${dragOfs.y}px)`, transform: 'translate(-50%, -50%)' }; return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { key: "backdrop", className: "fixed inset-0 z-40 bg-black/70 backdrop-blur-sm", onClick: onClose }), confirmModal && /*#__PURE__*/React.createElement("div", { key: "confirm-overlay", className: "fixed inset-0 z-[60] flex items-center justify-center bg-black/50" }, /*#__PURE__*/React.createElement("div", { className: "bg-[#262626] border border-[#383838] rounded-lg shadow-2xl p-5 max-w-sm w-full text-slate-200" }, /*#__PURE__*/React.createElement("h4", { className: "text-sm font-bold text-rose-400 mb-2" }, confirmModal.title), /*#__PURE__*/React.createElement("p", { className: "text-xs text-slate-300 mb-4" }, confirmModal.message), /*#__PURE__*/React.createElement("div", { className: "flex justify-end gap-2" }, /*#__PURE__*/React.createElement("button", { onClick: () => setConfirmModal(null), className: "px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 text-slate-300 rounded text-xs font-semibold" }, "Hủy"), /*#__PURE__*/React.createElement("button", { onClick: () => { const fn = confirmModal.onConfirm; setConfirmModal(null); fn(); }, className: "px-3 py-1.5 bg-rose-700 hover:bg-rose-600 text-white rounded text-xs font-semibold" }, "Xác nhận xóa")))), /*#__PURE__*/React.createElement("div", { key: "dialog", className: "fixed z-50 bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-2xl p-6 text-slate-200 flex flex-col max-h-[85vh]", style: modalStyle }, /*#__PURE__*/React.createElement("div", { className: "flex justify-between items-center pb-3 border-b border-[#383838] shrink-0 cursor-grab active:cursor-grabbing select-none", onMouseDown: handleDragStart }, /*#__PURE__*/React.createElement("h3", { className: "text-md font-bold text-teal-400 flex items-center gap-1.5" }, "👤 Hồ Sơ Cá Nhân & Quản Lý Dự Án"), /*#__PURE__*/React.createElement("button", { onClick: onClose, className: "text-slate-400 hover:text-slate-200" }, "✕")), /*#__PURE__*/React.createElement("div", { className: "flex gap-2 border-b border-[#383838] py-2 shrink-0 text-xs font-semibold" }, [/*#__PURE__*/React.createElement("button", { key: "tab-acc", onClick: () => setActiveTab('account'), className: `px-3 py-1.5 rounded transition ${activeTab === 'account' ? 'bg-teal-950/60 text-teal-300 border border-teal-800' : 'text-slate-400 hover:text-slate-200'}` }, "Tài Khoản"), /*#__PURE__*/React.createElement("button", { key: "tab-proj", onClick: () => setActiveTab('projects'), className: `px-3 py-1.5 rounded transition ${activeTab === 'projects' ? 'bg-teal-950/60 text-teal-300 border border-teal-800' : 'text-slate-400 hover:text-slate-200'}` }, "Dự Án Cloud"), /*#__PURE__*/React.createElement("button", { key: "tab-files", onClick: () => setActiveTab('files'), className: `px-3 py-1.5 rounded transition ${activeTab === 'files' ? 'bg-teal-950/60 text-teal-300 border border-teal-800' : 'text-slate-400 hover:text-slate-200'}` }, "Tập Tin Của Tôi")]), /*#__PURE__*/React.createElement("div", { className: "flex-1 overflow-y-auto pt-4 space-y-4 pr-1 min-h-[300px]" }, activeTab === 'account' && profile ? [/*#__PURE__*/React.createElement("div", { key: "quota-info", className: "bg-[#1e1e1e] p-4 rounded-lg border border-[#333] grid grid-cols-2 gap-4 text-xs" }, [/*#__PURE__*/React.createElement("div", { key: "username" }, [/*#__PURE__*/React.createElement("span", { className: "text-slate-500 block" }, "Tên người dùng"), /*#__PURE__*/React.createElement("span", { className: "font-bold text-teal-300 text-sm" }, profile.username)]), /*#__PURE__*/React.createElement("div", { key: "role" }, [/*#__PURE__*/React.createElement("span", { className: "text-slate-500 block" }, "Vai trò"), /*#__PURE__*/React.createElement("span", { className: "uppercase font-semibold text-amber-400" }, profile.role)]), /*#__PURE__*/React.createElement("div", { key: "email" }, [/*#__PURE__*/React.createElement("span", { className: "text-slate-500 block" }, "Email"), /*#__PURE__*/React.createElement("span", null, profile.email)]), /*#__PURE__*/React.createElement("div", { key: "quota" }, [/*#__PURE__*/React.createElement("span", { className: "text-slate-500 block" }, "Dung lượng Quota"), /*#__PURE__*/React.createElement("span", { className: "font-semibold text-slate-200" }, `${profile.quota.used_mb} MB / ${profile.quota.storage_limit_mb} MB`)])]), /*#__PURE__*/React.createElement("div", { key: "progress" }, [/*#__PURE__*/React.createElement("div", { className: "flex justify-between text-xs mb-1" }, [/*#__PURE__*/React.createElement("span", { className: "text-slate-400" }, "Tiến trình sử dụng bộ nhớ Server"), /*#__PURE__*/React.createElement("span", { className: "font-bold text-teal-400" }, `${(profile.quota.used_mb / profile.quota.storage_limit_mb * 100).toFixed(1)}%`)]), /*#__PURE__*/React.createElement("div", { className: "w-full h-2 bg-slate-800 rounded-full overflow-hidden" }, [/*#__PURE__*/React.createElement("div", { className: "h-full bg-teal-500 rounded-full transition-all duration-300", style: { width: `${Math.min(100, profile.quota.used_mb / profile.quota.storage_limit_mb * 100)}%` } })])]), /*#__PURE__*/React.createElement("form", { key: "pwd-form", onSubmit: handleChangePassword, className: "pt-4 border-t border-[#383838] space-y-3" }, [/*#__PURE__*/React.createElement("h4", { className: "text-xs font-bold text-slate-300 uppercase" }, "Thay Đổi Mật Khẩu"), msg && /*#__PURE__*/React.createElement("div", { className: "p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs" }, msg), error && /*#__PURE__*/React.createElement("div", { className: "p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs" }, error), /*#__PURE__*/React.createElement("div", { key: "old" }, [/*#__PURE__*/React.createElement("label", { className: "block text-xs text-slate-400 mb-1" }, "Mật khẩu cũ"), /*#__PURE__*/React.createElement("input", { type: "password", autoComplete: "current-password", required: true, value: oldPassword, onChange: e => setOldPassword(e.target.value), className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs focus:outline-none focus:border-teal-500" })]), /*#__PURE__*/React.createElement("div", { key: "new" }, [/*#__PURE__*/React.createElement("label", { className: "block text-xs text-slate-400 mb-1" }, "Mật khẩu mới"), /*#__PURE__*/React.createElement("input", { type: "password", autoComplete: "new-password", required: true, value: newPassword, onChange: e => setNewPassword(e.target.value), className: "w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs focus:outline-none focus:border-teal-500" })]), /*#__PURE__*/React.createElement("button", { type: "submit", disabled: loading, className: "w-full py-1.5 bg-teal-600 hover:bg-teal-500 text-white font-semibold text-xs rounded transition" }, loading ? 'Đang cập nhật...' : 'Cập Nhật Mật Khẩu')])] : activeTab === 'projects' ? [loadingProjects ? /*#__PURE__*/React.createElement("div", { key: "loading", className: "text-center py-8 text-xs text-zinc-500" }, "Đang tải danh sách dự án...") : projectsList.length === 0 ? /*#__PURE__*/React.createElement("div", { key: "empty", className: "text-center py-8 text-xs text-zinc-500" }, "Bạn chưa có dự án nào lưu trên Cloud.") : /*#__PURE__*/React.createElement("div", { key: "list", className: "space-y-1.5" }, [projectsList.map(proj => /*#__PURE__*/React.createElement("div", { key: proj.id, onClick: () => handleOpenProject(proj.id), className: "flex items-center justify-between p-3 bg-[#1e1e1e] hover:bg-zinc-800 rounded border border-zinc-800 transition cursor-pointer text-xs group" }, [/*#__PURE__*/React.createElement("div", { key: "meta" }, [/*#__PURE__*/React.createElement("div", { className: "font-bold text-slate-200 group-hover:text-teal-400" }, proj.name), /*#__PURE__*/React.createElement("div", { className: "text-[10px] text-zinc-500 mt-0.5" }, `Dung lượng: ${proj.size_mb} MB | Cập nhật: ${new Date(proj.updated_at * 1000).toLocaleString()}`)]), /*#__PURE__*/React.createElement("div", { key: "actions", className: "flex items-center gap-1.5" }, [/*#__PURE__*/React.createElement("button", { onClick: e => { e.stopPropagation(); handleOpenProject(proj.id); }, className: "px-2.5 py-1 bg-teal-800/80 hover:bg-teal-700 text-teal-200 rounded font-semibold text-[10px]" }, "MỞ"), /*#__PURE__*/React.createElement("button", { onClick: e => handleDeleteProject(proj.id, e), className: "px-2 py-1 bg-rose-950/60 hover:bg-rose-900/80 text-rose-300 rounded font-semibold text-[10px]" }, "XÓA")])]))])] : activeTab === 'files' ? [/*#__PURE__*/React.createElement("div", { key: "cleanup-header", className: "flex items-center justify-between bg-zinc-900/60 p-2.5 rounded border border-zinc-800 text-xs shrink-0 mb-3" }, [/*#__PURE__*/React.createElement("span", { className: "text-zinc-400 text-[10px]" }, "💡 Các tập tin không dùng trong dự án nào sẽ được đánh dấu 'Không dùng' và có thể xóa để giải phóng dung lượng."), /*#__PURE__*/React.createElement("button", { onClick: handleCleanUnusedFiles, className: "px-2.5 py-1 bg-rose-700 hover:bg-rose-600 text-white rounded font-bold text-[10px] uppercase flex items-center gap-1" }, "🧹 Dọn dẹp tệp rác")]), loadingFiles ? /*#__PURE__*/React.createElement("div", { key: "loading", className: "text-center py-8 text-xs text-zinc-500" }, "Đang tải danh sách tập tin...") : filesList.length === 0 ? /*#__PURE__*/React.createElement("div", { key: "empty", className: "text-center py-8 text-xs text-zinc-500" }, "Chưa có tập tin nào tải lên hoặc tạo ra.") : /*#__PURE__*/React.createElement("div", { key: "list", className: "space-y-1.5" }, [filesList.map(file => /*#__PURE__*/React.createElement("div", { key: file.file_id, className: "flex items-center justify-between p-3 bg-[#1e1e1e] rounded border border-zinc-800 text-xs" }, [/*#__PURE__*/React.createElement("div", { key: "meta", className: "max-w-[70%]" }, [/*#__PURE__*/React.createElement("div", { className: "font-semibold text-slate-300 truncate" }, file.original_name || file.file_id), /*#__PURE__*/React.createElement("div", { className: "text-[10px] text-zinc-500 mt-0.5" }, `Loại: ${file.type} | Dung lượng: ${file.size_mb} MB | Tạo lúc: ${new Date(file.created_at * 1000).toLocaleString()}`)]), /*#__PURE__*/React.createElement("div", { key: "actions", className: "flex items-center gap-2" }, [file.is_in_use ? /*#__PURE__*/React.createElement("span", { className: "px-2 py-0.5 bg-emerald-950 text-emerald-400 border border-emerald-900 rounded text-[9px] uppercase font-bold font-mono" }, "Đang dùng") : /*#__PURE__*/React.createElement("span", { className: "px-2 py-0.5 bg-zinc-850 text-zinc-400 border border-zinc-700 rounded text-[9px] uppercase font-bold font-mono" }, "Không dùng"), !file.is_in_use && /*#__PURE__*/React.createElement("button", { onClick: () => handleDeleteFile(file.file_id), className: "px-2 py-0.5 bg-rose-950 hover:bg-rose-900 text-rose-300 rounded font-semibold text-[10px] border border-rose-900" }, "Xóa")])]))])] : null))); }; const SaveProjectModal = ({ isOpen, onClose, onSave }) => { if (!isOpen) return null; const [name, setName] = useState(''); const handleSubmit = e => { e.preventDefault(); if (!name.trim()) return; onSave(name.trim()); onClose(); }; return /*#__PURE__*/React.createElement("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" }, /*#__PURE__*/React.createElement("div", { className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-md p-6 text-slate-200" }, /*#__PURE__*/React.createElement("h3", { className: "text-sm font-bold text-teal-400 mb-4 uppercase" }, "Đặt tên dự án"), /*#__PURE__*/React.createElement("form", { onSubmit: handleSubmit, className: "space-y-4" }, [/*#__PURE__*/React.createElement("input", { key: "name-input", type: "text", placeholder: "Nhập tên dự án...", required: true, value: name, onChange: e => setName(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-teal-500 font-bold", autoFocus: true }), /*#__PURE__*/React.createElement("div", { key: "actions", className: "flex justify-end gap-2 text-xs" }, [/*#__PURE__*/React.createElement("button", { key: "cancel", type: "button", onClick: onClose, className: "px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded" }, "Hủy"), /*#__PURE__*/React.createElement("button", { key: "save", type: "submit", className: "px-3 py-1.5 bg-teal-600 hover:bg-teal-500 text-white rounded font-semibold" }, "Lưu")])]))); }; const SaveAsModal = ({ isOpen, onClose, projectName, onSaveCloud, onSaveLocal }) => { if (!isOpen) return null; const [name, setName] = useState(projectName || ''); const [saveType, setSaveType] = useState('cloud'); const handleSubmit = e => { e.preventDefault(); if (!name.trim()) return; if (saveType === 'cloud') { onSaveCloud(name.trim()); } else { onSaveLocal(name.trim()); } onClose(); }; return /*#__PURE__*/React.createElement("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" }, /*#__PURE__*/React.createElement("div", { className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-md p-6 text-slate-200" }, /*#__PURE__*/React.createElement("h3", { className: "text-sm font-bold text-teal-400 mb-4 uppercase" }, "Lưu dưới tên khác (Save As...)"), /*#__PURE__*/React.createElement("form", { onSubmit: handleSubmit, className: "space-y-4" }, [/*#__PURE__*/React.createElement("div", { key: "name-block" }, [/*#__PURE__*/React.createElement("label", { className: "block text-[10px] text-zinc-400 uppercase font-bold mb-1" }, "Tên dự án mới"), /*#__PURE__*/React.createElement("input", { type: "text", placeholder: "Nhập tên mới...", required: true, value: name, onChange: e => setName(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-teal-500 font-bold", autoFocus: true })]), /*#__PURE__*/React.createElement("div", { key: "type-block" }, [/*#__PURE__*/React.createElement("label", { className: "block text-[10px] text-zinc-400 uppercase font-bold mb-1.5" }, "Phương thức lưu trữ"), /*#__PURE__*/React.createElement("div", { className: "grid grid-cols-2 gap-2 text-xs" }, [/*#__PURE__*/React.createElement("button", { key: "btn-cloud", type: "button", onClick: () => setSaveType('cloud'), className: `py-2 rounded border flex flex-col items-center gap-1 font-semibold transition ${saveType === 'cloud' ? 'bg-teal-950/60 border-teal-500 text-teal-300' : 'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400'}` }, [/*#__PURE__*/React.createElement("span", { key: "title" }, "☁️ Lưu Cloud"), /*#__PURE__*/React.createElement("span", { key: "desc", className: "text-[9px] font-normal text-zinc-500" }, "Lưu lên server cá nhân")]), /*#__PURE__*/React.createElement("button", { key: "btn-local", type: "button", onClick: () => setSaveType('local'), className: `py-2 rounded border flex flex-col items-center gap-1 font-semibold transition ${saveType === 'local' ? 'bg-teal-950/60 border-teal-500 text-teal-300' : 'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400'}` }, [/*#__PURE__*/React.createElement("span", { key: "title" }, "💾 Tải về máy (.sfs)"), /*#__PURE__*/React.createElement("span", { key: "desc", className: "text-[9px] font-normal text-zinc-500" }, "Tải tệp JSON dự án về máy")])])]), /*#__PURE__*/React.createElement("div", { key: "actions", className: "flex justify-end gap-2 text-xs pt-2" }, [/*#__PURE__*/React.createElement("button", { key: "cancel", type: "button", onClick: onClose, className: "px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded" }, "Hủy"), /*#__PURE__*/React.createElement("button", { key: "save", type: "submit", className: "px-3 py-1.5 bg-teal-600 hover:bg-teal-500 text-white rounded font-semibold" }, "Thực hiện lưu")])]))); }; const SystemManagerModal = ({ isOpen, onClose }) => { if (!isOpen) return null; const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [msg, setMsg] = useState(''); const [error, setError] = useState(''); const [editingQuotaUser, setEditingQuotaUser] = useState(null); const [newQuotaMb, setNewQuotaMb] = useState(500); useEffect(() => { if (isOpen) loadUsers(); }, [isOpen]); const loadUsers = async () => { setLoading(true); setError(''); try { const data = await window.SonicAPI.listUsers(); setUsers(data); } catch (err) { setError(err.message || 'Không thể tải danh sách người dùng hệ thống'); } finally { setLoading(false); } }; const handleSaveQuota = async userId => { try { await window.SonicAPI.updateUserQuota(userId, parseInt(newQuotaMb)); setMsg('Đã cập nhật hạn mức Quota thành công!'); setEditingQuotaUser(null); loadUsers(); } catch (err) { setError(err.message || 'Lỗi cập nhật Quota'); } }; const handleToggleRole = async user => { const nextRole = user.role === 'admin' ? 'standard' : 'admin'; try { await window.SonicAPI.updateUserRole(user.id, nextRole, user.is_active); setMsg(`Đã đổi vai trò người dùng ${user.username} thành ${nextRole}`); loadUsers(); } catch (err) { setError(err.message || 'Lỗi cập nhật vai trò'); } }; const handleDeleteUser = async userId => { if (!confirm('Bạn có chắc chắn muốn xóa người dùng này khỏi hệ thống?')) return; try { await window.SonicAPI.deleteUser(userId); setMsg('Đã xóa người dùng thành công'); loadUsers(); } catch (err) { setError(err.message || 'Lỗi khi xóa người dùng'); } }; return /*#__PURE__*/React.createElement("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" }, /*#__PURE__*/React.createElement("div", { className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-4xl p-6 text-slate-200" }, /*#__PURE__*/React.createElement("div", { className: "flex justify-between items-center pb-4 border-b border-[#383838]" }, /*#__PURE__*/React.createElement("h3", { className: "text-lg font-bold text-amber-400" }, "⚙️ Quản Lý Hệ Thống & Phân Quyền Admin"), /*#__PURE__*/React.createElement("button", { onClick: onClose, className: "text-slate-400 hover:text-slate-200" }, "✕")), msg && /*#__PURE__*/React.createElement("div", { className: "mt-3 p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs" }, msg), error && /*#__PURE__*/React.createElement("div", { className: "mt-3 p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs" }, error), /*#__PURE__*/React.createElement("div", { className: "mt-4 overflow-x-auto max-h-96 no-scrollbar" }, loading ? /*#__PURE__*/React.createElement("div", { className: "py-8 text-center text-slate-400 text-xs" }, "Đang tải thông tin hệ thống...") : /*#__PURE__*/React.createElement("table", { className: "w-full text-left text-xs border-collapse" }, /*#__PURE__*/React.createElement("thead", null, /*#__PURE__*/React.createElement("tr", { className: "border-b border-[#383838] text-slate-400 bg-[#1e1e1e]" }, /*#__PURE__*/React.createElement("th", { className: "p-3" }, "Tên Người Dùng"), /*#__PURE__*/React.createElement("th", { className: "p-3" }, "Email"), /*#__PURE__*/React.createElement("th", { className: "p-3" }, "Vai Trò"), /*#__PURE__*/React.createElement("th", { className: "p-3" }, "Dung Lượng Sử Dụng"), /*#__PURE__*/React.createElement("th", { className: "p-3" }, "Hạn Mức Quota"), /*#__PURE__*/React.createElement("th", { className: "p-3 text-right" }, "Thao Tác"))), /*#__PURE__*/React.createElement("tbody", { className: "divide-y divide-[#333]" }, users.map(u => /*#__PURE__*/React.createElement("tr", { key: u.id, className: "hover:bg-[#2e2e2e]" }, /*#__PURE__*/React.createElement("td", { className: "p-3 font-semibold text-teal-300" }, u.username, u.must_change_password && /*#__PURE__*/React.createElement("span", { className: "ml-2 text-xs bg-amber-900/60 text-amber-300 px-1.5 py-0.5 rounded" }, "Mật khẩu gốc")), /*#__PURE__*/React.createElement("td", { className: "p-3 text-slate-300" }, u.email), /*#__PURE__*/React.createElement("td", { className: "p-3 uppercase font-bold text-amber-400" }, u.role), /*#__PURE__*/React.createElement("td", { className: "p-3" }, u.used_mb, " MB"), /*#__PURE__*/React.createElement("td", { className: "p-3" }, editingQuotaUser === u.id ? /*#__PURE__*/React.createElement("div", { className: "flex items-center space-x-1" }, /*#__PURE__*/React.createElement("input", { type: "number", value: newQuotaMb, onChange: e => setNewQuotaMb(e.target.value), className: "w-16 bg-[#1e1e1e] border border-[#444] rounded px-1 py-0.5 text-xs text-slate-200" }), /*#__PURE__*/React.createElement("span", null, "MB"), /*#__PURE__*/React.createElement("button", { onClick: () => handleSaveQuota(u.id), className: "px-2 py-0.5 bg-teal-600 rounded text-xs" }, "Lưu")) : /*#__PURE__*/React.createElement("span", { className: "font-semibold" }, u.quota_mb, " MB")), /*#__PURE__*/React.createElement("td", { className: "p-3 text-right space-x-2" }, /*#__PURE__*/React.createElement("button", { onClick: () => { setEditingQuotaUser(u.id); setNewQuotaMb(u.quota_mb); }, className: "px-2 py-1 bg-slate-700 hover:bg-slate-600 rounded text-xs" }, "Sửa Quota"), /*#__PURE__*/React.createElement("button", { onClick: () => handleToggleRole(u), className: "px-2 py-1 bg-amber-700/60 hover:bg-amber-600 rounded text-xs" }, "Đổi Role"), u.role !== 'admin' && /*#__PURE__*/React.createElement("button", { onClick: () => handleDeleteUser(u.id), className: "px-2 py-1 bg-red-700/60 hover:bg-red-600 rounded text-xs" }, "Xóa"))))))))); }; const App = () => { // ── State Definitions ── const [tracks, setTracks] = useState([{ id: '1', name: 'Track 01', buffer: null, startTime: 0, height: 128, volumeDb: 0, pan: 0, muted: false, solo: false, color: '#0f766e', markers: [], serverFileId: null, clips: [] }, { id: '2', name: 'Track 02', buffer: null, startTime: 0, height: 128, volumeDb: 0, pan: 0, muted: false, solo: false, color: '#1d4ed8', markers: [], serverFileId: null, clips: [], sections: [], midiItems: [] }]); 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 selectionRef = useRef({ start: null, end: null }); selectionRef.current = { start: selectionStart, end: selectionEnd }; 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 [projectName, setProjectName] = useState(() => localStorage.getItem('sonic_project_name') || ''); const [currentProjectId, setCurrentProjectId] = useState(() => localStorage.getItem('sonic_project_id') || null); const [saveProjectModalOpen, setSaveProjectModalOpen] = useState(false); const [saveAsModalOpen, setSaveAsModalOpen] = 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 [showMediaExplorer, setShowMediaExplorer] = useState(true); const [showFxRack, setShowFxRack] = useState(false); const [showMidiEvents, setShowMidiEvents] = useState(false); const [rightSidebarWidth, setRightSidebarWidth] = useState(320); const [mediaExplorerHeight, setMediaExplorerHeight] = useState(50); const [panelPositions, setPanelPositions] = useState({ export: 'bottom', ai: 'right', python_tools: 'bottom', selection: 'bottom', media_explorer: 'bottom', fx_rack: 'bottom', midi_events: '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 colResizerRef = useRef(null); const rowResizerRef = 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 [aiProviders, setAiProviders] = useState([]); useEffect(() => { (async () => { try { const data = await window.SonicAPI.getAIConfigs(); if (data && data.providers) { setAiProviders(data.providers); const active = data.providers.find(p => p.is_active) || data.providers[0]; if (active) setSelectedProviderId(active.id); } } catch (e) {/* server may not have config endpoint */} })(); }, []); const [analysisState, setAnalysisState] = useState({ status: 'Sẵn sàng. Chạy AI để phân tích nhịp.', data: null, isRunning: false }); const [aiPrompt, setAiPrompt] = useState(''); const [promptHistory, setPromptHistory] = useState([]); const [promptHistIdx, setPromptHistIdx] = useState(-1); const promptHistRef = useRef([]); const [aiProvider, setAiProvider] = useState('OpenAI'); const [aiModel, setAiModel] = useState('GPT-4o'); const [aiActionLog, setAiActionLog] = useState([]); const actionLogContainerRef = useRef(null); useEffect(() => { if (actionLogContainerRef.current) { actionLogContainerRef.current.scrollTop = actionLogContainerRef.current.scrollHeight; } }, [aiActionLog]); const [aiProcessing, setAiProcessing] = useState(false); const [selectedProviderId, setSelectedProviderId] = useState(''); const [exportSettings, setExportSettings] = useState({ sampleRate: '44100', bitDepth: '16', format: 'wav', source: 'project', quality: '44khz', channels: 'stereo' }); 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 [draggedSectionItem, setDraggedSectionItem] = useState(null); const [resizedSectionItem, setResizedSectionItem] = useState(null); const [editingTrackName, setEditingTrackName] = useState(null); // trackId being edited const [editingClipName, setEditingClipName] = useState(null); // { trackId, clipId } 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 [sessionTabs, setSessionTabs] = useState([]); // [{id, name, tracks}, ...] const activeTracks = useMemo(() => { const st = sessionTabs.find(s => s.id === activeTab); return st ? st.tracks : tracks; }, [activeTab, sessionTabs, tracks]); 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); localStorage.setItem('sonic_user', JSON.stringify(profile)); if (profile.must_change_password) { setIsMandatoryLogin(true); setAuthMode('force_change'); setAuthModalOpen(true); } else { setIsMandatoryLogin(false); setAuthModalOpen(false); } } catch (err) { const cached = localStorage.getItem('sonic_user'); if (cached) { try { setCurrentUser(JSON.parse(cached)); } catch (_) {} setIsMandatoryLogin(false); setAuthModalOpen(false); } else { 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 loadPrefs = p => { if (!p) return; if (p.showAIPanel !== undefined) setShowAIPanel(p.showAIPanel); if (p.showExportPanel !== undefined) setShowExportPanel(p.showExportPanel); if (p.showSelectionPanel !== undefined) setShowSelectionPanel(p.showSelectionPanel); if (p.showPythonToolsPanel !== undefined) setShowPythonToolsPanel(p.showPythonToolsPanel); if (p.showMediaExplorer !== undefined) setShowMediaExplorer(p.showMediaExplorer); if (p.showFxRack !== undefined) setShowFxRack(p.showFxRack); if (p.showMidiEvents !== undefined) setShowMidiEvents(p.showMidiEvents); if (p.panelPositions) setPanelPositions(p.panelPositions); if (p.rightSidebarWidth) setRightSidebarWidth(p.rightSidebarWidth); if (p.mediaExplorerHeight) setMediaExplorerHeight(p.mediaExplorerHeight); if (p.selectedProviderId) setSelectedProviderId(p.selectedProviderId); }; (async () => { try { const data = await window.SonicAPI.getPreferences(); if (data && data.preferences) loadPrefs(data.preferences);else { const cached = localStorage.getItem('sonic_preferences'); if (cached) loadPrefs(JSON.parse(cached)); } } catch (e) { const cached = localStorage.getItem('sonic_preferences'); if (cached) loadPrefs(JSON.parse(cached)); } try { const data = await window.SonicAPI.getAIConfigs(); if (data && data.providers) { setAiProviders(data.providers); const active = data.providers.find(p => p.is_active) || data.providers[0]; if (active) setSelectedProviderId(active.id); } } catch (e) {} })(); } }; 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 [scrollLeft, setScrollLeft] = useState(0); const handleTimelineScroll = e => { if (tcpContainerRef.current) { tcpContainerRef.current.scrollTop = e.currentTarget.scrollTop; } setScrollLeft(e.currentTarget.scrollLeft); }; 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); }; // ── Right Sidebar Column Resizer ── const startColResize = e => { e.preventDefault(); const startX = e.clientX; const startWidth = rightSidebarWidth; const onMove = ev => { const deltaX = startX - ev.clientX; const newWidth = Math.max(200, Math.min(600, startWidth + deltaX)); setRightSidebarWidth(newWidth); }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }; // ── Right Sidebar Row Resizer (Media Explorer / AI Panel) ── const startRowResize = e => { e.preventDefault(); const startY = e.clientY; const startHeight = mediaExplorerHeight; const sidebarEl = document.getElementById('right-sidebar'); const sidebarHeight = sidebarEl ? sidebarEl.getBoundingClientRect().height : 400; const onMove = ev => { const deltaY = startY - ev.clientY; const pct = (startHeight / 100 * sidebarHeight + deltaY) / sidebarHeight * 100; const newPct = Math.max(20, Math.min(80, pct)); setMediaExplorerHeight(newPct); }; const onUp = () => { 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); const currentTimeRef = useRef(currentTime); currentTimeRef.current = currentTime; // ── 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', sampleRate: sr, channels: 1, speed: 1.0 }; 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, clips: [], sections: [], midiItems: [] }, { id: '2', name: 'Track 02', buffer: null, startTime: 0, height: 128, volumeDb: 0, pan: 0, muted: false, solo: false, color: '#1d4ed8', markers: [], serverFileId: null, clips: [], sections: [], midiItems: [] }]); 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(); handleSaveProject(); 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); }; // ── Double-click/Edit Section: open Main Session in new tab ── const handleEditSectionInTab = (trackId, sectionId) => { const track = tracks.find(t => t.id === trackId); if (!track) return; const section = (track.sections || []).find(s => s.id === sectionId); if (!section) return; const existing = sessionTabs.find(s => s.sectionId === sectionId); if (existing) { setActiveTab(existing.id); showToast(`Tab "${section.name}" already open.`, 'info'); return; } const tabId = 'session_' + Date.now(); const tabName = section.name || 'Section'; const clonedTracks = tracks.map(t => ({ ...t, clips: [], sections: [], midiItems: [], markers: [] })); setSessionTabs(prev => [...prev, { id: tabId, name: tabName, sectionId: sectionId, tracks: clonedTracks }]); 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 closeSessionTab = tabId => { setSessionTabs(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, sectionId) => { e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY, trackId, time: clickTime || currentTime, sectionId: sectionId || null }); }; const closeContextMenu = () => setContextMenu(null); const contextMenuEditSection = () => { if (contextMenu.sectionId) { handleEditSectionInTab(contextMenu.trackId, contextMenu.sectionId); } closeContextMenu(); }; // 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 sessionTab = sessionTabs.find(s => s.id === activeTab); if (sessionTab) { updateActiveTracks(prev => prev.filter(t => t.id !== tid)); if (selectedTrackId === tid) setSelectedTrackId('1'); closeContextMenu(); showToast('Đã xoá track.', 'info'); } else { const track = tracks.find(t => t.id === tid); if (track && track.sections && track.sections.length > 0) { closeContextMenu(); showToast('Không thể xoá track chứa Section item.', 'warning'); return; } 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 numCh = track.buffer.numberOfChannels || 1; const clipBuffer = ctx.createBuffer(numCh, len, sr); for (let ch = 0; ch < numCh; ch++) { clipBuffer.copyToChannel(track.buffer.getChannelData(ch).subarray(startSample, endSample), ch); } clipboardRef.current = { buffer: clipBuffer, name: track.name, volumeDb: track.volumeDb, pan: track.pan, color: track.color, sampleRate: clipBuffer.sampleRate, channels: numCh, speed: track.speed || 1.0 }; 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, sampleRate: track.buffer.sampleRate, channels: track.buffer.numberOfChannels, speed: track.speed || 1.0 }; 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 numCh = t.buffer.numberOfChannels || 1; const clipBuffer = ctx.createBuffer(numCh, len, sr); for (let ch = 0; ch < numCh; ch++) { clipBuffer.copyToChannel(t.buffer.getChannelData(ch).subarray(startSample, endSample), ch); } clipboardRef.current = { buffer: clipBuffer, name: t.name, volumeDb: t.volumeDb, pan: t.pan, color: t.color, sampleRate: sr, channels: numCh, speed: t.speed || 1.0 }; const newLen = data.length - len; const newBuffer = ctx.createBuffer(numCh, newLen, sr); for (let ch = 0; ch < numCh; ch++) { const src = t.buffer.getChannelData(ch); const dst = newBuffer.getChannelData(ch); let idx = 0; for (let i = 0; i < startSample; i++) dst[idx++] = src[i]; for (let i = endSample; i < src.length; i++) dst[idx++] = src[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, sampleRate, channels, speed } = clipboardRef.current; const ctx = getAudioContext(); const targetTrack = tracks.find(t => t.id === targetTrackId); const newClip = { id: nextClipId(), startTime: pasteTime, buffer: clipBuffer, name: (name || 'Pasted Clip').replace(/\.\w+$/, '') + ' (Pasted)', ...(volumeDb !== undefined ? { volumeDb } : {}), ...(pan !== undefined ? { pan } : {}), ...(color ? { color } : {}), ...(speed !== undefined ? { speed } : {}) }; if (targetTrack) { setTracks(p => p.map(t => { if (t.id === targetTrackId) { const existingClips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ id: 'default_' + t.id, buffer: t.buffer, startTime: t.startTime || 0, name: t.name }] : []; const updatedClips = [...existingClips, newClip]; return { ...t, clips: updatedClips, buffer: updatedClips[0].buffer, startTime: updatedClips[0].startTime, name: name || t.name, volumeDb: volumeDb ?? t.volumeDb, pan: pan ?? t.pan, color: color || t.color }; } return t; })); setCurrentTime(pasteTime); showToast('Đã dán clip vào track.', 'success'); return targetTrackId; } // No matching track — create a new one const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; const newId = 'track_pasted_' + Date.now(); setTracks(prev => [...prev, { id: newId, name: `Pasted_${name || 'track'}`, buffer: clipBuffer, startTime: pasteTime, clips: [newClip], volumeDb: volumeDb ?? 0, pan: pan ?? 0, muted: false, solo: false, color: color || colors[prev.length % colors.length], markers: [], serverFileId: null }]); setSelectedTrackId(newId); setCurrentTime(pasteTime); showToast('Đã dán track mới từ clipboard.', 'success'); return newId; }; const handlePasteTrack = () => doPaste(selectedTrackId, currentTime); const contextMenuPaste = () => { const result = doPaste(contextMenu.trackId, contextMenu.time || currentTime); closeContextMenu(); }; const contextMenuMerge = () => { const activeTracks = tracks.filter(t => t.buffer && !t.muted); if (activeTracks.length < 2) { showToast('Cần ít nhất 2 track có dữ liệu để merge.', 'warning'); closeContextMenu(); return; } const ctx = getAudioContext(); const maxDur = Math.max(...activeTracks.map(t => (t.startTime || 0) + t.buffer.duration)); const sr = activeTracks[0].buffer.sampleRate; const merged = ctx.createBuffer(1, Math.ceil(maxDur * sr), sr); const mergedData = merged.getChannelData(0); activeTracks.forEach(t => { const data = t.buffer.getChannelData(0); const startSample = Math.floor((t.startTime || 0) * sr); const volLinear = (t.volumeDb ?? 0) <= -50 ? 0 : Math.pow(10, (t.volumeDb ?? 0) / 20); for (let i = 0; i < data.length; i++) { if (startSample + i < mergedData.length) { mergedData[startSample + i] += data[i] * volLinear; } } }); let maxPeak = 0; for (let i = 0; i < mergedData.length; i++) { const abs = Math.abs(mergedData[i]); if (abs > maxPeak) maxPeak = abs; } if (maxPeak > 1.0) { for (let i = 0; i < mergedData.length; i++) mergedData[i] /= maxPeak; } const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; const newId = 'track_merged_' + Date.now(); const names = activeTracks.map(t => t.name).join('+').slice(0, 30); setTracks(prev => [...prev, { id: newId, name: `Merged_${names}.wav`, buffer: merged, startTime: 0, volumeDb: 0, pan: 0, muted: false, solo: false, color: colors[prev.length % colors.length], markers: [], serverFileId: null }]); setSelectedTrackId(newId); closeContextMenu(); showToast(`Đã merge ${activeTracks.length} tracks.`, 'success'); }; // Menu bar direct handlers (don't rely on contextMenu state) const handleMergeTracks = () => { const at = tracks.filter(t => t.buffer && !t.muted); if (at.length < 2) { showToast('Cần 2+ tracks để merge.', 'warning'); return; } const actx = getAudioContext(); const maxDur = Math.max(...at.map(t => (t.startTime || 0) + t.buffer.duration)); const sr = at[0].buffer.sampleRate; const mb = actx.createBuffer(1, Math.ceil(maxDur * sr), sr); const mdata = mb.getChannelData(0); at.forEach(t => { const d = t.buffer.getChannelData(0); const startSample = Math.floor((t.startTime || 0) * sr); const volLinear = (t.volumeDb ?? 0) <= -50 ? 0 : Math.pow(10, (t.volumeDb ?? 0) / 20); for (let i = 0; i < d.length; i++) { if (startSample + i < mdata.length) { mdata[startSample + i] += d[i] * volLinear; } } }); let mp = 0; for (let i = 0; i < mdata.length; i++) { const a = Math.abs(mdata[i]); if (a > mp) mp = a; } if (mp > 1.0) for (let i = 0; i < mdata.length; i++) mdata[i] /= mp; setTracks(p => [...p, { id: 'merged_' + Date.now(), name: 'Merged_mix.wav', buffer: mb, startTime: 0, height: 128, volumeDb: 0, pan: 0, muted: false, solo: false, color: ['#0f766e', '#1d4ed8'][p.length % 2], markers: [], serverFileId: null }]); showToast('Merged all unmuted tracks.', 'success'); }; const handleCopyTrack = () => { const t = tracks.find(x => x.id === selectedTrackId); if (!t || !t.buffer) return; const sr = t.buffer.sampleRate; const data = t.buffer.getChannelData(0); // If selection exists, copy only the selected region if (selLeft !== null && selRight !== null && selRight > selLeft) { const trackStart = t.startTime || 0; const relSelLeft = Math.max(0, selLeft - trackStart); const relSelRight = Math.max(0, selRight - trackStart); const startSample = Math.floor(relSelLeft * sr); const endSample = Math.min(data.length, Math.floor(relSelRight * sr)); const len = endSample - startSample; if (len > 0) { const ctx = getAudioContext(); const clipBuffer = ctx.createBuffer(1, len, sr); clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); clipboardRef.current = { buffer: clipBuffer, name: t.name, volumeDb: t.volumeDb, color: t.color }; showToast('Copied selection to clipboard.', 'info'); return; } } // No selection: copy entire track clipboardRef.current = { buffer: clipBuffer, name: t.name, volumeDb: t.volumeDb, pan: t.pan, color: t.color, sampleRate: clipBuffer.sampleRate, channels: clipBuffer.numberOfChannels, speed: t.speed || 1.0 }; 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; const sessionTab = sessionTabs.find(s => s.id === activeTab); if (sessionTab) { updateActiveTracks(prev => prev.filter(t => t.id !== tid)); setSelectedTrackId(activeTracks.filter(t => t.id !== tid)[0]?.id || '1'); } else { const curTracks = tracks; const track = curTracks.find(t => t.id === tid); if (track && track.sections && track.sections.length > 0) { showToast('Không thể xoá track chứa Section item.', 'warning'); return; } setTracks(p => p.filter(t => t.id !== tid)); setSelectedTrackId(curTracks.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]); const dspSelectionStats = useMemo(() => { const track = tracks.find(t => t.id === selectedTrackId); if (!track) return null; const numChannels = track.buffer ? track.buffer.numberOfChannels || 1 : 0; if (selLeft === null || selRight === null || selRight <= selLeft || !track.buffer) { return { trackName: track.name, channels: numChannels, timeRange: 'Chưa chọn vùng', peakVolume: 'N/A' }; } const buffer = track.buffer; const sampleRate = buffer.sampleRate; const startSample = Math.max(0, Math.min(buffer.length - 1, Math.floor(selLeft * sampleRate))); const endSample = Math.max(0, Math.min(buffer.length, Math.floor(selRight * sampleRate))); let maxVal = 0; for (let c = 0; c < numChannels; c++) { const data = buffer.getChannelData(c); for (let i = startSample; i < endSample; i++) { const val = Math.abs(data[i]); if (val > maxVal) maxVal = val; } } let peakDb = 'N/A'; if (maxVal > 0) { const db = 20 * Math.log10(maxVal); peakDb = db.toFixed(2) + ' dB'; } else { peakDb = '-∞ dB'; } return { trackName: track.name, channels: numChannels, timeRange: `${selLeft.toFixed(2)}s - ${selRight.toFixed(2)}s (${(selRight - selLeft).toFixed(2)}s)`, peakVolume: peakDb }; }, [tracks, selectedTrackId, selLeft, selRight]); // ── Toast helper ── const showToast = (text, type = 'info', actionText = null, onActionClick = null) => { if (toastTimeoutRef.current) clearTimeout(toastTimeoutRef.current); setToastMessage({ text, type, actionText, onActionClick }); toastTimeoutRef.current = setTimeout(() => setToastMessage(null), actionText ? 8000 : 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 zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; setZoom(prevZoom => { let newZoom = prevZoom * zoomFactor; if (newZoom < minZoom) newZoom = minZoom; if (newZoom > 50000) newZoom = 50000; requestAnimationFrame(() => { const centerTime = currentTimeRef.current; const newCenterX = centerTime * newZoom; const viewportWidth = timeline.clientWidth; timeline.scrollLeft = newCenterX - viewportWidth / 2; }); return newZoom; }); } else if (e.shiftKey) { e.preventDefault(); timeline.scrollLeft += e.deltaY; } }; timeline.addEventListener('wheel', handleWheel, { passive: false }); return () => timeline.removeEventListener('wheel', handleWheel); }, [minZoom]); // ── 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 (soloedTrackId !== null || selectionMode === 'local') { stopAllPlayback(); startOffsetTimeRef.current = selLeft; startAudioTimeRef.current = context.currentTime; const soloTid = soloedTrackId !== null ? soloedTrackId : localSelectionTrackId; startLocalTrackPlayback(soloTid, selLeft); setCurrentTime(selLeft); setIsPlaying(true); } else { stopAllPlayback(); startOffsetTimeRef.current = selLeft; startAudioTimeRef.current = context.currentTime; startTrackPlayback(selLeft); setCurrentTime(selLeft); setIsPlaying(true); } animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); return; } } if (updatedTime >= maxDurationRef.current) { if (isLoopingSelection) { stopAllPlayback(); startOffsetTimeRef.current = 0; startAudioTimeRef.current = context.currentTime; startTrackPlayback(0); setCurrentTime(0); setIsPlaying(true); animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); return; } 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 draggedSectionItemRef = useRef(null); draggedSectionItemRef.current = draggedSectionItem; const resizedSectionItemRef = useRef(null); resizedSectionItemRef.current = resizedSectionItem; let clipSeqCounter = 0; const nextClipId = () => `clip_${Date.now()}_${++clipSeqCounter}`; 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); if (isDuplicate) { const cloneId = nextClipId(); const clone = { ...clip, id: cloneId, startTime: clip.startTime || 0, 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; })); setDraggedClip({ trackId, clipId: cloneId, clickOffset, buffer: clip.buffer, name: clip.name + ' (Copy)', beforeSnap, isDuplicate: false }); return; } if (!track.clips || track.clips.length === 0) { setTracks(prev => prev.map(t => { if (t.id === trackId) { return { ...t, clips: existingClips }; } return t; })); } setDraggedClip({ trackId, clipId: clip.id, clickOffset, buffer: clip.buffer, name: clip.name, beforeSnap, isDuplicate: false }); }; 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 sessionTab = sessionTabs.find(s => s.id === activeTab); if (sessionTab) { updateActiveTracks(prev => { const filtered = prev.filter(t => t.id !== trackId); if (filtered.length > 0) setSelectedTrackId(filtered[0].id); return filtered; }); } else { const track = tracks.find(t => t.id === trackId); if (track && track.sections && track.sections.length > 0) { showToast('Không thể xoá track chứa Section item.', 'warning'); return; } 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]); // ── Section / MIDI Item Drag Start ── const handleSectionItemDragStart = (trackId, itemType, itemId, clickOffset) => { setDraggedSectionItem({ trackId, itemType, itemId, clickOffset }); }; // ── Section / MIDI Item Resize Start ── const handleSectionItemResizeStart = (trackId, itemType, itemId, side, clickTime) => { const curTracks = activeTracks; const track = curTracks.find(t => t.id === trackId); if (!track) return; const items = itemType === 'section' ? track.sections : track.midiItems; const item = (items || []).find(it => it.id === itemId); if (!item) return; const start = itemType === 'section' ? item.start : item.startTime; setResizedSectionItem({ trackId, itemType, itemId, side, originalStart: start, originalDuration: item.duration }); }; // ── Document-level mousemove/mouseup for Section/MIDI item drag ── useEffect(() => { const handleMouseMove = e => { const drag = draggedSectionItemRef.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 newStart = Math.max(0, time - drag.clickOffset); const targetTrackId = hoveredTrackIdRef.current || drag.trackId; updateActiveTracks(prev => prev.map(t => { const items = drag.itemType === 'section' ? (t.sections || []) : (t.midiItems || []); const updatedItems = items.filter(it => it.id !== drag.itemId); if (t.id === targetTrackId) { const movedItem = items.find(it => it.id === drag.itemId); if (movedItem) { updatedItems.push(drag.itemType === 'section' ? { ...movedItem, start: newStart } : { ...movedItem, startTime: newStart }); } else { if (drag.itemType === 'section') { updatedItems.push({ id: drag.itemId, name: 'Section', start: newStart, duration: 4, color: '#06b6d4' }); } else { updatedItems.push({ id: drag.itemId, name: 'MIDI Item', startTime: newStart, duration: 4, notes: [], color: '#a78bfa' }); } } } return drag.itemType === 'section' ? { ...t, sections: updatedItems } : { ...t, midiItems: updatedItems }; })); if (drag.trackId !== targetTrackId) { setDraggedSectionItem(prev => ({ ...prev, trackId: targetTrackId })); } }; const handleMouseUp = () => { const drag = draggedSectionItemRef.current; if (!drag) return; setDraggedSectionItem(null); showToast(`Đã di chuyển ${drag.itemType === 'section' ? 'section' : 'MIDI item'}.`, 'success'); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, [zoom, activeTab, sessionTabs]); // ── Document-level mousemove/mouseup for Section/MIDI item resize ── useEffect(() => { const handleMouseMove = e => { const resize = resizedSectionItemRef.current; if (!resize) 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; updateActiveTracks(prev => prev.map(t => { if (t.id !== resize.trackId) return t; const items = resize.itemType === 'section' ? [...(t.sections || [])] : [...(t.midiItems || [])]; const idx = items.findIndex(it => it.id === resize.itemId); if (idx === -1) return t; const item = items[idx]; if (resize.side === 'left') { const newStart = Math.min(time, resize.originalStart + resize.originalDuration - 0.1); const end = resize.originalStart + resize.originalDuration; const newDuration = end - newStart; if (newDuration < 0.1) return t; items[idx] = resize.itemType === 'section' ? { ...item, start: newStart, duration: newDuration } : { ...item, startTime: newStart, duration: newDuration }; } else { const newDuration = Math.max(0.1, time - resize.originalStart); items[idx] = { ...item, duration: newDuration }; } return resize.itemType === 'section' ? { ...t, sections: items } : { ...t, midiItems: items }; })); }; const handleMouseUp = () => { const resize = resizedSectionItemRef.current; if (!resize) return; setResizedSectionItem(null); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, [zoom, activeTab, sessionTabs]); 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; }); }; const updateClipName = (trackId, clipId, newName) => { setTracks(prev => prev.map(t => { if (t.id !== trackId) return t; const clips = t.clips && t.clips.length > 0 ? t.clips : []; return { ...t, clips: clips.map(c => c.id === clipId ? { ...c, name: newName } : c) }; })); }; // ── 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'); }; // ── Update tracks in active context (main session or section tab) ── const updateActiveTracks = updater => { const sessionTab = sessionTabs.find(s => s.id === activeTab); if (sessionTab) { setSessionTabs(prev => prev.map(st => st.id === activeTab ? { ...st, tracks: updater(st.tracks) } : st)); } else { setTracks(updater); } }; // ── Add Track ── const addNewTrack = () => { const curTracks = activeTracks; const newId = (curTracks.length + 1).toString(); const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; const selectColor = colors[curTracks.length % colors.length]; updateActiveTracks(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, clips: [], sections: [], midiItems: [] }]); showToast(`Đã thêm Track ${newId}.`, 'info'); setTimeout(() => lucide.createIcons(), 200); return newId; }; // ── Insert Track Below Selected ── const insertTrackBelow = () => { const curTracks = activeTracks; const newId = `t${Date.now()}`; const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; const selectColor = colors[curTracks.length % colors.length]; const newTrack = { id: newId, name: `Track ${newId}`, buffer: null, startTime: 0, height: 128, volumeDb: 0, pan: 0, muted: false, solo: false, color: selectColor, markers: [], serverFileId: null, clips: [], sections: [], midiItems: [] }; updateActiveTracks(prev => { const idx = prev.findIndex(t => t.id === selectedTrackId); if (idx === -1) return [...prev, newTrack]; const copy = [...prev]; copy.splice(idx + 1, 0, newTrack); return copy; }); setSelectedTrackId(newId); showToast(`Đã thêm Track ${newId}.`, 'info'); setTimeout(() => lucide.createIcons(), 200); }; // ── Insert Section at Playhead ── const insertSectionAtPlayhead = () => { const track = tracks.find(t => t.id === selectedTrackId); if (!track) { showToast('Chọn track trước', 'warning'); return; } const section = { id: `sec_${Date.now()}`, name: 'Section', start: currentTime, duration: 4, color: track.color || '#06b6d4' }; setTracks(prev => prev.map(t => t.id === selectedTrackId ? { ...t, sections: [...(t.sections || []), section] } : t)); showToast(`Đã thêm Section tại ${currentTime.toFixed(2)}s`, 'success'); }; // ── Insert MIDI Item at Playhead ── const insertMidiItemAtPlayhead = () => { const curTracks = activeTracks; const track = curTracks.find(t => t.id === selectedTrackId); if (!track) { showToast('Chọn track trước', 'warning'); return; } const midiItem = { id: `midi_${Date.now()}`, name: 'MIDI Item', startTime: currentTime, duration: 4, notes: [], color: '#a78bfa' }; updateActiveTracks(prev => prev.map(t => t.id === selectedTrackId ? { ...t, midiItems: [...(t.midiItems || []), midiItem] } : t)); showToast(`Đã thêm MIDI item tại ${currentTime.toFixed(2)}s`, 'success'); }; // ── Insert Sound Clip at Cursor ── const insertSoundClipAtCursor = () => { const curTracks = activeTracks; const track = curTracks.find(t => t.id === selectedTrackId); if (!track) { showToast('Chọn track trước', 'warning'); return; } const input = document.createElement('input'); input.type = 'file'; input.accept = 'audio/*'; input.multiple = true; input.onchange = async e => { const files = Array.from(e.target.files || []); if (!files.length) return; let offset = currentTime; let count = 0; for (const file of files) { try { uploadToServer(file, track.id); const { audioBuffer: decodedBuffer, channelInfo } = await window.SonicAudio.decodeAudioFile(file); const clipId = `clip_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; const newClip = { id: clipId, buffer: decodedBuffer, startTime: offset, name: file.name, speed: 1.0 }; updateActiveTracks(prev => prev.map(t => t.id === selectedTrackId ? { ...t, clips: [...(t.clips || []), newClip] } : t)); offset += decodedBuffer.duration; count++; } catch (err) { showToast(`Lỗi nạp ${file.name}`, 'error'); } } if (count > 0) showToast(`Đã chèn ${count} file âm thanh`, 'success'); }; input.click(); }; // ── Server-side Export ── const triggerWavExport = async () => { let exportTracks; let clipStart = 0; let clipEnd = 0; const src = exportSettings.source; if (src === 'active_clip' || src === 'clip_selection') { const selTrack = selectedTrackId ? tracks.find(t => t.id === selectedTrackId) : null; if (!selTrack || !selTrack.buffer) { showToast("Không có clip nào được chọn.", "warning"); return; } let rangeStart = selectionStart; let rangeEnd = selectionEnd; if (rangeStart === null || rangeEnd === null || rangeEnd <= rangeStart) { rangeStart = 0; rangeEnd = selTrack.buffer.duration; } const ctx = getAudioContext(); const numCh = selTrack.buffer.numberOfChannels || 1; const sr = selTrack.buffer.sampleRate; const startSample = Math.max(0, Math.floor(rangeStart * sr)); const endSample = Math.min(selTrack.buffer.length, Math.floor(rangeEnd * sr)); const len = endSample - startSample; if (len <= 100) { showToast("Vùng chọn quá ngắn hoặc không có dữ liệu.", "warning"); return; } const clipBuf = ctx.createBuffer(numCh, len, sr); for (let ch = 0; ch < numCh; ch++) { clipBuf.copyToChannel(selTrack.buffer.getChannelData(ch).subarray(startSample, endSample), ch); } exportTracks = [{ ...selTrack, buffer: clipBuf, startTime: 0, clips: [{ id: 'export_clip', buffer: clipBuf, startTime: 0, name: selTrack.name }] }]; } else if (src === 'track_mix') { const sel = tracks.filter(t => t.buffer && !t.muted); const selTrk = selectedTrackId ? sel.filter(t => t.id === selectedTrackId) : sel; if (selTrk.length === 0) { showToast("Track được chọn không có dữ liệu.", "warning"); return; } exportTracks = selTrk; } else { exportTracks = tracks.filter(t => t.buffer && !t.muted); } if (exportTracks.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 = exportTracks.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 = exportTracks.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, channels: exportSettings.channels }, 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}`; showToast("Xuất bản âm thanh từ máy chủ hoàn tất!", "success", "Tải về", () => { const a = document.createElement('a'); a.href = downloadUrl; a.download = fileId; a.click(); }); } } 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(exportTracks); } finally { setIsExporting(false); } } else { // Client-side export (existing working code) clientSideExport(exportTracks); } }; const handleSaveProject = async () => { if (!currentUser) { setIsMandatoryLogin(false); setAuthMode('login'); setAuthModalOpen(true); return; } if (!projectName) { setSaveProjectModalOpen(true); return; } const ss = 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 })); const dataJson = JSON.stringify({ id: currentProjectId || 'project_' + Date.now(), name: projectName, tracks: ss(tracks) }); try { if (currentProjectId) { await window.SonicAPI.updateCloudProject(currentProjectId, projectName, dataJson); showToast(`Đã cập nhật dự án "${projectName}" lên server!`, "success"); } else { const res = await window.SonicAPI.saveCloudProject(projectName, dataJson); const newProjId = res.project_id; if (newProjId) { setCurrentProjectId(newProjId); localStorage.setItem('sonic_project_id', newProjId); } showToast(`Đã lưu dự án "${projectName}" mới lên server!`, "success"); } } catch (err) { showToast(err.message || "Lỗi lưu server", "error"); } }; const handleSaveProjectWithName = async newName => { setProjectName(newName); localStorage.setItem('sonic_project_name', newName); const ss = 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 })); const dataJson = JSON.stringify({ id: 'project_' + Date.now(), name: newName, tracks: ss(tracks) }); try { const res = await window.SonicAPI.saveCloudProject(newName, dataJson); const newProjId = res.project_id; if (newProjId) { setCurrentProjectId(newProjId); localStorage.setItem('sonic_project_id', newProjId); } showToast(`Đã lưu dự án "${newName}" mới lên server!`, "success"); } catch (err) { showToast(err.message || "Lỗi lưu server", "error"); } }; const handleSaveAsCloud = async newName => { const ss = 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 })); const dataJson = JSON.stringify({ id: 'project_' + Date.now(), name: newName, tracks: ss(tracks) }); try { const res = await window.SonicAPI.saveCloudProject(newName, dataJson); const newProjId = res.project_id; setProjectName(newName); localStorage.setItem('sonic_project_name', newName); if (newProjId) { setCurrentProjectId(newProjId); localStorage.setItem('sonic_project_id', newProjId); } showToast(`Đã lưu dự án dưới tên mới "${newName}" lên server!`, "success"); } catch (err) { showToast(err.message || "Lỗi Save As lên server", "error"); } }; const handleSaveCloud = handleSaveProject; const handleExportSFS = (customName = null) => { const finalName = customName || projectName || 'Dự án SonicForge'; 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: currentProjectId || 'proj_' + Date.now(), name: finalName, 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); setProjectName(proj.name || 'Dự án mới'); setCurrentProjectId(null); localStorage.setItem('sonic_project_name', proj.name || 'Dự án mới'); localStorage.removeItem('sonic_project_id'); 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 outChannels = exportSettings.channels === 'mono' ? 1 : 2; const offlineCtx = new OfflineAudioContext(outChannels, 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 numExportCh = renderedBuffer.numberOfChannels; const exportLength = renderedBuffer.length; const bytesPerSample = bitDepth / 8; const headerSize = 44; const fileSizeBytes = headerSize + exportLength * bytesPerSample * numExportCh; 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, numExportCh, true); view.setUint32(24, targetRate, true); view.setUint32(28, targetRate * bytesPerSample * numExportCh, true); view.setUint16(32, bytesPerSample, true); view.setUint16(34, bitDepth, true); writeString(36, 'data'); view.setUint32(40, exportLength * bytesPerSample * numExportCh, true); let offset = 44; for (let i = 0; i < exportLength; i++) { for (let ch = 0; ch < numExportCh; ch++) { const chData = renderedBuffer.getChannelData(ch); const sample = Math.max(-1, Math.min(1, chData[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); if ((exportSettings.format === 'mp3' || exportSettings.format === 'ogg') && serverStatus === 'connected') { setIsExporting(true); showToast("Đang gửi yêu cầu nén định dạng lên máy chủ...", "info"); try { const file = new File([blob], `session_export.wav`, { type: 'audio/wav' }); const formData = new FormData(); formData.append('file', file); const uploadResp = await fetch(`${API_AUDIO}/upload`, { method: 'POST', body: formData }); if (!uploadResp.ok) throw new Error("Upload to transcode server failed"); const uploadData = await uploadResp.json(); const fileId = uploadData.file_id; const exportResp = await fetch(`${API_AUDIO}/export`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file_id: fileId, format: exportSettings.format, sample_rate: targetRate, bit_depth: bitDepth }) }); if (!exportResp.ok) throw new Error("Transcode request failed"); const exportData = await exportResp.json(); const result = await pollTaskResult(exportData.task_id, 20); if (result.success) { const outId = result.output_file_id; const downloadUrl = `${API_AUDIO}/download/${outId}`; showToast(`Xuất bản âm thanh định dạng ${exportSettings.format.toUpperCase()} thành công!`, "success", "Tải về", () => { const a = document.createElement('a'); a.href = downloadUrl; a.download = `sonicforge-session-${targetRate}hz-${bitDepth}bit.${exportSettings.format}`; a.click(); }); } else { throw new Error(result.error || 'Server encoding failed'); } } catch (transcodeErr) { showToast(`Lỗi chuyển đổi: ${transcodeErr.message}.`, "warning", "Tải WAV thay thế", () => { const a = document.createElement('a'); a.href = url; a.download = `sonicforge-session-${targetRate}hz-${bitDepth}bit.wav`; a.click(); }); } } else { if (exportSettings.format !== 'wav') { showToast("Đang ngoại tuyến. Tải về định dạng WAV thay thế.", "warning"); } const a = document.createElement('a'); a.href = url; a.download = `sonicforge-session-${targetRate}hz-${bitDepth}bit.wav`; a.click(); showToast("Xuất bản âm thanh hoàn tất!", "success"); } URL.revokeObjectURL(url); } 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); }; // ── AI Prompt Send to Active Provider ── const handleAISend = async () => { const prompt = aiPrompt.trim(); if (!prompt) { showToast('Vui lòng nhập nội dung prompt.', 'warning'); return; } if (window.DAWCommandDispatcher) { window.DAWCommandDispatcher.currentSelectedTrackId = selectedTrackId; window.DAWCommandDispatcher.currentTracks = tracks; window.DAWCommandDispatcher.lastCutSourceTrackId = null; window.DAWCommandDispatcher.lastCutNewTrackId = null; } setAiProcessing(true); setAiActionLog(prev => [...prev, { type: 'status', text: ` ⏳ Đang gửi prompt đến AI...`, time: Date.now() }]); try { if (aiProviders.length === 0 || !selectedProviderId) { try { const data = await window.SonicAPI.getAIConfigs(); if (data && data.providers && data.providers.length > 0) { setAiProviders(data.providers); const active = data.providers.find(p => p.is_active) || data.providers[0]; if (active) setSelectedProviderId(active.id); } } catch (e) {} } const prv = aiProviders.find(p => p.id === selectedProviderId) || (aiProviders.length > 0 ? aiProviders[0] : null); const provider = prv || aiConfig; const baseUrl = provider.api_base_url || provider.baseUrl || `${API_BASE_URL}`; const apiKey = provider.api_key || provider.apiKey || ''; const model = provider.model_name || provider.model || 'deepseek-chat'; setAiActionLog(prev => [...prev, { type: 'info', text: ` Provider: ${provider.name || 'default'} | Model: ${model} | URL: ${baseUrl.slice(0, 40)}`, time: Date.now() }]); const dawContext = window.AIGateway.buildAIPromptContext({ tracks, bpm, selectedTrackId, currentTime, selLeft, selRight }); const result = await window.AIGateway.executeAIPrompt({ prompt: prompt, provider: provider.name || 'default', model: model, apiKey: apiKey, baseUrl: baseUrl.replace(/\/chat\/completions$/, '').replace(/\/$/, ''), dawContext, tools: window.AIGateway.DEFAULT_TOOLS }); const hasText = !!result.textResponse; const hasCalls = result.functionCalls && result.functionCalls.length > 0; if (hasText) { setAiActionLog(prev => [...prev, { type: 'status', text: ` AI: ${result.textResponse.slice(0, 500)}`, time: Date.now() }]); } if (hasCalls) { setAiActionLog(prev => [...prev, { type: 'info', text: ` Gọi ${result.functionCalls.length} lệnh...`, time: Date.now() }]); if (window.DAWCommandDispatcher) { window.DAWCommandDispatcher.isExecutingAI = true; } try { for (const fc of result.functionCalls) { setAiActionLog(prev => [...prev, { type: 'info', text: ` Gọi lệnh: ${fc.name}(${JSON.stringify(fc.arguments)})`, time: Date.now() }]); const cmdName = fc.name.toUpperCase(); if (window.DAWCommandDispatcher) { try { let cmdResult = window.DAWCommandDispatcher.execute(cmdName, fc.arguments); if (cmdResult && typeof cmdResult.then === 'function') cmdResult = await cmdResult; setAiActionLog(prev => [...prev, { type: 'status', text: ` ✅ ${fc.name}: ${cmdResult && cmdResult.success ? 'thành công' : 'thất bại: ' + (cmdResult && cmdResult.error || 'unknown')}`, time: Date.now() }]); } catch (cmdErr) { setAiActionLog(prev => [...prev, { type: 'error', text: ` ❌ ${fc.name}: ${cmdErr.message}`, time: Date.now() }]); } } else { setAiActionLog(prev => [...prev, { type: 'error', text: ` ❌ ${fc.name}: DAWCommandDispatcher not available`, time: Date.now() }]); } } } finally { if (window.DAWCommandDispatcher) { window.DAWCommandDispatcher.isExecutingAI = false; } } } if (!hasText && !hasCalls) { const rawKeys = result.raw ? Object.keys(result.raw).join(', ') : 'null'; const errDetail = result.raw && result.raw.error ? ` (${result.raw.error.message || result.raw.error})` : ''; setAiActionLog(prev => [...prev, { type: 'error', text: ` AI không trả về lệnh hoặc text. Keys: [${rawKeys}]${errDetail}`, time: Date.now() }]); } if (prompt) { promptHistRef.current = [...promptHistRef.current.slice(-49), prompt]; setPromptHistory(promptHistRef.current); } setPromptHistIdx(-1); setAiActionLog(prev => [...prev, { type: 'status', text: ` Hoàn tất.`, time: Date.now() }]); setAiPrompt(''); setTimeout(() => lucide.createIcons(), 200); } catch (err) { setAiActionLog(prev => [...prev, { type: 'error', text: ` Lỗi: ${err.message}`, time: Date.now() }]); showToast(`AI Error: ${err.message}`, 'error'); } finally { setAiProcessing(false); } }; // ── 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'); }; // ── DAW Command Registration for AI (28_AI_PANEL.md §1 & §2) ── useEffect(() => { if (typeof window.DAWCommandDispatcher === 'undefined') return; const api = { createTrack: args => { const name = args.name || `AI_Track_${Date.now()}`; const type = args.type || 'audio'; const newId = addNewTrack(); if (name && name !== `AI_Track_${Date.now()}`) { updateTrackName(newId, name); } return { success: true, trackId: newId, name }; }, deleteTrack: args => { const tid = args.track_id || selectedTrackId; if (!tid) return { success: false, error: 'No track_id provided' }; deleteTrack(tid); return { success: true, trackId: tid }; }, addClip: args => { const trackId = args.track_id || selectedTrackId; const barDur = 60 / parseInt(bpm || 120) * 4; let startTime; if (args.start_time !== undefined && args.start_time !== null) startTime = args.start_time;else if (args.start_bar !== undefined && args.start_bar !== null) startTime = args.start_bar * barDur;else startTime = currentTime; const track = tracks.find(t => t.id === trackId); if (!track) return { success: false, error: 'Track not found' }; const ctx = getAudioContext(); const sr = 44100; let duration; if (args.duration_seconds !== undefined && args.duration_seconds !== null) duration = args.duration_seconds;else if (args.length_bars !== undefined && args.length_bars !== null) duration = args.length_bars * barDur;else duration = 2; const buffer = ctx.createBuffer(1, Math.floor(sr * duration), sr); const data = buffer.getChannelData(0); for (let i = 0; i < data.length; i++) data[i] = 0; const clipId = nextClipId(); setTracks(prev => prev.map(t => { if (t.id !== trackId) return t; const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{ id: 'default_' + t.id, buffer: t.buffer, startTime: t.startTime || 0, name: t.name }] : []; return { ...t, clips: [...clips, { id: clipId, buffer, startTime, name: args.name || 'AI Clip' }], buffer: clips.length > 0 ? clips[0].buffer : buffer, startTime: clips.length > 0 ? clips[0].startTime : startTime, name: clips.length > 0 ? clips[0].name : args.name || t.name }; })); return { success: true, clipId, trackId }; }, removeClip: args => { const trackId = args.track_id || selectedTrackId; const clipId = args.clip_id; setTracks(prev => prev.map(t => { if (t.id !== trackId) return t; const updatedClips = (t.clips || []).filter(c => c.id !== clipId); return { ...t, clips: updatedClips, buffer: updatedClips[0]?.buffer || null, startTime: updatedClips[0]?.startTime || 0, name: updatedClips[0]?.name || t.name }; })); return { success: true }; }, setTrackVolume: args => { const trackId = args.track_id || selectedTrackId; const vol = args.volume_db ?? args.volume ?? 0; updateTrackVolumeDb(trackId, parseFloat(vol)); return { success: true, trackId, volumeDb: vol }; }, setTrackPan: args => { const trackId = args.track_id || selectedTrackId; const pan = args.pan ?? 0; updateTrackPan(trackId, parseInt(pan)); return { success: true, trackId, pan }; }, toggleMute: args => { const trackId = args.track_id || selectedTrackId; toggleTrackMute(trackId); const track = tracks.find(t => t.id === trackId); return { success: true, trackId, muted: track ? track.muted : null }; }, toggleSolo: args => { const trackId = args.track_id || selectedTrackId; toggleTrackSoloEvaluate(trackId); const track = tracks.find(t => t.id === trackId); return { success: true, trackId, solo: track ? track.solo : null }; }, processAudioDsp: args => { const trackId = args.track_id || selectedTrackId; const action = args.action; const params = args.params || {}; const track = tracks.find(t => t.id === trackId); if (!track || !track.buffer) return { success: false, error: 'Track has no audio buffer' }; if (action === 'normalize') { const channelData = track.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; } return { success: true, action: 'normalize' }; } else if (action === 'invert_phase') { const channelData = track.buffer.getChannelData(0); for (let i = 0; i < channelData.length; i++) channelData[i] *= -1; return { success: true, action: 'invert_phase' }; } else if (action === 'gain') { const gainDb = params.gain_db ?? 0; const scale = Math.pow(10, gainDb / 20); const channelData = track.buffer.getChannelData(0); for (let i = 0; i < channelData.length; i++) channelData[i] = Math.max(-1, Math.min(1, channelData[i] * scale)); return { success: true, action: 'gain', gainDb }; } else if (action === 'pitch_shift') { const semitones = params.semitones ?? 0; const ratio = Math.pow(2, semitones / 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 channelData = track.buffer.getChannelData(0); const newData = applyResample(channelData, 1 / ratio); const ctx = getAudioContext(); const newBuffer = ctx.createBuffer(1, newData.length, track.buffer.sampleRate); newBuffer.copyToChannel(newData, 0); setTracks(prev => prev.map(t => t.id === trackId ? { ...t, buffer: newBuffer } : t)); return { success: true, action: 'pitch_shift', semitones }; } return { success: false, error: `Unknown action: ${action}` }; }, renameTrack: args => { const tid = args.track_id || selectedTrackId; const name = args.name; if (!tid) return { success: false, error: 'No track_id provided' }; if (!name) return { success: false, error: 'No name provided' }; updateTrackName(tid, name); return { success: true, trackId: tid, name }; }, setSelection: args => { const barDur = 60 / parseInt(bpm || 120) * 4; let start, end; if (args.start_time !== undefined && args.start_time !== null) start = args.start_time;else if (args.start_bar !== undefined && args.start_bar !== null) start = args.start_bar * barDur;else start = currentTime; if (args.end_time !== undefined && args.end_time !== null) end = args.end_time;else if (args.length_bars !== undefined && args.length_bars !== null) end = start + args.length_bars * barDur;else if (args.end_bar !== undefined && args.end_bar !== null) end = args.end_bar * barDur;else end = start + barDur; clearLocalSelection(); setSelectionMode('global'); setSelectionStart(start); setSelectionEnd(end); selectionRef.current = { start, end }; return { success: true, start: parseFloat(start.toFixed(3)), end: parseFloat(end.toFixed(3)), length: parseFloat((end - start).toFixed(3)) }; }, cutAudio: args => { const currentTracks = window.DAWCommandDispatcher?.currentTracks || tracks; const currentSelTrackId = window.DAWCommandDispatcher?.currentSelectedTrackId || selectedTrackId; const tid = args.track_id || currentSelTrackId; const track = currentTracks.find(t => t.id === String(tid)); if (!track) return { success: false, error: 'Track not found' }; if (!track.buffer) return { success: false, error: 'Track has no audio buffer' }; const barDur = 60 / parseInt(bpm || 120) * 4; const sel = selectionRef.current; let rawStart, rawEnd; if (args.start_time !== undefined && args.start_time !== null) rawStart = args.start_time;else if (args.start_bar !== undefined && args.start_bar !== null) rawStart = args.start_bar * barDur;else if (sel.start !== null) rawStart = sel.start;else rawStart = currentTime; if (args.end_time !== undefined && args.end_time !== null) rawEnd = args.end_time;else if (args.end_bar !== undefined && args.end_bar !== null) rawEnd = args.end_bar * barDur;else if (args.length_bars !== undefined && args.length_bars !== null) rawEnd = rawStart + args.length_bars * barDur;else if (sel.end !== null && sel.end > rawStart) rawEnd = sel.end;else return { success: false, error: 'No end position provided. Provide end_time, end_bar, or length_bars.' }; if (rawEnd <= rawStart) return { success: false, error: 'End position must be after start position.' }; const buffer = track.buffer; const ctx = getAudioContext(); const snap = args.snap_silence !== false; const loopStart = snap ? findZeroCrossing(buffer, rawStart) : rawStart; const loopEnd = snap ? findZeroCrossing(buffer, rawEnd) : rawEnd; const sampleRate = buffer.sampleRate; const startSample = Math.max(0, Math.min(buffer.length - 1, Math.floor(loopStart * sampleRate))); const endSample = Math.max(0, Math.min(buffer.length, Math.floor(loopEnd * sampleRate))); const sliceLength = endSample - startSample; if (sliceLength <= 100) return { success: false, error: 'Selection too short or invalid' }; const numChannels = buffer.numberOfChannels || 1; const slicedBuffer = ctx.createBuffer(numChannels, sliceLength, sampleRate); for (let c = 0; c < numChannels; c++) { const src = buffer.getChannelData(c); const dst = slicedBuffer.getChannelData(c); dst.set(src.subarray(startSample, endSample)); } const newId = 'track_cut_' + Date.now(); const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; const color = colors[currentTracks.length % colors.length]; const cutName = args.new_track_name || `Cut_${track.name}`; const newTrack = { id: newId, name: cutName, buffer: slicedBuffer, startTime: 0, height: 128, volumeDb: 0, pan: 0, muted: false, solo: false, color, markers: [], clips: [{ id: 'clip_' + newId, buffer: slicedBuffer, startTime: 0, name: cutName }], serverFileId: null }; const idx = currentTracks.findIndex(t => t.id === tid); const nextTracks = [...currentTracks]; if (idx !== -1) { nextTracks.splice(idx + 1, 0, newTrack); } else { nextTracks.push(newTrack); } if (window.DAWCommandDispatcher) { window.DAWCommandDispatcher.currentTracks = nextTracks; window.DAWCommandDispatcher.currentSelectedTrackId = newId; window.DAWCommandDispatcher.lastCutSourceTrackId = tid; window.DAWCommandDispatcher.lastCutNewTrackId = newId; } setTracks(nextTracks); setSelectedTrackId(newId); selectionRef.current = { start: 0, end: slicedBuffer.duration }; clearLocalSelection(); setSelectionMode('global'); setSelectionStart(0); setSelectionEnd(slicedBuffer.duration); setTimeout(() => lucide.createIcons(), 200); return { success: true, trackId: newId, trackName: cutName, cutStart: parseFloat(loopStart.toFixed(3)), cutEnd: parseFloat(loopEnd.toFixed(3)), duration: parseFloat(slicedBuffer.duration.toFixed(3)) }; }, scanTrack: args => { const tid = args.track_id || selectedTrackId; const track = tracks.find(t => t.id === tid); if (!track) return { success: false, error: 'Track not found' }; if (!track.buffer) return { success: false, error: 'Track has no audio buffer. Load audio first.' }; const buffer = track.buffer; const data = buffer.getChannelData(0); const sr = buffer.sampleRate; const channels = buffer.numberOfChannels; const duration = buffer.duration; const totalSamples = buffer.length; const windowSize = Math.min(sr * 3, data.length); let detectedBPM = 0; 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(300, Math.max(30, detectedBPM))); if (args.set_tempo !== false && detectedBPM > 0) { setBpm(String(detectedBPM)); } const bitDepth = 16; const bitrate = Math.round(sr * channels * bitDepth / 1000); return { success: true, trackId: tid, trackName: track.name, bpm: detectedBPM, sampleRate: sr, channels, duration: parseFloat(duration.toFixed(3)), totalSamples, bitDepth, bitrateKbps: bitrate, hasAudio: true }; }, setBpm: args => { const bpmVal = args.bpm || args.tempo || 120; setBpm(String(bpmVal)); return { success: true, bpm: bpmVal }; }, setPlayhead: args => { const barDur = 60 / parseInt(bpm || 120) * 4; let time; if (args.time !== undefined && args.time !== null) time = args.time;else if (args.bar !== undefined && args.bar !== null) time = args.bar * barDur;else time = 0; handlePlayheadSet(time); return { success: true, time: parseFloat(time.toFixed(3)) }; }, exportAudio: async args => { const currentTracks = window.DAWCommandDispatcher?.currentTracks || tracks; const currentSelTrackId = window.DAWCommandDispatcher?.currentSelectedTrackId || selectedTrackId; let tid = args.track_id; if (tid && window.DAWCommandDispatcher?.lastCutSourceTrackId && (String(tid) === String(window.DAWCommandDispatcher.lastCutSourceTrackId) || 'track_' + tid === String(window.DAWCommandDispatcher.lastCutSourceTrackId))) { tid = window.DAWCommandDispatcher.lastCutNewTrackId; } if (!tid) tid = currentSelTrackId; const track = tid && currentTracks.find(t => t.id === String(tid) || t.id === 'track_' + tid); if (!track) return { success: false, error: 'No track found' }; 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 }] : []; if (clips.length === 0) return { success: false, error: 'Track has no audio clips' }; const totalDuration = Math.max(...clips.map(c => (c.startTime || 0) + (c.buffer ? c.buffer.duration / (c.speed || 1.0) : 0))); const barDur = 60 / parseInt(bpm || 120) * 4; const sel = selectionRef.current; let rawStart, rawEnd; if (args.start_time !== undefined) rawStart = args.start_time;else if (args.start_bar !== undefined) rawStart = args.start_bar * barDur;else if (sel.start !== null) rawStart = sel.start;else rawStart = 0; if (args.end_time !== undefined) rawEnd = args.end_time;else if (args.length_bars !== undefined) rawEnd = (rawStart || 0) + args.length_bars * barDur;else if (args.end_bar !== undefined) rawEnd = args.end_bar * barDur;else if (sel.end !== null && sel.end > rawStart) rawEnd = sel.end;else rawEnd = totalDuration; if (rawEnd <= rawStart) return { success: false, error: 'Export range is empty or invalid.' }; const ctx = getAudioContext(); const sr = parseInt(args.sample_rate || '44100'); const firstBuffer = clips.find(c => c.buffer)?.buffer; const numCh = args.channels === 'mono' ? 1 : firstBuffer ? firstBuffer.numberOfChannels : 2; const bd = parseInt(args.bit_depth || '16'); const fmt = args.format || 'wav'; const renderLength = rawEnd - rawStart; const offlineCtx = new OfflineAudioContext(numCh, Math.ceil(sr * renderLength), sr); clips.forEach(clip => { if (!clip.buffer) return; const clipStart = clip.startTime || 0; const clipDuration = clip.buffer.duration / (clip.speed || 1.0); const clipEnd = clipStart + clipDuration; if (clipEnd <= rawStart || clipStart >= rawEnd) return; const source = offlineCtx.createBufferSource(); source.buffer = clip.buffer; source.playbackRate.value = clip.speed || 1.0; source.connect(offlineCtx.destination); if (rawStart < clipStart) { const delay = clipStart - rawStart; const playDur = Math.min(clipDuration, rawEnd - clipStart); source.start(delay, 0, playDur * (clip.speed || 1.0)); } else { const offsetInClip = rawStart - clipStart; const playDur = Math.min(clipEnd - rawStart, renderLength); source.start(0, offsetInClip * (clip.speed || 1.0), playDur * (clip.speed || 1.0)); } }); const renderedBuffer = await offlineCtx.startRendering(); const len = renderedBuffer.length; const bps = bd / 8; const hdrSz = 44; const fileBuf = new ArrayBuffer(hdrSz + len * bps * numCh); const vw = new DataView(fileBuf); const ws = (off, s) => { for (let i = 0; i < s.length; i++) vw.setUint8(off + i, s.charCodeAt(i)); }; ws(0, 'RIFF'); vw.setUint32(4, fileBuf.byteLength - 8, true); ws(8, 'WAVE'); ws(12, 'fmt '); vw.setUint32(16, 16, true); vw.setUint16(20, 1, true); vw.setUint16(22, numCh, true); vw.setUint32(24, sr, true); vw.setUint32(28, sr * bps * numCh, true); vw.setUint16(32, bps * numCh, true); vw.setUint16(34, bd, true); ws(36, 'data'); vw.setUint32(40, len * bps * numCh, true); let ofs = 44; for (let i = 0; i < len; i++) { for (let ch = 0; ch < numCh; ch++) { const smp = Math.max(-1, Math.min(1, renderedBuffer.getChannelData(ch)[i])); if (bd === 8) vw.setUint8(ofs, Math.floor((smp + 1) * 127.5));else if (bd === 16) vw.setInt16(ofs, Math.floor(smp < 0 ? smp * 0x8000 : smp * 0x7FFF), true);else { const v24 = Math.floor(smp < 0 ? smp * 0x800000 : smp * 0x7FFFFF); vw.setUint8(ofs, v24 & 0xFF); vw.setUint8(ofs + 1, v24 >> 8 & 0xFF); vw.setUint8(ofs + 2, v24 >> 16 & 0xFF); } ofs += bps; } } const blob = new Blob([fileBuf], { type: 'audio/wav' }); const localUrl = URL.createObjectURL(blob); const targetFilename = `export_${Date.now()}.${fmt}`; const triggerDownload = (downloadUrl, finalFilename) => { if (window.DAWCommandDispatcher?.isExecutingAI) { showToast(`Xuất nhạc thành công (${fmt.toUpperCase()})!`, "success", "Tải về", () => { const a = document.createElement('a'); a.href = downloadUrl; a.download = finalFilename; a.click(); if (downloadUrl.startsWith('blob:')) { URL.revokeObjectURL(downloadUrl); } }); } else { const a = document.createElement('a'); a.href = downloadUrl; a.download = finalFilename; a.click(); showToast("Xuất bản âm thanh hoàn tất!", "success"); if (downloadUrl.startsWith('blob:')) { URL.revokeObjectURL(downloadUrl); } } }; if ((fmt === 'mp3' || fmt === 'ogg') && serverStatus === 'connected') { try { const file = new File([blob], `export_ai.wav`, { type: 'audio/wav' }); const formData = new FormData(); formData.append('file', file); const uploadResp = await fetch(`${API_AUDIO}/upload`, { method: 'POST', body: formData }); if (!uploadResp.ok) throw new Error("Upload failed"); const uploadData = await uploadResp.json(); const uploadId = uploadData.file_id; const exportResp = await fetch(`${API_AUDIO}/export`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file_id: uploadId, format: fmt, sample_rate: sr, bit_depth: bd }) }); if (!exportResp.ok) throw new Error("Export failed"); const exportData = await exportResp.json(); const result = await pollTaskResult(exportData.task_id, 20); if (result.success) { const downloadUrl = `${API_AUDIO}/download/${result.output_file_id}`; triggerDownload(downloadUrl, targetFilename); } else { throw new Error(result.error || 'Server encoding failed'); } } catch (transcodeErr) { showToast(`Lỗi chuyển đổi: ${transcodeErr.message}. Tải về dạng WAV thay thế.`, "warning"); triggerDownload(localUrl, `export_${Date.now()}.wav`); } } else { const finalFilename = fmt === 'wav' ? `export_${Date.now()}.wav` : `export_${Date.now()}.wav`; if (fmt !== 'wav') { showToast("Đang ngoại tuyến. Tải về định dạng WAV thay thế.", "warning"); } triggerDownload(localUrl, finalFilename); } return { success: true, trackId: tid, range: parseFloat((rawEnd - rawStart).toFixed(3)) + 's', format: fmt, channels: numCh === 1 ? 'mono' : 'stereo' }; }, selectItem: args => { if (args.select_all) { setSelectedTrackId(null); clearLocalSelection(); setSelectionMode('global'); setSelectionStart(0); const maxDur = tracks.reduce((max, t) => { const dur = t.buffer ? t.buffer.duration : 0; const clips = t.clips || []; const clipMax = clips.reduce((m, c) => Math.max(m, (c.startTime || 0) + (c.buffer ? c.buffer.duration : 0)), 0); return Math.max(max, dur, clipMax); }, 0); setSelectionEnd(Math.max(maxDur, currentTime + 10)); return { success: true, selection: 'all', duration: parseFloat(Math.max(maxDur, currentTime + 10).toFixed(3)) }; } const tid = args.track_id || selectedTrackId; const track = tracks.find(t => t.id === tid); if (!track) return { success: false, error: `Track ${tid} not found` }; const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ id: 'default_' + track.id, name: track.name, buffer: track.buffer, startTime: track.startTime || 0 }] : []; if (args.item_name) { const match = clips.find(c => c.name && c.name.toLowerCase().includes(args.item_name.toLowerCase())); if (!match) return { success: false, error: `No clip matching "${args.item_name}" on track ${track.name}` }; setSelectedTrackId(tid); clearLocalSelection(); setSelectionMode('global'); const start = match.startTime || 0; const end = start + (match.buffer ? match.buffer.duration : 2); setSelectionStart(start); setSelectionEnd(end); return { success: true, trackId: tid, trackName: track.name, clipId: match.id, clipName: match.name, start: parseFloat(start.toFixed(3)), end: parseFloat(end.toFixed(3)) }; } setSelectedTrackId(tid); clearLocalSelection(); setSelectionMode('global'); const trackEnd = track.buffer ? track.buffer.duration : clips.length > 0 ? Math.max(...clips.map(c => (c.startTime || 0) + (c.buffer ? c.buffer.duration : 0))) : 4; setSelectionStart(0); setSelectionEnd(trackEnd); return { success: true, trackId: tid, trackName: track.name, duration: parseFloat(trackEnd.toFixed(3)) }; }, addMarker: args => { const trackId = args.track_id || selectedTrackId; const time = args.time ?? currentTime; const track = tracks.find(t => t.id === trackId); if (!track) return { success: false, error: 'Track not found' }; setTracks(prev => prev.map(t => { if (t.id !== trackId) return t; return { ...t, markers: [...(t.markers || []), { id: 'ai_marker_' + Date.now(), time, label: args.label || 'AI Marker' }] }; })); return { success: true, trackId, time }; }, fadeIn: args => { const currentTracks = window.DAWCommandDispatcher?.currentTracks || tracks; const currentSelTrackId = window.DAWCommandDispatcher?.currentSelectedTrackId || selectedTrackId; let trackIdRaw = args.track_id; if (trackIdRaw && window.DAWCommandDispatcher?.lastCutSourceTrackId && (String(trackIdRaw) === String(window.DAWCommandDispatcher.lastCutSourceTrackId) || 'track_' + trackIdRaw === String(window.DAWCommandDispatcher.lastCutSourceTrackId))) { trackIdRaw = window.DAWCommandDispatcher.lastCutNewTrackId; } if (!trackIdRaw) trackIdRaw = currentSelTrackId; const track = currentTracks.find(t => t.id === String(trackIdRaw) || t.id === 'track_' + trackIdRaw); if (!track) return { success: false, error: `Track ${trackIdRaw} not found` }; 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 }] : []; if (clips.length === 0) return { success: false, error: 'No clips on track' }; let clip = null; if (args.clip_id) { clip = clips.find(c => c.id === args.clip_id); } else if (args.clip_index !== undefined) { const idx = parseInt(args.clip_index); const realIdx = idx > 0 ? idx - 1 : 0; clip = clips[realIdx] || clips[0]; } else { clip = clips[0]; } if (!clip || !clip.buffer) return { success: false, error: 'Clip has no audio buffer' }; const duration = parseFloat(args.duration_seconds || 3); const buffer = clip.buffer; const sr = buffer.sampleRate; const numChannels = buffer.numberOfChannels; const length = buffer.length; const ctx = getAudioContext(); const newBuffer = ctx.createBuffer(numChannels, length, sr); for (let c = 0; c < numChannels; c++) { newBuffer.getChannelData(c).set(buffer.getChannelData(c)); } const fadeSamples = Math.min(length, Math.floor(duration * sr)); for (let c = 0; c < numChannels; c++) { const data = newBuffer.getChannelData(c); for (let i = 0; i < fadeSamples; i++) { const factor = (1 - Math.cos(Math.PI * i / fadeSamples)) / 2; data[i] *= factor; } } const beforeSnap = captureTrackSnapshot(track.id); const nextTracks = currentTracks.map(t => { if (t.id !== track.id) return t; 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.map(c => { if (c.id === clip.id || clip.id.startsWith('default_') && c.id === 'default') { return { ...c, buffer: newBuffer }; } return c; }); const mainBuffer = updatedClips[0]?.buffer || t.buffer; return { ...t, clips: updatedClips, buffer: mainBuffer }; }); if (window.DAWCommandDispatcher) { window.DAWCommandDispatcher.currentTracks = nextTracks; } setTracks(nextTracks); setTimeout(() => { const afterSnap = captureTrackSnapshot(track.id); pushAction('AI_FADE_IN', track.id, beforeSnap, afterSnap); }, 50); return { success: true, trackId: track.id, clipId: clip.id, duration_seconds: duration }; }, fadeOut: args => { const currentTracks = window.DAWCommandDispatcher?.currentTracks || tracks; const currentSelTrackId = window.DAWCommandDispatcher?.currentSelectedTrackId || selectedTrackId; let trackIdRaw = args.track_id; if (trackIdRaw && window.DAWCommandDispatcher?.lastCutSourceTrackId && (String(trackIdRaw) === String(window.DAWCommandDispatcher.lastCutSourceTrackId) || 'track_' + trackIdRaw === String(window.DAWCommandDispatcher.lastCutSourceTrackId))) { trackIdRaw = window.DAWCommandDispatcher.lastCutNewTrackId; } if (!trackIdRaw) trackIdRaw = currentSelTrackId; const track = currentTracks.find(t => t.id === String(trackIdRaw) || t.id === 'track_' + trackIdRaw); if (!track) return { success: false, error: `Track ${trackIdRaw} not found` }; 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 }] : []; if (clips.length === 0) return { success: false, error: 'No clips on track' }; let clip = null; if (args.clip_id) { clip = clips.find(c => c.id === args.clip_id); } else if (args.clip_index !== undefined) { const idx = parseInt(args.clip_index); const realIdx = idx > 0 ? idx - 1 : 0; clip = clips[realIdx] || clips[0]; } else { clip = clips[0]; } if (!clip || !clip.buffer) return { success: false, error: 'Clip has no audio buffer' }; const duration = parseFloat(args.duration_seconds || 3); const buffer = clip.buffer; const sr = buffer.sampleRate; const numChannels = buffer.numberOfChannels; const length = buffer.length; const ctx = getAudioContext(); const newBuffer = ctx.createBuffer(numChannels, length, sr); for (let c = 0; c < numChannels; c++) { newBuffer.getChannelData(c).set(buffer.getChannelData(c)); } const fadeSamples = Math.min(length, Math.floor(duration * sr)); for (let c = 0; c < numChannels; c++) { const data = newBuffer.getChannelData(c); for (let i = 0; i < fadeSamples; i++) { const idx = length - fadeSamples + i; const factor = (1 + Math.cos(Math.PI * i / fadeSamples)) / 2; data[idx] *= factor; } } const beforeSnap = captureTrackSnapshot(track.id); const nextTracks = currentTracks.map(t => { if (t.id !== track.id) return t; 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.map(c => { if (c.id === clip.id || clip.id.startsWith('default_') && c.id === 'default') { return { ...c, buffer: newBuffer }; } return c; }); const mainBuffer = updatedClips[0]?.buffer || t.buffer; return { ...t, clips: updatedClips, buffer: mainBuffer }; }); if (window.DAWCommandDispatcher) { window.DAWCommandDispatcher.currentTracks = nextTracks; } setTracks(nextTracks); setTimeout(() => { const afterSnap = captureTrackSnapshot(track.id); pushAction('AI_FADE_OUT', track.id, beforeSnap, afterSnap); }, 50); return { success: true, trackId: track.id, clipId: clip.id, duration_seconds: duration }; } }; window.DAWCommandDispatcher.registerDAWCommands(api); }, [tracks, selectedTrackId, currentTime, bpm]); // ── 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]); // ── Auto-save user preferences (panel state, provider) ── const prefsRef = useRef({}); prefsRef.current = { showAIPanel, showExportPanel, showSelectionPanel, showPythonToolsPanel, showMediaExplorer, showFxRack, showMidiEvents, panelPositions, rightSidebarWidth, mediaExplorerHeight, selectedProviderId }; useEffect(() => { const prefs = prefsRef.current; localStorage.setItem('sonic_preferences', JSON.stringify(prefs)); if (!currentUser || currentUser === 'cached') return; const timer = setTimeout(async () => { try { await window.SonicAPI.savePreferences(prefs); } catch (e) {} }, 2000); return () => clearTimeout(timer); }, [showAIPanel, showExportPanel, showSelectionPanel, showPythonToolsPanel, showMediaExplorer, showFxRack, showMidiEvents, panelPositions, rightSidebarWidth, mediaExplorerHeight, selectedProviderId, currentUser]); return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { className: "h-full w-full flex flex-col bg-[#1e1e1e]" }, /*#__PURE__*/React.createElement("header", { className: "h-9 bg-[#2e2e2e] border-b border-[#181818] flex items-center px-1 shrink-0 select-none" }, [{ label: 'File', items: [{ label: 'New Project', icon: 'file-plus', shortcut: 'Ctrl+N', action: () => { setTracks([{ id: '1', name: 'Track 01', buffer: null, startTime: 0, height: 128, volumeDb: 0, pan: 0, muted: false, solo: false, color: '#0f766e', markers: [], serverFileId: null, clips: [], sections: [], midiItems: [] }, { id: '2', name: 'Track 02', buffer: null, startTime: 0, height: 128, volumeDb: 0, pan: 0, muted: false, solo: false, color: '#1d4ed8', markers: [], serverFileId: null, clips: [], sections: [], midiItems: [] }]); setSelectedTrackId('1'); setProjectName(''); setCurrentProjectId(null); localStorage.removeItem('sonic_project_name'); localStorage.removeItem('sonic_project_id'); showToast('New project created', 'info'); } }, { label: 'Open Project...', icon: 'folder-open', shortcut: 'Ctrl+O', action: () => handleImportSFS() }, { label: 'Save Project', icon: 'upload-cloud', shortcut: 'Ctrl+S', action: () => handleSaveProject() }, { label: 'Save As...', icon: 'download', shortcut: 'Ctrl+Alt+S', action: () => setSaveAsModalOpen(true) }, { 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: 'Insert', items: [...(!sessionTabs.some(s => s.id === activeTab) ? [{ label: 'Insert Section', icon: 'folder-plus', action: insertSectionAtPlayhead }] : []), { label: 'Insert MIDI item', icon: 'music', action: insertMidiItemAtPlayhead }, { label: 'Insert sound clip', icon: 'file-input', action: insertSoundClipAtCursor }, { label: 'Insert track', icon: 'plus', action: insertTrackBelow }] }, { 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: 'DSP Tools Panel', icon: 'wrench', action: () => openPanel('python_tools') }] }, { label: 'Help', items: [{ label: 'About SonicForge', icon: 'info', action: () => showToast('SonicForge Studio v1.0 - Professional DAW', 'info') }] }].map(menu => /*#__PURE__*/React.createElement("div", { key: menu.label, className: "relative" }, /*#__PURE__*/React.createElement("button", { onClick: () => setMenuOpen(menuOpen === menu.label ? null : menu.label), className: `px-3 py-1 text-xs font-medium transition rounded ${menuOpen === menu.label ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}` }, menu.label), menuOpen === menu.label && /*#__PURE__*/React.createElement("div", { className: `absolute top-full left-0 mt-0.5 bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 z-50 ${menu.label === 'Edit' ? 'w-72' : 'w-64'}`, onClick: () => setMenuOpen(null) }, menu.items.map((item, i) => item.sep ? /*#__PURE__*/React.createElement("div", { key: i, className: "h-px bg-zinc-700 my-1" }) : /*#__PURE__*/React.createElement("button", { key: item.label, onClick: e => { e.stopPropagation(); item.action(); setMenuOpen(null); }, className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": item.icon, className: "w-3.5 h-3.5 text-zinc-500 shrink-0" })), /*#__PURE__*/React.createElement("span", { className: "flex-1" }, item.label), item.shortcut && /*#__PURE__*/React.createElement("span", { className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8" }, item.shortcut)))))), /*#__PURE__*/React.createElement("div", { className: "flex-1" }), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-2 px-2" }, /*#__PURE__*/React.createElement("span", { className: `text-xs font-bold uppercase px-1.5 py-0.5 rounded ${serverStatus === 'connected' ? 'bg-emerald-950 text-emerald-400' : serverStatus === 'checking' ? 'bg-amber-950 text-amber-400' : 'bg-red-950 text-red-400'}` }, "Server: ", serverStatus), /*#__PURE__*/React.createElement("button", { onClick: () => setShowAIConfig(!showAIConfig), className: `px-1.5 py-0.5 rounded text-xs border transition ${showAIConfig ? 'bg-purple-900 text-purple-200 border-purple-700' : 'bg-zinc-800 text-zinc-400 border-zinc-700 hover:text-zinc-200'}` }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "cpu", className: "w-3 h-3" }))))), menuOpen && /*#__PURE__*/React.createElement("div", { className: "fixed inset-0 z-40", onClick: () => setMenuOpen(null) }), /*#__PURE__*/React.createElement("div", { className: "h-7 bg-[#222] border-b border-zinc-800 flex items-stretch px-2 gap-0.5 shrink-0 overflow-x-auto" }, /*#__PURE__*/React.createElement("button", { onClick: () => setActiveTab('main'), className: `px-3 text-xs font-bold uppercase tracking-wider border-b-2 transition flex items-center gap-1 ${activeTab === 'main' ? 'text-cyan-400 border-cyan-500 bg-zinc-800/50' : 'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'}` }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "layout-dashboard", className: "w-3 h-3" })), " Main Session"), sessionTabs.map(st => /*#__PURE__*/React.createElement("div", { key: st.id, className: "flex items-stretch" }, /*#__PURE__*/React.createElement("button", { onClick: () => setActiveTab(st.id), className: `px-2 text-xs font-medium border-b-2 transition flex items-center gap-1 ${activeTab === st.id ? 'text-cyan-400 border-cyan-500 bg-zinc-800/50' : 'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'}` }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "layout-dashboard", className: "w-3 h-3" })), /*#__PURE__*/React.createElement("span", { className: "max-w-[120px] truncate" }, st.name)), /*#__PURE__*/React.createElement("button", { onClick: () => closeSessionTab(st.id), className: "px-1 text-zinc-600 hover:text-red-400 transition text-xs", title: "Close tab" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "x", className: "w-3 h-3" }))))), subTabs.map(st => /*#__PURE__*/React.createElement("div", { key: st.id, className: "flex items-stretch" }, /*#__PURE__*/React.createElement("button", { onClick: () => setActiveTab(st.id), className: `px-2 text-xs font-medium border-b-2 transition flex items-center gap-1 ${activeTab === st.id ? 'text-amber-400 border-amber-500 bg-zinc-800/50' : 'text-zinc-500 border-transparent hover:text-zinc-300 hover:bg-zinc-800/30'}` }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "file-edit", className: "w-3 h-3" })), /*#__PURE__*/React.createElement("span", { className: "max-w-[100px] truncate" }, st.label)), /*#__PURE__*/React.createElement("button", { onClick: () => closeSubTab(st.id), className: "px-1 text-zinc-600 hover:text-red-400 transition text-xs", title: "Close tab" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "x", className: "w-3 h-3" })))))), showAIConfig && /*#__PURE__*/React.createElement("div", { className: "bg-zinc-900 border-b border-purple-900 p-3 flex flex-col gap-2 transition-all" }, /*#__PURE__*/React.createElement("div", { className: "text-xs font-bold text-purple-400 uppercase tracking-wider flex items-center gap-1" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "cpu", className: "w-4 h-4" })), " Cấu hình cổng kết nối API"), /*#__PURE__*/React.createElement("div", { className: "grid grid-cols-1 md:grid-cols-3 gap-2 text-xs" }, /*#__PURE__*/React.createElement("div", { className: "flex flex-col gap-1" }, /*#__PURE__*/React.createElement("span", { className: "text-xs text-zinc-500 font-bold uppercase" }, "Endpoint Base URL"), /*#__PURE__*/React.createElement("input", { type: "text", value: aiConfig.baseUrl, onChange: e => setAiConfig(prev => ({ ...prev, baseUrl: e.target.value })), className: "bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-xs", placeholder: "https://api.openai.com/v1" })), /*#__PURE__*/React.createElement("div", { className: "flex flex-col gap-1" }, /*#__PURE__*/React.createElement("span", { className: "text-xs text-zinc-500 font-bold uppercase" }, "API Token Key"), /*#__PURE__*/React.createElement("input", { type: "password", value: aiConfig.apiKey, onChange: e => setAiConfig(prev => ({ ...prev, apiKey: e.target.value })), className: "bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-xs", placeholder: "sk-..." })), /*#__PURE__*/React.createElement("div", { className: "flex flex-col gap-1" }, /*#__PURE__*/React.createElement("span", { className: "text-xs text-zinc-500 font-bold uppercase" }, "Model Name"), /*#__PURE__*/React.createElement("input", { type: "text", value: aiConfig.model, onChange: e => setAiConfig(prev => ({ ...prev, model: e.target.value })), className: "bg-zinc-850 text-zinc-200 p-1.5 rounded border border-zinc-800 focus:outline-none focus:border-purple-600 font-mono text-xs", placeholder: "gpt-4o-mini" })))), /*#__PURE__*/React.createElement("div", { className: "h-9 bg-[#222] border-b border-zinc-800 flex items-center px-3 gap-2 shrink-0 select-none" }, /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1 bg-zinc-900 border border-zinc-700 rounded px-1.5 py-0.5 shadow-lg", title: "Kéo để di chuyển toolbar", style: { cursor: 'grab' } }, /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-0.5 mr-1 text-zinc-600" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "grip-vertical", className: "w-3 h-3" }))), /*#__PURE__*/React.createElement("button", { onClick: () => { setActiveTool('select'); showToast('Select Tool', 'info'); }, className: `w-7 h-7 flex items-center justify-center rounded ${activeTool === 'select' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`, title: "Select Tool (V)" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "mouse-pointer", className: "w-3.5 h-3.5" }))), /*#__PURE__*/React.createElement("button", { onClick: () => { setActiveTool('grab'); showToast('Grab Tool', 'info'); }, className: `w-7 h-7 flex items-center justify-center rounded ${activeTool === 'grab' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`, title: "Grab Tool (H)" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "hand", className: "w-3.5 h-3.5" }))), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-0.5 border border-zinc-700 rounded bg-zinc-850 px-0.5" }, /*#__PURE__*/React.createElement("button", { onClick: () => { setActiveTool('razor'); showToast('Razor Tool', 'info'); }, className: `w-7 h-7 flex items-center justify-center rounded ${activeTool === 'razor' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`, title: "Razor Tool (C)" }, /*#__PURE__*/React.createElement("svg", { className: "w-3.5 h-3.5 text-orange-400", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round" }, /*#__PURE__*/React.createElement("path", { d: "M6 3h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z" }), /*#__PURE__*/React.createElement("path", { d: "M4 9h16l-3 9H7z" }), /*#__PURE__*/React.createElement("circle", { cx: "12", cy: "6", r: "1" }))), /*#__PURE__*/React.createElement("button", { onClick: handleGlueTracks, className: "w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-purple-400 hover:bg-zinc-800", title: "Glue Clips" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "link", className: "w-3.5 h-3.5" })))), /*#__PURE__*/React.createElement("button", { onClick: () => { setActiveTool('pen'); showToast('Pen Tool', 'info'); }, className: `w-7 h-7 flex items-center justify-center rounded ${activeTool === 'pen' ? 'bg-cyan-700 text-white' : 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800'}`, title: "Pen Tool (P)" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "pen-tool", className: "w-3.5 h-3.5" }))), /*#__PURE__*/React.createElement("div", { className: "w-[1px] h-5 bg-zinc-700 mx-0.5" }), /*#__PURE__*/React.createElement("button", { onClick: handleCutTrack, className: "w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-red-400 hover:bg-zinc-800", title: "Cut (Ctrl+X)" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "scissors", className: "w-3.5 h-3.5" }))), /*#__PURE__*/React.createElement("button", { onClick: handleCopyTrack, className: "w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-blue-400 hover:bg-zinc-800", title: "Copy (Ctrl+C)" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "copy", className: "w-3.5 h-3.5" }))), /*#__PURE__*/React.createElement("button", { onClick: handlePasteTrack, className: "w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-emerald-400 hover:bg-zinc-800", title: "Paste (Ctrl+V)" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "clipboard", className: "w-3.5 h-3.5" }))), /*#__PURE__*/React.createElement("div", { className: "w-[1px] h-5 bg-zinc-700 mx-0.5" }), /*#__PURE__*/React.createElement("button", { onClick: addNewTrack, className: "px-2 py-1 bg-cyan-600 hover:bg-cyan-500 text-white font-semibold rounded text-xs flex items-center gap-1 shadow transition", title: "Thêm Track Mới (Ctrl+I)" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "plus", className: "w-3.5 h-3.5" })), /*#__PURE__*/React.createElement("span", null, "Track")), /*#__PURE__*/React.createElement("div", { className: "w-[1px] h-5 bg-zinc-700 mx-0.5" }), /*#__PURE__*/React.createElement("button", { onClick: handleUndo, disabled: undoStack.length === 0, className: "w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 disabled:opacity-30", title: "Undo (Ctrl+Z)" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "undo", className: "w-3.5 h-3.5" }))), /*#__PURE__*/React.createElement("button", { onClick: handleRedo, disabled: redoStack.length === 0, className: "w-7 h-7 flex items-center justify-center rounded text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800 disabled:opacity-30", title: "Redo (Ctrl+Y)" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "redo", className: "w-3.5 h-3.5" })))), /*#__PURE__*/React.createElement("div", { className: "w-[1px] h-5 bg-zinc-800 mx-0.5" }), /*#__PURE__*/React.createElement("button", { onClick: () => { if (activeTab !== 'main') { setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, currentTime: 0 } : s)); } else { setCurrentTime(0); } }, className: "w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", title: "Quay lại đầu" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "skip-back", className: "w-3.5 h-3.5" }))), /*#__PURE__*/React.createElement("button", { onClick: () => { if (activeTab !== 'main') { setSubTabs(prev => prev.map(s => { if (s.id !== activeTab) return s; const left = s.selectionStart !== null && s.selectionEnd !== null ? Math.min(s.selectionStart, s.selectionEnd) : null; return left !== null ? { ...s, currentTime: left } : s; })); } else { if (selLeft !== null) setCurrentTime(selLeft); } }, className: "w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", title: "Đầu vùng chọn" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "step-back", className: "w-3.5 h-3.5" }))), /*#__PURE__*/React.createElement("button", { onClick: handlePlayPause, className: `w-7 h-7 flex items-center justify-center rounded border transition ${isPlaying ? 'bg-emerald-600 text-black border-emerald-500 hover:bg-emerald-500' : 'bg-cyan-600 text-white border-cyan-500 hover:bg-cyan-500'}`, title: isPlaying ? "Tạm dừng" : "Play" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": isPlaying ? "pause" : "play", className: "w-3.5 h-3.5 fill-current" }))), /*#__PURE__*/React.createElement("button", { onClick: handleStop, className: "w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", title: "Stop" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "square", className: "w-3.5 h-3.5 fill-current" }))), /*#__PURE__*/React.createElement("button", { onClick: () => { if (activeTab !== 'main') { setSubTabs(prev => prev.map(s => { if (s.id !== activeTab) return s; const right = s.selectionStart !== null && s.selectionEnd !== null ? Math.max(s.selectionStart, s.selectionEnd) : null; return right !== null ? { ...s, currentTime: right } : s; })); } else { if (selRight !== null) setCurrentTime(selRight); } }, className: "w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", title: "Cuối vùng chọn" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "step-forward", className: "w-3.5 h-3.5" }))), /*#__PURE__*/React.createElement("button", { onClick: () => { if (activeTab !== 'main') { setSubTabs(prev => prev.map(s => { if (s.id !== activeTab) return s; const duration = s.buffer ? s.buffer.duration / (s.speed || 1.0) : 0; return { ...s, currentTime: duration }; })); } else { setCurrentTime(maxDuration); } }, className: "w-7 h-7 flex items-center justify-center bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded border border-zinc-700 transition", title: "Đến cuối" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "skip-forward", className: "w-3.5 h-3.5" }))), /*#__PURE__*/React.createElement("div", { className: "w-[1px] h-5 bg-zinc-800 mx-0.5" }), /*#__PURE__*/React.createElement("button", { onClick: () => setIsLoopingSelection(prev => !prev), className: `w-7 h-7 flex items-center justify-center rounded border transition ${isLoopingSelection ? 'bg-amber-600 text-black border-amber-500 hover:bg-amber-500' : 'bg-zinc-800 text-zinc-200 border-zinc-700 hover:bg-zinc-700'}`, title: isLoopingSelection ? selLeft !== null && selRight !== null ? "Loop vùng chọn" : "Loop timeline" : "Bật loop" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "repeat", className: "w-3.5 h-3.5" }))), /*#__PURE__*/React.createElement("span", { className: "text-[14px] text-zinc-500 font-bold uppercase ml-2" }, "Snap"), /*#__PURE__*/React.createElement("select", { value: snapValue, onChange: e => setSnapValue(e.target.value), className: "bg-black text-white text-[14px] px-1.5 py-0.5 rounded border border-zinc-700 focus:outline-none focus:border-cyan-500 font-mono cursor-pointer" }, /*#__PURE__*/React.createElement("option", { value: "free" }, "Free"), /*#__PURE__*/React.createElement("option", { value: "1" }, "1"), /*#__PURE__*/React.createElement("option", { value: "1/2" }, "1/2"), /*#__PURE__*/React.createElement("option", { value: "1/4" }, "1/4"), /*#__PURE__*/React.createElement("option", { value: "1/8" }, "1/8"), /*#__PURE__*/React.createElement("option", { value: "1/16" }, "1/16"), /*#__PURE__*/React.createElement("option", { value: "1/32" }, "1/32")), /*#__PURE__*/React.createElement("div", { className: "w-[1px] h-6 bg-zinc-800 mx-1.5" }), /*#__PURE__*/React.createElement("span", { className: "text-[14px] text-zinc-500 font-bold" }, "Bars:"), /*#__PURE__*/React.createElement("input", { type: "number", min: "1", value: beginBar, onChange: e => { const b = parseInt(e.target.value) || 1; setBeginBar(b); const beatDuration = 60 / parseInt(bpm || 120); const t = (b - 1) * beatDuration * 4; clearLocalSelection(); setSelectionMode('global'); setSelectionStart(t); setSelectionEnd(t + beatDuration * 4); }, className: "w-14 bg-black text-white text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" }), /*#__PURE__*/React.createElement("span", { className: "text-[14px] text-zinc-500" }, "-"), /*#__PURE__*/React.createElement("input", { type: "number", min: "1", value: endBar, onChange: e => { const b = parseInt(e.target.value) || 1; setEndBar(b); const beatDuration = 60 / parseInt(bpm || 120); const t = (b - 1) * beatDuration * 4; setSelectionEnd(t + beatDuration * 4); setNumberBar(b - beginBar + 1); }, className: "w-14 bg-black text-white text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" }), /*#__PURE__*/React.createElement("span", { className: "text-[14px] text-zinc-500" }, "#"), /*#__PURE__*/React.createElement("input", { type: "number", min: "1", value: numberBar, readOnly: true, className: "w-14 bg-black text-zinc-400 text-[14px] px-1 py-0.5 rounded border border-zinc-800 text-center font-mono" }), /*#__PURE__*/React.createElement("div", { className: "w-[1px] h-6 bg-zinc-800 mx-1.5" }), /*#__PURE__*/React.createElement("span", { className: "text-[14px] text-zinc-500" }, "Start:"), /*#__PURE__*/React.createElement("input", { type: "text", value: selLeft !== null ? formatTime(selLeft) : '', onChange: e => { const parts = e.target.value.split(/[:.]/); if (parts.length === 3) { const secs = parseInt(parts[0]) * 60 + parseInt(parts[1]) + parseFloat('0.' + (parts[2] || '0')); clearLocalSelection(); setSelectionMode('global'); setSelectionStart(secs); } }, className: "w-20 bg-black text-zinc-200 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" }), /*#__PURE__*/React.createElement("span", { className: "text-[14px] text-zinc-500" }, "End:"), /*#__PURE__*/React.createElement("input", { type: "text", value: selRight !== null ? formatTime(selRight) : '', onChange: e => { const parts = e.target.value.split(/[:.]/); if (parts.length === 3) { const secs = parseInt(parts[0]) * 60 + parseInt(parts[1]) + parseFloat('0.' + (parts[2] || '0')); setSelectionEnd(secs); } }, className: "w-20 bg-black text-zinc-200 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" }), /*#__PURE__*/React.createElement("span", { className: "text-[14px] text-zinc-500" }, "Len:"), /*#__PURE__*/React.createElement("input", { type: "text", value: selLeft !== null && selRight !== null ? formatTime(Math.abs(selRight - selLeft)) : '', onChange: e => { const parts = e.target.value.split(/[:.]/); if (parts.length === 3 && selLeft !== null) { const secs = parseInt(parts[0]) * 60 + parseInt(parts[1]) + parseFloat('0.' + (parts[2] || '0')); setSelectionEnd(selLeft + secs); } }, className: "w-20 bg-black text-amber-300 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" }), /*#__PURE__*/React.createElement("div", { className: "flex-1" }), /*#__PURE__*/React.createElement("div", { className: "text-xs font-bold font-mono text-zinc-100" }, formatTime(currentTime))), (() => { const dockPanels = { top: [], right: [], bottom: [], left: [] }; const addPanel = (id, pos, visible) => { if (visible) dockPanels[pos].push(id); }; addPanel('export', panelPositions.export, showExportPanel); addPanel('ai', panelPositions.ai, showAIPanel); addPanel('python_tools', panelPositions.python_tools || 'bottom', showPythonToolsPanel); addPanel('selection', panelPositions.selection, showSelectionPanel); addPanel('media_explorer', 'bottom', showMediaExplorer); addPanel('fx_rack', panelPositions.fx_rack || 'bottom', showFxRack); addPanel('midi_events', panelPositions.midi_events || 'bottom', showMidiEvents); 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);else if (id === 'media_explorer') setShowMediaExplorer(false);else if (id === 'fx_rack') setShowFxRack(false);else if (id === 'midi_events') setShowMidiEvents(false); }; const renderPanelContent = panelId => { const h = id => e => { startPanelDrag(id, e); }; if (panelId === 'export') return /*#__PURE__*/React.createElement("div", { className: "flex flex-col h-full gap-1.5" }, /*#__PURE__*/React.createElement("div", { className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none", onMouseDown: e => startPanelDrag('export', e) }, /*#__PURE__*/React.createElement("h3", { className: "font-bold text-xs text-zinc-200 flex items-center gap-1" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "grip-vertical", className: "w-3 h-3 text-zinc-500" })), /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "save", className: "w-3.5 h-3.5 text-cyan-400" })), " Export"), /*#__PURE__*/React.createElement("button", { onClick: () => closePanel('export'), className: "text-zinc-600 hover:text-zinc-300" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "x", className: "w-3 h-3" })))), /*#__PURE__*/React.createElement("div", { className: "grid grid-cols-2 gap-1" }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" }, "Ngu\u1ed3n"), /*#__PURE__*/React.createElement("select", { value: exportSettings.source, onChange: e => setExportSettings(p => ({ ...p, source: e.target.value })), className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" }, /*#__PURE__*/React.createElement("option", { value: "project" }, "Project (Mix)"), /*#__PURE__*/React.createElement("option", { value: "track_mix" }, "Track Selection"), /*#__PURE__*/React.createElement("option", { value: "active_clip" }, "Active Clip"), /*#__PURE__*/React.createElement("option", { value: "clip_selection" }, "Clip Selection"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" }, "\u0110\u1ecbnh d\u1ea1ng"), /*#__PURE__*/React.createElement("select", { value: exportSettings.format, onChange: e => setExportSettings(p => ({ ...p, format: e.target.value, sampleRate: e.target.value === 'wav' ? '44100' : e.target.value === 'mp3' ? '44100' : '44100', bitDepth: e.target.value === 'wav' ? '16' : '16', quality: '44khz' })), className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" }, /*#__PURE__*/React.createElement("option", { value: "wav" }, "WAV"), /*#__PURE__*/React.createElement("option", { value: "mp3" }, "MP3"), /*#__PURE__*/React.createElement("option", { value: "ogg" }, "OGG")))), exportSettings.format === 'wav' ? /*#__PURE__*/React.createElement("div", { className: "grid grid-cols-2 gap-1" }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" }, "SR (Hz)"), /*#__PURE__*/React.createElement("select", { value: exportSettings.sampleRate, onChange: e => setExportSettings(p => ({ ...p, sampleRate: e.target.value })), className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" }, /*#__PURE__*/React.createElement("option", { value: "22500" }, "22500"), /*#__PURE__*/React.createElement("option", { value: "44100" }, "44100"))), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" }, "Bit"), /*#__PURE__*/React.createElement("select", { value: exportSettings.bitDepth, onChange: e => setExportSettings(p => ({ ...p, bitDepth: e.target.value })), className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" }, /*#__PURE__*/React.createElement("option", { value: "8" }, "8"), /*#__PURE__*/React.createElement("option", { value: "16" }, "16"), /*#__PURE__*/React.createElement("option", { value: "24" }, "24")))) : /*#__PURE__*/React.createElement("div", { className: "grid grid-cols-2 gap-1" }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" }, "Ch\u1ea5t l\u01b0\u1ee3ng"), /*#__PURE__*/React.createElement("select", { value: exportSettings.quality, onChange: e => setExportSettings(p => ({ ...p, quality: e.target.value })), className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" }, /*#__PURE__*/React.createElement("option", { value: "44khz" }, "44kHz"), /*#__PURE__*/React.createElement("option", { value: "lossless" }, "Lossless"))), /*#__PURE__*/React.createElement("div", null)), /*#__PURE__*/React.createElement("div", { className: "grid grid-cols-2 gap-1" }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("label", { className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" }, "Kênh"), /*#__PURE__*/React.createElement("select", { value: exportSettings.channels, onChange: e => setExportSettings(p => ({ ...p, channels: e.target.value })), className: "w-full bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" }, /*#__PURE__*/React.createElement("option", { value: "mono" }, "Mono"), /*#__PURE__*/React.createElement("option", { value: "stereo" }, "Stereo"))), /*#__PURE__*/React.createElement("div", null)), /*#__PURE__*/React.createElement("button", { onClick: triggerWavExport, disabled: isExporting, className: "w-full py-1 bg-cyan-700 hover:bg-cyan-600 text-zinc-100 font-bold rounded text-xs flex items-center justify-center gap-1" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "download-cloud", className: "w-3 h-3" })), isExporting ? '...' : 'Export')); if (panelId === 'ai') return /*#__PURE__*/React.createElement("div", { className: "flex flex-col gap-1.5 flex-1 min-h-0" }, /*#__PURE__*/React.createElement("div", { className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none", onMouseDown: e => startPanelDrag('ai', e) }, /*#__PURE__*/React.createElement("h3", { className: "font-bold text-xs text-zinc-200 flex items-center gap-1" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "grip-vertical", className: "w-3 h-3 text-zinc-500" })), /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "cpu", className: "w-3.5 h-3.5 text-purple-400" })), " AI Copilot"), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1" }, /*#__PURE__*/React.createElement("button", { onClick: () => { setAiActionLog([]); showToast('Đã xoá nhật ký AI.', 'info'); }, className: "text-zinc-600 hover:text-zinc-300", title: "Clear log" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "trash-2", className: "w-3 h-3" }))), /*#__PURE__*/React.createElement("button", { onClick: () => closePanel('ai'), className: "text-zinc-600 hover:text-zinc-300" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "x", className: "w-3 h-3" }))))), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1.5 w-full min-w-0 pb-1" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "cpu", className: "w-3 h-3 text-purple-400" })), /*#__PURE__*/React.createElement("select", { value: selectedProviderId, onChange: e => setSelectedProviderId(e.target.value), className: "flex-1 min-w-0 max-w-full bg-[#141414] text-zinc-200 border border-zinc-700 rounded px-1 py-0.5 text-xs font-mono focus:outline-none focus:border-purple-600 truncate" }, aiProviders.length === 0 ? /*#__PURE__*/React.createElement("option", { value: "" }, "Chưa có provider") : aiProviders.map(p => /*#__PURE__*/React.createElement("option", { key: p.id, value: p.id }, p.name, p.is_active ? '' : ' (inactive)')))), /*#__PURE__*/React.createElement("div", { className: "border-t border-zinc-800 pt-1 mt-1 shrink-0" }, /*#__PURE__*/React.createElement("button", { onClick: () => { if (window.DAWCommandDispatcher && window.DAWCommandDispatcher.undo) { const entry = window.DAWCommandDispatcher.undo(); if (entry) { setAiActionLog(prev => [...prev, { type: 'undo', text: `Undo: ${entry.name}`, time: Date.now() }]); showToast(`Undo AI: ${entry.name}`, 'info'); } } else { handleUndo(); setAiActionLog(prev => [...prev, { type: 'undo', text: 'Undo (Ctrl+Z)', time: Date.now() }]); } }, className: "w-full text-[10px] text-amber-400 hover:text-amber-300 border border-amber-800 rounded py-0.5" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center justify-center gap-1" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "rotate-ccw", className: "w-3 h-3" }), "Undo"))), /*#__PURE__*/React.createElement("div", { className: "flex-1 min-h-0 flex flex-col overflow-hidden mt-1" }, /*#__PURE__*/React.createElement("div", { className: "text-[10px] font-bold text-zinc-400 uppercase shrink-0 pb-0.5" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center gap-1" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "list", className: "w-3 h-3" }), " Action Log")), /*#__PURE__*/React.createElement("div", { ref: actionLogContainerRef, className: "flex-1 overflow-y-auto no-scrollbar bg-[#0f0f0f] rounded border border-zinc-800 select-text" }, aiActionLog.length === 0 ? /*#__PURE__*/React.createElement("div", { className: "text-xs text-zinc-600 italic select-text" }, "Chưa có hành động nào.") : aiActionLog.map((entry, i) => /*#__PURE__*/React.createElement("div", { key: i, className: `text-xs font-mono py-0.5 border-b border-zinc-900 last:border-0 ${entry.type === 'error' ? 'text-red-400' : entry.type === 'status' ? 'text-zinc-400 italic' : entry.type === 'undo' ? 'text-amber-400' : 'text-zinc-300'}` }, new Date(entry.time).toLocaleTimeString(), entry.text)))), /*#__PURE__*/React.createElement("div", { className: "border-t border-zinc-800 pt-1.5 mt-1 shrink-0" }, /*#__PURE__*/React.createElement("div", { className: "text-xs font-bold text-zinc-400 uppercase mb-1 flex items-center gap-1" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "message-square", className: "w-3 h-3" })), " Copilot Prompt"), /*#__PURE__*/React.createElement("textarea", { value: aiPrompt, onChange: e => setAiPrompt(e.target.value), placeholder: "Nhập lệnh điều khiển DAW... (VD: Tạo track mới tên Beat, đặt BPM 128)", className: "w-full bg-[#1e1e1e] text-zinc-200 p-1.5 rounded border border-zinc-700 focus:outline-none focus:border-purple-600 font-mono text-xs resize-none", rows: 2, onKeyDown: e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleAISend(); } else if (e.key === 'ArrowUp' && promptHistRef.current.length > 0) { e.preventDefault(); const idx = promptHistIdx === -1 ? promptHistRef.current.length - 1 : Math.max(0, promptHistIdx - 1); setPromptHistIdx(idx); setAiPrompt(promptHistRef.current[idx]); } else if (e.key === 'ArrowDown') { e.preventDefault(); if (promptHistIdx === -1) return; const idx = promptHistIdx + 1; if (idx >= promptHistRef.current.length) { setPromptHistIdx(-1); setAiPrompt(''); } else { setPromptHistIdx(idx); setAiPrompt(promptHistRef.current[idx]); } } } })), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1 mt-1 shrink-0" }, /*#__PURE__*/React.createElement("button", { onClick: handleAISend, disabled: aiProcessing, className: "flex-1 py-1 bg-purple-700 hover:bg-purple-600 text-white font-bold rounded text-xs border border-purple-500 flex items-center justify-center gap-1" }, aiProcessing ? 'Đang suy luận...' : /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "send", className: "w-3 h-3" })), " Gửi")), /*#__PURE__*/React.createElement("button", { onClick: () => { setAiPrompt(''); setAiActionLog([]); }, className: "px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded text-xs border border-zinc-700" }, "Clear")), /*#__PURE__*/React.createElement("div", { className: "text-xs text-zinc-600 shrink-0" }, "Enter để gửi nhanh")); if (panelId === 'python_tools') return /*#__PURE__*/React.createElement("div", { className: "flex flex-col h-full gap-1.5" }, /*#__PURE__*/React.createElement("div", { className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none", onMouseDown: e => startPanelDrag('python_tools', e) }, /*#__PURE__*/React.createElement("h3", { className: "font-bold text-xs text-amber-300 flex items-center gap-1" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "grip-vertical", className: "w-3 h-3 text-zinc-500" })), /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "wrench", className: "w-3.5 h-3.5 text-amber-400" })), " DSP Tools"), /*#__PURE__*/React.createElement("button", { onClick: () => closePanel('python_tools'), className: "text-zinc-600 hover:text-zinc-300" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "x", className: "w-3 h-3" })))), /*#__PURE__*/React.createElement("div", { className: "p-1.5 bg-[#141414] rounded border border-zinc-800 text-[10px] font-mono text-zinc-400 space-y-0.5 leading-relaxed" }, dspSelectionStats ? [/*#__PURE__*/React.createElement("div", { key: "track" }, `Track: ${dspSelectionStats.trackName}`), /*#__PURE__*/React.createElement("div", { key: "range" }, `Range: ${dspSelectionStats.timeRange}`), /*#__PURE__*/React.createElement("div", { key: "ch" }, `Channels: ${dspSelectionStats.channels}`), /*#__PURE__*/React.createElement("div", { key: "peak" }, `Peak Vol: ${dspSelectionStats.peakVolume}`)] : "Chưa chọn track"), /*#__PURE__*/React.createElement("div", { className: "grid grid-cols-2 gap-1 text-xs" }, /*#__PURE__*/React.createElement("button", { onClick: () => runPythonTool('normalize'), className: "py-1 bg-amber-950/80 hover:bg-amber-900 text-amber-300 border border-amber-800/80 rounded font-bold flex items-center justify-center gap-1" }, "⚡ Peak Norm (0dB)"), /*#__PURE__*/React.createElement("button", { onClick: () => runPythonTool('invert_phase'), className: "py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 border border-zinc-700 rounded font-bold flex items-center justify-center gap-1" }, "🔄 Phase Invert"), /*#__PURE__*/React.createElement("button", { onClick: () => runPythonTool('swap_channels'), className: "py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 border border-zinc-700 rounded font-bold flex items-center justify-center gap-1" }, "🔀 Swap L/R"), /*#__PURE__*/React.createElement("button", { onClick: () => runPythonTool('synth_wave'), className: "py-1 bg-teal-950/80 hover:bg-teal-900 text-teal-300 border border-teal-800/80 rounded font-bold flex items-center justify-center gap-1" }, "🎹 Gen Synth Tone"))); if (panelId === 'selection') return /*#__PURE__*/React.createElement("div", { className: "flex flex-col h-full gap-1.5" }, /*#__PURE__*/React.createElement("div", { className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none", onMouseDown: e => startPanelDrag('selection', e) }, /*#__PURE__*/React.createElement("span", { className: "text-xs text-zinc-500 font-bold uppercase flex items-center gap-1" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "grip-vertical", className: "w-3 h-3 text-zinc-500" })), " Selection"), /*#__PURE__*/React.createElement("button", { onClick: () => closePanel('selection'), className: "text-zinc-600 hover:text-zinc-300" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "x", className: "w-3 h-3" })))), /*#__PURE__*/React.createElement("div", { className: "grid grid-cols-3 gap-1 bg-[#141414] p-1.5 rounded border border-zinc-800" }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", { className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" }, "Start"), /*#__PURE__*/React.createElement("input", { type: "number", step: "0.01", value: selectionStats.start, onChange: e => handleSelectionInputChange('start', e.target.value), className: "w-full bg-[#242424] text-amber-400 text-center font-mono text-xs rounded py-0.5 border border-zinc-700 focus:outline-none" })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", { className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" }, "End"), /*#__PURE__*/React.createElement("input", { type: "number", step: "0.01", value: selectionStats.end, onChange: e => handleSelectionInputChange('end', e.target.value), className: "w-full bg-[#242424] text-amber-400 text-center font-mono text-xs rounded py-0.5 border border-zinc-700 focus:outline-none" })), /*#__PURE__*/React.createElement("div", { className: "flex flex-col justify-center" }, /*#__PURE__*/React.createElement("span", { className: "text-[7px] text-zinc-500 font-bold uppercase" }, "Len"), /*#__PURE__*/React.createElement("span", { className: "text-zinc-200 font-mono text-xs font-semibold mt-0.5" }, selectionStats.length, "s"))), /*#__PURE__*/React.createElement("div", { className: "grid grid-cols-3 gap-1 bg-[#141414] p-1.5 rounded border border-zinc-800 mt-1" }, /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", { className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" }, "Begin Bar"), /*#__PURE__*/React.createElement("input", { type: "number", min: "1", value: beginBar, onChange: e => { const b = parseInt(e.target.value) || 1; setBeginBar(b); const beatDuration = 60 / parseInt(bpm || 120); const t = (b - 1) * beatDuration * 4; clearLocalSelection(); setSelectionMode('global'); setSelectionStart(t); setSelectionEnd(t + beatDuration * 4); }, className: "w-full bg-[#242424] text-white text-center font-mono text-xs rounded py-0.5 border border-zinc-700 focus:outline-none" })), /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("span", { className: "block text-[7px] text-zinc-500 font-bold uppercase mb-0.5" }, "End Bar"), /*#__PURE__*/React.createElement("input", { type: "number", min: "1", value: endBar, onChange: e => { const b = parseInt(e.target.value) || 1; setEndBar(b); const beatDuration = 60 / parseInt(bpm || 120); const t = (b - 1) * beatDuration * 4; setSelectionEnd(t + beatDuration * 4); setNumberBar(b - beginBar + 1); }, className: "w-full bg-[#242424] text-white text-center font-mono text-xs rounded py-0.5 border border-zinc-700 focus:outline-none" })), /*#__PURE__*/React.createElement("div", { className: "flex flex-col justify-center" }, /*#__PURE__*/React.createElement("span", { className: "text-[7px] text-zinc-500 font-bold uppercase" }, "# Bars"), /*#__PURE__*/React.createElement("span", { className: "text-zinc-200 font-mono text-xs font-semibold mt-0.5" }, numberBar)))); if (panelId === 'media_explorer') return /*#__PURE__*/React.createElement("div", { className: "flex flex-col h-full gap-1.5" }, /*#__PURE__*/React.createElement("div", { className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none", onMouseDown: e => startPanelDrag('media_explorer', e) }, /*#__PURE__*/React.createElement("h3", { className: "font-bold text-xs text-emerald-300 flex items-center gap-1" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "grip-vertical", className: "w-3 h-3 text-zinc-500" })), /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "folder-open", className: "w-3.5 h-3.5 text-emerald-400" })), " Media Explorer"), /*#__PURE__*/React.createElement("button", { onClick: () => closePanel('media_explorer'), className: "text-zinc-600 hover:text-zinc-300" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "x", className: "w-3 h-3" })))), /*#__PURE__*/React.createElement("div", { className: "flex-1 overflow-y-auto text-xs text-zinc-400 font-mono p-2" }, "// Placeholder: Media files browser")); if (panelId === 'fx_rack') return /*#__PURE__*/React.createElement("div", { className: "flex flex-col h-full gap-1.5" }, /*#__PURE__*/React.createElement("div", { className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none", onMouseDown: e => startPanelDrag('fx_rack', e) }, /*#__PURE__*/React.createElement("h3", { className: "font-bold text-xs text-rose-300 flex items-center gap-1" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "grip-vertical", className: "w-3 h-3 text-zinc-500" })), /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "sliders", className: "w-3.5 h-3.5 text-rose-400" })), " Plugin FX Rack"), /*#__PURE__*/React.createElement("button", { onClick: () => closePanel('fx_rack'), className: "text-zinc-600 hover:text-zinc-300" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "x", className: "w-3 h-3" })))), /*#__PURE__*/React.createElement("div", { className: "flex-1 text-xs text-zinc-500 italic flex items-center justify-center" }, "No FX plugins loaded")); if (panelId === 'midi_events') return /*#__PURE__*/React.createElement("div", { className: "flex flex-col h-full gap-1.5" }, /*#__PURE__*/React.createElement("div", { className: "flex items-center justify-between shrink-0 cursor-grab active:cursor-grabbing select-none", onMouseDown: e => startPanelDrag('midi_events', e) }, /*#__PURE__*/React.createElement("h3", { className: "font-bold text-xs text-sky-300 flex items-center gap-1" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "grip-vertical", className: "w-3 h-3 text-zinc-500" })), /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "music", className: "w-3.5 h-3.5 text-sky-400" })), " MIDI Event List"), /*#__PURE__*/React.createElement("button", { onClick: () => closePanel('midi_events'), className: "text-zinc-600 hover:text-zinc-300" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "x", className: "w-3 h-3" })))), /*#__PURE__*/React.createElement("div", { className: "flex-1 text-xs text-zinc-500 italic flex items-center justify-center" }, "No MIDI events selected")); return null; }; const renderDock = (pos, title) => { const panels = dockPanels[pos]; if (panels.length === 0) return null; const isSide = pos === 'left' || pos === 'right'; 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; if (pos === 'right') return /*#__PURE__*/React.createElement("div", { id: "right-sidebar", className: `${borderClass} ${bgClass} flex flex-col overflow-hidden select-none h-full`, style: { width: `${rightSidebarWidth}px`, minWidth: '200px', maxWidth: '600px', flexShrink: 0 } }, /*#__PURE__*/React.createElement("div", { className: "flex-1 flex flex-col gap-2 p-2 overflow-hidden h-full" }, panels.map((p, idx) => /*#__PURE__*/React.createElement(React.Fragment, { key: p }, /*#__PURE__*/React.createElement("div", { className: 'flex flex-col flex-1 min-h-0 border border-zinc-700 rounded-lg bg-[#262626] shadow-sm p-3' }, renderPanelContent(p)), idx < panels.length - 1 && /*#__PURE__*/React.createElement("div", { className: "h-1.5 cursor-ns-resize hover:bg-cyan-500/50 transition-colors rounded shrink-0", onMouseDown: startRowResize }))))); const sideClass = isSide ? 'shrink-0 overflow-y-auto' : 'shrink-0'; return /*#__PURE__*/React.createElement("div", { id: "daw-bottom", className: `${sideClass} ${borderClass} ${bgClass} flex ${isSide ? 'flex-col p-2 gap-2' : 'flex-row p-1.5 gap-3'} select-none ${highlight ? 'ring-2 ring-cyan-500 ring-inset' : ''}` }, panels.map(p => /*#__PURE__*/React.createElement("div", { key: p, className: `${isSide ? 'w-full' : 'flex-none w-[320px]'} border border-zinc-700 rounded-lg bg-[#262626] shadow-sm p-2` }, renderPanelContent(p)))); }; return /*#__PURE__*/React.createElement("div", { ref: workspaceRef, className: "flex-1 flex flex-col overflow-hidden select-none daw-bg relative" }, panelDragRef.current && panelDropZone && /*#__PURE__*/React.createElement("div", { className: "absolute inset-0 z-50 pointer-events-none" }, panelDropZone === 'top' && /*#__PURE__*/React.createElement("div", { className: "absolute top-0 left-0 right-0 h-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]" }), panelDropZone === 'bottom' && /*#__PURE__*/React.createElement("div", { className: "absolute bottom-0 left-0 right-0 h-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]" }), panelDropZone === 'left' && /*#__PURE__*/React.createElement("div", { className: "absolute top-0 bottom-0 left-0 w-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]" }), panelDropZone === 'right' && /*#__PURE__*/React.createElement("div", { className: "absolute top-0 bottom-0 right-0 w-1 bg-cyan-400 shadow-[0_0_8px_#06b6d4]" })), dragGhostPanel && dragGhostPos && /*#__PURE__*/React.createElement("div", { className: "fixed z-[100] pointer-events-none opacity-80 border border-cyan-500 rounded-lg bg-[#262626] shadow-2xl p-3 w-56", style: { left: dragGhostPos.x, top: dragGhostPos.y } }, /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-2 text-xs text-zinc-200 font-bold" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "move", className: "w-3.5 h-3.5 text-cyan-400" })), dragGhostPanel === 'export' ? 'Export Panel' : dragGhostPanel === 'ai' ? 'AI Panel' : dragGhostPanel === 'python_tools' ? 'Audio Processing Panel' : 'Selection Panel'), /*#__PURE__*/React.createElement("div", { className: "text-xs text-zinc-500 mt-1" }, "Drop at edge to dock")), renderDock('top', 'Top'), /*#__PURE__*/React.createElement("div", { className: "flex-1 flex overflow-hidden" }, renderDock('left', 'Left'), /*#__PURE__*/React.createElement("div", { className: "flex-1 flex flex-col overflow-hidden min-w-0" }, /*#__PURE__*/React.createElement("div", { className: "flex-1 flex overflow-hidden" }, activeTab === 'main' || sessionTabs.some(s => s.id === activeTab) ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { ref: tcpContainerRef, onScroll: handleTCPScroll, className: "w-[300px] shrink-0 z-20 bg-[#262626] overflow-hidden flex flex-col border-r border-zinc-900", style: { scrollbarWidth: 'none', msOverflowStyle: 'none' } }, /*#__PURE__*/React.createElement("div", { className: "sticky top-0 z-50 flex items-center justify-between h-10 border-b border-zinc-900 bg-[#242424] px-2 shrink-0" }, /*#__PURE__*/React.createElement("span", { className: "text-xs font-bold text-zinc-300 flex items-center gap-1.5" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "sliders", className: "w-3.5 h-3.5 text-cyan-400" })), "TRACKS (", activeTracks.length, ")"), /*#__PURE__*/React.createElement("button", { onClick: addNewTrack, className: "px-2.5 py-1 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold flex items-center gap-1 shadow transition" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "plus", className: "w-3 h-3" })), " Add Track")), /*#__PURE__*/React.createElement("div", { className: "sticky top-10 z-40 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] p-2 border-l-4 border-purple-500 shrink-0" }, /*#__PURE__*/React.createElement("div", { className: "flex items-center justify-between w-full" }, /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-2" }, /*#__PURE__*/React.createElement("span", { className: "text-xs font-bold text-purple-400 font-mono" }, "TM"), /*#__PURE__*/React.createElement("span", { className: "text-xs font-semibold text-zinc-300" }, "Tempo")), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1" }, /*#__PURE__*/React.createElement("input", { type: "number", value: bpm, onChange: e => setBpm(e.target.value), onBlur: () => localStorage.setItem('studio_bpm', bpm), className: "w-12 bg-zinc-800 border border-zinc-700 rounded text-xs text-zinc-300 text-center font-mono focus:outline-none focus:border-purple-500", min: "40", max: "300" }), /*#__PURE__*/React.createElement("span", { className: "text-xs text-zinc-500" }, "BPM")))), /*#__PURE__*/React.createElement("div", { className: "flex-1 flex flex-col divide-y divide-[#141414] bg-[#262626]" }, activeTracks.length === 0 ? /*#__PURE__*/React.createElement("div", { className: "p-6 text-center text-zinc-400 flex flex-col items-center justify-center space-y-3" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "plus-circle", className: "w-8 h-8 text-cyan-400 opacity-80" })), /*#__PURE__*/React.createElement("p", { className: "text-xs font-medium" }, "Chưa có Track nào trong dự án."), /*#__PURE__*/React.createElement("button", { onClick: addNewTrack, className: "px-3 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold flex items-center gap-1.5 shadow" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "plus", className: "w-3.5 h-3.5" })), " Thêm Track Mới")) : activeTracks.map((track, idx) => { const isSelected = selectedTrackId === track.id; return /*#__PURE__*/React.createElement("div", { key: track.id, style: { height: `${track.height || 96}px` }, className: `shrink-0 relative flex flex-col justify-between p-2.5 bg-[#1e1e1e] border-r border-zinc-900 cursor-pointer border-l-4 border-b border-[#141414] overflow-hidden ${isSelected ? 'border-cyan-500 bg-[#252525]' : 'border-transparent hover:bg-zinc-800/20'}`, onClick: () => setSelectedTrackId(track.id) }, /*#__PURE__*/React.createElement("div", { className: "flex items-start justify-between" }, /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-2" }, /*#__PURE__*/React.createElement("span", { className: "text-xs font-bold text-zinc-500 font-mono" }, (idx + 1).toString().padStart(2, '0')), /*#__PURE__*/React.createElement("label", { onClick: e => { e.stopPropagation(); const el = e.currentTarget.querySelector('input'); if (el) el.click(); }, className: "cursor-pointer" }, /*#__PURE__*/React.createElement("input", { type: "color", value: track.color || '#0f766e', onChange: e => { e.stopPropagation(); updateTrackColor(track.id, e.target.value); }, className: "w-0 h-0 opacity-0 absolute pointer-events-none" }), /*#__PURE__*/React.createElement("div", { className: "w-3 h-3 rounded-full border border-zinc-600 hover:scale-110 transition-transform", style: { backgroundColor: track.color } })), editingTrackName === track.id ? /*#__PURE__*/React.createElement("input", { type: "text", value: editNameInput, autoFocus: true, onChange: e => setEditNameInput(e.target.value), onBlur: () => { updateTrackName(track.id, editNameInput || track.name); setEditingTrackName(null); }, onKeyDown: e => { if (e.key === 'Enter') { updateTrackName(track.id, editNameInput || track.name); setEditingTrackName(null); } if (e.key === 'Escape') setEditingTrackName(null); }, onClick: e => e.stopPropagation(), className: "text-xs font-semibold bg-black text-zinc-200 border border-cyan-500 rounded px-1 py-0 w-24 outline-none" }) : /*#__PURE__*/React.createElement("span", { className: "text-xs font-semibold text-zinc-300 truncate max-w-[80px] cursor-text hover:text-cyan-300 transition-colors", title: "Click to rename", onClick: e => { e.stopPropagation(); setEditingTrackName(track.id); setEditNameInput(track.name); } }, track.name)), /*#__PURE__*/React.createElement("div", { className: "flex flex-wrap gap-0.5 max-w-[100px] mb-0.5" }, (track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ id: 'default', name: track.name, startTime: track.startTime }] : []).slice(0, 3).map(c => /*#__PURE__*/React.createElement("span", { key: c.id, className: "text-[9px] font-mono text-zinc-500 bg-zinc-800/60 rounded px-0.5 truncate max-w-[90px] cursor-pointer hover:text-cyan-400 hover:bg-zinc-700", title: c.name || track.name, onClick: e => { e.stopPropagation(); setSelectedTrackId(track.id); clearLocalSelection(); setSelectionMode('global'); const start = c.startTime || 0; const end = start + (c.buffer ? c.buffer.duration : 2); setSelectionStart(start); setSelectionEnd(end); showToast(`Selected: ${c.name || track.name}`, 'info'); } }, c.name || track.name), editingClipName && editingClipName.trackId === track.id && editingClipName.clipId === c.id ? /*#__PURE__*/React.createElement("input", { type: "text", value: editNameInput, autoFocus: true, onChange: e => setEditNameInput(e.target.value), onBlur: () => { if (editNameInput.trim()) updateClipName(track.id, c.id, editNameInput.trim()); setEditingClipName(null); }, onKeyDown: e => { if (e.key === 'Enter') { if (editNameInput.trim()) updateClipName(track.id, c.id, editNameInput.trim()); setEditingClipName(null); } if (e.key === 'Escape') setEditingClipName(null); }, onClick: e => e.stopPropagation(), className: "w-20 text-[9px] font-mono bg-black text-cyan-300 border border-cyan-500 rounded px-0.5 py-0 outline-none" }) : /*#__PURE__*/React.createElement("button", { className: "text-[9px] text-zinc-600 hover:text-cyan-400 ml-0.5 shrink-0", title: "Sửa tên clip", onClick: e => { e.stopPropagation(); setEditingClipName({ trackId: track.id, clipId: c.id }); setEditNameInput(c.name || track.name); } }, /*#__PURE__*/React.createElement("i", { "data-lucide": "pencil", className: "w-2.5 h-2.5" })))), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1" }, /*#__PURE__*/React.createElement("button", { onClick: e => { e.stopPropagation(); toggleTrackMute(track.id); }, className: `px-1.5 py-0.5 text-xs rounded font-mono font-bold border transition ${track.muted ? 'bg-red-950 text-red-400 border-red-700' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}` }, "M"), /*#__PURE__*/React.createElement("button", { onClick: e => { e.stopPropagation(); toggleTrackSoloEvaluate(track.id); }, className: `px-1.5 py-0.5 text-xs rounded font-mono font-bold border transition ${soloedTrackId === track.id || track.solo ? 'bg-amber-950 text-amber-400 border-amber-600' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}` }, "S"), /*#__PURE__*/React.createElement("button", { onClick: e => { e.stopPropagation(); deleteTrack(track.id); }, className: "p-0.5 rounded text-red-500 hover:bg-red-950/40 hover:text-red-400 transition" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "trash-2", className: "w-3.5 h-3.5" }))))), /*#__PURE__*/React.createElement("div", { className: "flex flex-col gap-0.5 text-xs", onClick: e => e.stopPropagation() }, /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1" }, /*#__PURE__*/React.createElement("span", { className: "w-8 text-right text-zinc-500 text-xs" }, "Vol:"), /*#__PURE__*/React.createElement("input", { type: "range", min: "-50", max: "7", step: "0.5", value: track.volumeDb ?? 0, onChange: e => updateTrackVolumeDb(track.id, parseFloat(e.target.value)), className: "flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-cyan-500", style: { height: '4px' } }), /*#__PURE__*/React.createElement("span", { className: "w-12 text-right font-mono text-zinc-300 text-xs" }, track.volumeDb ?? 0, "dB")), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1" }, /*#__PURE__*/React.createElement("span", { className: "w-8 text-right text-zinc-500 text-xs" }, "Pan:"), /*#__PURE__*/React.createElement("input", { type: "range", min: "-100", max: "100", step: "1", value: track.pan ?? 0, onChange: e => updateTrackPan(track.id, parseInt(e.target.value)), className: "flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-purple-500", style: { height: '4px' } }), /*#__PURE__*/React.createElement("span", { className: "w-12 text-right font-mono text-zinc-300 text-xs" }, track.pan > 0 ? 'R' + track.pan : track.pan < 0 ? 'L' + Math.abs(track.pan) : 'C'))), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1.5 mt-1", onClick: e => e.stopPropagation() }, /*#__PURE__*/React.createElement("input", { type: "file", id: `upload-${track.id}`, accept: "audio/*", className: "hidden", onChange: e => loadFileOnTrack(track.id, e.target.files[0]) }), /*#__PURE__*/React.createElement("label", { htmlFor: `upload-${track.id}`, className: "px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded text-xs flex items-center gap-1 cursor-pointer border border-zinc-700 font-medium" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "upload", className: "w-3 h-3" })), " File"), /*#__PURE__*/React.createElement("button", { onClick: e => { e.stopPropagation(); showToast('FX panel for track ' + track.id, 'info'); }, className: "px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-purple-400 border border-purple-900 rounded text-xs font-bold flex items-center gap-1" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "wand-2", className: "w-3 h-3" })), " FX: ", /*#__PURE__*/React.createElement("span", { className: "text-zinc-500 font-normal" }, "None")), /*#__PURE__*/React.createElement("button", { onClick: () => generateSynthToTrack(track.id, 'synth'), className: "px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-amber-400 border border-amber-800 rounded text-xs font-bold flex items-center gap-1" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "music", className: "w-3 h-3" })), " Synth: ", /*#__PURE__*/React.createElement("span", { className: "text-zinc-500 font-normal" }, "None"))), /*#__PURE__*/React.createElement("div", { onMouseDown: e => handleTrackResizeMouseDown(e, track.id), className: "absolute bottom-0 left-0 right-0 h-1 cursor-ns-resize z-30 hover:bg-cyan-500/50 transition-colors", onClick: e => e.stopPropagation() })); })), /*#__PURE__*/React.createElement("div", { className: "h-[48px] border-r border-zinc-900 bg-[#1e1e1e]/50 shrink-0" })), /*#__PURE__*/React.createElement("div", { ref: timelineWrapperRef, onScroll: handleTimelineScroll, className: "flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0" }, /*#__PURE__*/React.createElement("div", { style: { width: `${timelineWidth}px` }, className: "relative flex flex-col min-h-full" }, /*#__PURE__*/React.createElement("div", { className: "sticky top-0 z-45 flex h-10 border-b border-zinc-900 bg-[#242424] shrink-0" }, /*#__PURE__*/React.createElement("div", { ref: rulerRef, className: "flex-1 relative h-full flex items-center select-none cursor-ew-resize overflow-hidden", onMouseDown: handleRulerMouseDown }, Array.from({ length: Math.ceil(maxDuration) }).map((_, i) => { const sec = i; const x = sec * zoom; return /*#__PURE__*/React.createElement("div", { key: i, className: "absolute h-full border-l border-zinc-700 pl-1 pt-1 text-[14px] font-mono text-zinc-300 pointer-events-none", style: { left: `${x}px` } }, formatTimeSimple(sec)); }))), selectionMode === 'global' && selLeft !== null && selRight !== null && selRight > selLeft && /*#__PURE__*/React.createElement("div", { className: "absolute inset-0 pointer-events-none z-20", style: { left: `${selLeft * zoom}px`, width: `${(selRight - selLeft) * zoom}px`, top: '40px' } }, /*#__PURE__*/React.createElement("div", { className: "w-full h-full bg-amber-500/10", style: { borderLeft: '1px solid #f59e0b', borderRight: '1px solid #f59e0b' } })), /*#__PURE__*/React.createElement("div", { className: "sticky top-10 z-35 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] shrink-0" }, /*#__PURE__*/React.createElement("div", { className: "flex-1 relative h-full overflow-hidden bg-[#1a1a2e]" }, /*#__PURE__*/React.createElement(TempoTrackLane, { bpm: parseInt(bpm) || 120, zoom: zoom, timelineWidth: timelineWidth, viewportWidth: viewportWidth, onPlayheadSet: handlePlayheadSet, snapValue: snapValue, onRulerMouseDown: handleRulerMouseDown, scrollLeft: scrollLeft }))), /*#__PURE__*/React.createElement("div", { className: "flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full" }, activeTracks.map((track, idx) => { const isSelected = selectedTrackId === track.id; return /*#__PURE__*/React.createElement("div", { key: track.id, style: { height: `${track.height || 96}px` }, className: `shrink-0 relative border-b border-[#141414] hover:bg-zinc-850/5 transition-colors ${isSelected ? 'bg-zinc-800/10' : ''}`, onDragOver: e => e.preventDefault(), onDrop: e => { e.preventDefault(); if (e.dataTransfer.files[0]) loadFileOnTrack(track.id, e.dataTransfer.files[0]); }, onMouseEnter: () => setHoveredTrackId(track.id) }, /*#__PURE__*/React.createElement(WaveformLane, { track: track, zoom: zoom, timelineWidth: timelineWidth, viewportWidth: viewportWidth, onSelectRange: handleSelectRange, onPlayheadSet: handlePlayheadSet, isSelected: isSelected, onSelectTrack: setSelectedTrackId, markers: track.markers, onTrackLaneMouseDown: handleTrackLaneMouseDown, onContextMenu: handleContextMenu, onClipDragStart: handleClipDragStart, onClipStretchStart: handleClipStretchStart, onSectionItemDragStart: handleSectionItemDragStart, onSectionItemResizeStart: handleSectionItemResizeStart, onSelectionEdgeDragStart: handleSelectionEdgeDragStart, setSelectedClipId: setSelectedClipId, selectedClipId: selectedClipId, activeTool: activeTool, onSplitTrackAtTime: handleSplitTrackAtTime, onEditClipInSubTab: handleEditClipInSubTab, onEditSectionInTab: handleEditSectionInTab, snapValue: snapValue, bpm: bpm, selectionMode: selectionMode, localSelectionTrackId: localSelectionTrackId, localSelectionStart: localSelectionStart, currentTime: currentTime, getLocalAnchor: () => localSelectionAnchorRef.current, onClearLocalSelection: clearLocalSelection, onSetSelectionMode: setSelectionMode, onSetSelectionStart: setSelectionStart, onSetSelectionEnd: setSelectionEnd, onSetCurrentTime: setCurrentTime, onSetLocalSelectionTrackId: setLocalSelectionTrackId, onSetLocalSelectionStart: setLocalSelectionStart, onSetLocalSelectionEnd: setLocalSelectionEnd, localSelLeft: localSelectionStart !== null && localSelectionEnd !== null ? Math.min(localSelectionStart, localSelectionEnd) : null, localSelRight: localSelectionStart !== null && localSelectionEnd !== null ? Math.max(localSelectionStart, localSelectionEnd) : null, scrollLeft: scrollLeft }), selectionMode === 'local' && localSelectionTrackId === track.id && localSelectionStart !== null && localSelectionEnd !== null && Math.abs(localSelectionEnd - localSelectionStart) > 0 && /*#__PURE__*/React.createElement("div", { className: "absolute top-0 bottom-0 border-l border-r border-amber-400 bg-amber-500/20 z-20 pointer-events-none", style: { left: `${Math.min(localSelectionStart, localSelectionEnd) * zoom}px`, width: `${Math.abs(localSelectionEnd - localSelectionStart) * zoom}px` } }, /*#__PURE__*/React.createElement("div", { className: "absolute -left-[1px] top-0 bottom-0 w-[2px] bg-amber-400 z-30 pointer-events-auto cursor-ew-resize", onMouseDown: e => handleHandleDragStart(e, 'left') }), /*#__PURE__*/React.createElement("div", { className: "absolute -right-[1px] top-0 bottom-0 w-[2px] bg-amber-400 z-30 pointer-events-auto cursor-ew-resize", onMouseDown: e => handleHandleDragStart(e, 'right') })), track.buffer && /*#__PURE__*/React.createElement("div", { className: "absolute right-2 top-2 flex items-center gap-1 z-10 opacity-70 hover:opacity-100" }, /*#__PURE__*/React.createElement("button", { onClick: () => handleSplitTrack(track.id), className: "px-1.5 py-0.5 bg-zinc-900/95 text-zinc-300 rounded text-xs flex items-center gap-1 border border-zinc-700/50" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "scissors", className: "w-2.5 h-2.5 text-cyan-400" })), " Cắt")), /*#__PURE__*/React.createElement("div", { onMouseDown: e => handleTrackResizeMouseDown(e, track.id), className: "absolute bottom-0 left-0 right-0 h-1 cursor-ns-resize z-30 hover:bg-cyan-500/50 transition-colors", onClick: e => e.stopPropagation() })); }), /*#__PURE__*/React.createElement("div", { className: "h-[48px] flex items-center justify-center text-xs text-zinc-500 hover:bg-zinc-900/20 cursor-pointer select-none border-t border-dashed border-zinc-800", onMouseEnter: () => { if (draggedClipRef.current || draggedSectionItemRef.current) { setHoveredTrackId(addNewTrack()); } }, onClick: addNewTrack }, /*#__PURE__*/React.createElement("span", { className: "flex items-center gap-1 text-zinc-400" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "plus", className: "w-3.5 h-3.5" })), " Kéo clip xuống hoặc Click tạo Track")), selectionMode === 'global' && selLeft !== null && selRight !== null && selRight > selLeft && /*#__PURE__*/React.createElement("div", { className: "absolute top-0 bottom-0 border-l border-r border-amber-500 bg-amber-500/10 z-20 selection-interactive-box cursor-grab active:cursor-grabbing", style: { left: `${selLeft * zoom}px`, width: `${(selRight - selLeft) * zoom}px` }, onMouseDown: handleSelectionBodyDragStart }, /*#__PURE__*/React.createElement("div", { className: "absolute -left-[1px] top-0 bottom-0 w-[1px] bg-amber-500 hover:bg-amber-400 cursor-ew-resize z-30", onMouseDown: e => handleHandleDragStart(e, 'left') }), /*#__PURE__*/React.createElement("div", { className: "absolute -right-[1px] top-0 bottom-0 w-[1px] bg-amber-500 hover:bg-amber-400 cursor-ew-resize z-30", onMouseDown: e => handleHandleDragStart(e, 'right') })), /*#__PURE__*/React.createElement("div", { className: "absolute top-0 bottom-0 w-[2px] bg-[#ef4444] z-20 pointer-events-none", style: { left: `${playheadLeftPos}px` } }, /*#__PURE__*/React.createElement("div", { className: "w-3 h-3 bg-[#ef4444] rotate-45 transform -translate-x-1/2 -translate-y-1/2 absolute top-0" })))))) : (() => { const st = subTabs.find(s => s.id === activeTab); if (!st) return null; const subTrack = tracks.find(t => t.id === st.trackId); const vTrack = subTrack ? { ...subTrack, buffer: st.buffer, isSubTab: true } : null; const subTabTimelineWidth = Math.max(zoom * (st.buffer ? st.buffer.duration : 0), viewportWidth); return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { className: "w-[320px] shrink-0 z-30 bg-[#262626] overflow-y-scroll no-scrollbar flex flex-col border-r border-zinc-900", style: { scrollbarWidth: 'none', msOverflowStyle: 'none' } }, /*#__PURE__*/React.createElement("div", { className: "sticky top-0 z-50 flex items-center h-10 border-b border-zinc-900 bg-[#242424] px-2 justify-between shrink-0" }, /*#__PURE__*/React.createElement("span", { className: "text-xs font-bold text-zinc-500 uppercase" }, "Sub-Tab"), /*#__PURE__*/React.createElement("button", { onClick: () => closeSubTab(st.id), className: "px-1.5 py-0.5 text-xs bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded border border-zinc-700 flex items-center gap-1" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "x", className: "w-3 h-3" })), " Close")), vTrack ? /*#__PURE__*/React.createElement("div", { key: vTrack.id, className: "flex-1 flex flex-col p-2.5 bg-[#1e1e1e] border-r border-zinc-900 border-l-4 border-l-cyan-500" }, /*#__PURE__*/React.createElement("div", { className: "flex items-center justify-between mb-2" }, /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-2" }, /*#__PURE__*/React.createElement("label", { onClick: e => { e.stopPropagation(); const el = e.currentTarget.querySelector('input'); if (el) el.click(); }, className: "cursor-pointer" }, /*#__PURE__*/React.createElement("input", { type: "color", value: vTrack.color || '#0f766e', onChange: e => { e.stopPropagation(); updateTrackColor(vTrack.id, e.target.value); }, className: "w-0 h-0 opacity-0 absolute pointer-events-none" }), /*#__PURE__*/React.createElement("div", { className: "w-3 h-3 rounded-full border border-zinc-600 hover:scale-110 transition-transform", style: { backgroundColor: vTrack.color } })), editingTrackName === vTrack.id ? /*#__PURE__*/React.createElement("input", { type: "text", value: editNameInput, autoFocus: true, onChange: e => setEditNameInput(e.target.value), onBlur: () => { updateTrackName(vTrack.id, editNameInput || vTrack.name); setEditingTrackName(null); }, onKeyDown: e => { if (e.key === 'Enter') { updateTrackName(vTrack.id, editNameInput || vTrack.name); setEditingTrackName(null); } if (e.key === 'Escape') setEditingTrackName(null); }, onClick: e => e.stopPropagation(), className: "text-xs font-semibold bg-black text-zinc-200 border border-cyan-500 rounded px-1 py-0 w-24 outline-none" }) : /*#__PURE__*/React.createElement("span", { className: "text-xs font-semibold text-zinc-300 truncate max-w-[80px] cursor-text hover:text-cyan-300 transition-colors", title: "Click to rename", onClick: e => { e.stopPropagation(); setEditingTrackName(vTrack.id); setEditNameInput(vTrack.name); } }, vTrack.name)), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1" }, /*#__PURE__*/React.createElement("button", { onClick: e => { e.stopPropagation(); toggleTrackMute(vTrack.id); }, className: `px-1.5 py-0.5 text-xs rounded font-mono font-bold border ${vTrack.muted ? 'bg-red-950 text-red-400 border-red-700' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}` }, "M"), /*#__PURE__*/React.createElement("button", { onClick: e => { e.stopPropagation(); toggleTrackSoloEvaluate(vTrack.id); }, className: `px-1.5 py-0.5 text-xs rounded font-mono font-bold border ${soloedTrackId === vTrack.id || vTrack.solo ? 'bg-amber-950 text-amber-400 border-amber-600' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}` }, "S"))), /*#__PURE__*/React.createElement("div", { className: "flex flex-col gap-2.5 text-[14px] mb-3" }, /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1" }, /*#__PURE__*/React.createElement("span", { className: "w-10 text-right text-zinc-500 text-[14px]" }, "Vol:"), /*#__PURE__*/React.createElement("input", { type: "range", min: "-50", max: "7", step: "0.5", id: `tcp-vol-${st.id}`, value: vTrack.volumeDb ?? 0, onChange: e => updateTrackVolumeDb(vTrack.id, parseFloat(e.target.value)), className: "flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-cyan-500", style: { height: '4px' } }), /*#__PURE__*/React.createElement("span", { className: "w-16 text-right font-mono text-zinc-300 text-[14px]", id: `tcp-vol-label-${st.id}` }, vTrack.volumeDb ?? 0, "dB")), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1" }, /*#__PURE__*/React.createElement("span", { className: "w-10 text-right text-zinc-500 text-[14px]" }, "Pan:"), /*#__PURE__*/React.createElement("input", { type: "range", min: "-100", max: "100", step: "1", id: `tcp-pan-${st.id}`, value: vTrack.pan ?? 0, onChange: e => updateTrackPan(vTrack.id, parseInt(e.target.value)), className: "flex-1 h-1 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-purple-500", style: { height: '4px' } }), /*#__PURE__*/React.createElement("span", { className: "w-16 text-right font-mono text-zinc-300 text-[14px]", id: `tcp-pan-label-detailed-${st.id}` }, vTrack.pan > 0 ? 'R' + vTrack.pan : vTrack.pan < 0 ? 'L' + Math.abs(vTrack.pan) : 'C')), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1.5 text-[14px]" }, /*#__PURE__*/React.createElement("button", { onClick: () => setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, isLooping: !s.isLooping } : s)), className: `px-2 py-0.5 text-[14px] rounded border font-bold ${st.isLooping ? 'bg-amber-700 text-white border-amber-500' : 'bg-zinc-800 text-zinc-400 border-zinc-700'}` }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "repeat", className: "w-3.5 h-3.5" }))), /*#__PURE__*/React.createElement("button", { onClick: () => updateSubTabEffects(st.id, { reverse: !(st.effects || {}).reverse }), className: `px-2 py-0.5 rounded border text-[14px] font-bold ${(st.effects || {}).reverse ? 'bg-zinc-600 text-white border-zinc-500' : 'bg-zinc-800 text-zinc-400 border-zinc-700'} flex items-center justify-center`, title: "Reverse" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "arrow-left-right", className: "w-3.5 h-3.5" }))), /*#__PURE__*/React.createElement("span", { className: "w-10 text-right text-zinc-500 text-[14px]" }, "Loop:"), /*#__PURE__*/React.createElement("input", { type: "number", min: "0", max: "999", value: st.loopCount || 0, onChange: e => setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, loopCount: Math.max(0, parseInt(e.target.value) || 0) } : s)), className: "w-12 bg-black text-amber-300 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono", title: "Loop count" }))), /*#__PURE__*/React.createElement("div", { className: "flex gap-1 justify-between my-2.5" }, /*#__PURE__*/React.createElement("div", { className: "flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1" }, /*#__PURE__*/React.createElement("span", { className: "text-[14px] font-bold text-zinc-400 uppercase tracking-tighter", title: "Normalize" }, "Norm"), /*#__PURE__*/React.createElement("input", { type: "range", min: "-12", max: "0", step: "0.1", value: subTabNormVal, onChange: e => setSubTabNormVal(parseFloat(e.target.value)), style: { writingMode: 'vertical-lr', direction: 'rtl', height: '120px', width: '20px', accentColor: '#a1a1aa' }, className: "my-2 cursor-pointer" }), /*#__PURE__*/React.createElement("span", { className: "text-[14px] font-mono text-zinc-300 font-bold" }, subTabNormVal, "dB"), /*#__PURE__*/React.createElement("button", { onClick: () => applySubTabEffect(st.id, 'normalize', subTabNormVal), className: "mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650" }, "Apply")), /*#__PURE__*/React.createElement("div", { className: "flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1" }, /*#__PURE__*/React.createElement("span", { className: "text-[14px] font-bold text-zinc-400 uppercase tracking-tighter", title: "Pitch Shift" }, "Pitch"), /*#__PURE__*/React.createElement("input", { type: "range", min: "-12", max: "12", step: "0.5", value: subTabPitchVal, onChange: e => setSubTabPitchVal(parseFloat(e.target.value)), style: { writingMode: 'vertical-lr', direction: 'rtl', height: '120px', width: '20px', accentColor: '#a1a1aa' }, className: "my-2 cursor-pointer" }), /*#__PURE__*/React.createElement("span", { className: "text-[14px] font-mono text-zinc-300 font-bold" }, subTabPitchVal > 0 ? '+' : '', subTabPitchVal, "st"), /*#__PURE__*/React.createElement("button", { onClick: () => applySubTabEffect(st.id, 'pitch', subTabPitchVal), className: "mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650" }, "Apply")), /*#__PURE__*/React.createElement("div", { className: "flex flex-col items-center bg-zinc-900/60 p-1.5 rounded border border-zinc-800 flex-1" }, /*#__PURE__*/React.createElement("span", { className: "text-[14px] font-bold text-zinc-400 uppercase tracking-tighter", title: "Gain Multiplier" }, "Gain"), /*#__PURE__*/React.createElement("input", { type: "range", min: "0", max: "150", step: "1", value: subTabGainVal, onChange: e => setSubTabGainVal(parseInt(e.target.value)), style: { writingMode: 'vertical-lr', direction: 'rtl', height: '120px', width: '20px', accentColor: '#a1a1aa' }, className: "my-2 cursor-pointer" }), /*#__PURE__*/React.createElement("span", { className: "text-[14px] font-mono text-zinc-300 font-bold" }, subTabGainVal, "%"), /*#__PURE__*/React.createElement("button", { onClick: () => applySubTabEffect(st.id, 'gain', subTabGainVal), className: "mt-1.5 px-1 py-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-100 rounded text-[14px] font-bold w-full uppercase border border-zinc-650" }, "Apply"))), /*#__PURE__*/React.createElement("div", { className: "flex-1" }), /*#__PURE__*/React.createElement("div", { className: "mt-auto pt-2.5 border-t border-zinc-800" }, /*#__PURE__*/React.createElement("div", { className: "flex items-center justify-between text-[14px] text-zinc-500 mb-1 font-semibold uppercase" }, /*#__PURE__*/React.createElement("span", null, "Duration:"), /*#__PURE__*/React.createElement("span", { className: "font-mono text-zinc-300" }, st.buffer ? formatTime(st.buffer.duration) : '0s')), /*#__PURE__*/React.createElement("div", { className: "flex items-center justify-between text-[14px] text-zinc-500 mb-2.5 font-semibold uppercase" }, /*#__PURE__*/React.createElement("span", null, "SR:"), /*#__PURE__*/React.createElement("span", { className: "font-mono text-zinc-300" }, st.buffer ? st.buffer.sampleRate : 0, " Hz")), /*#__PURE__*/React.createElement("div", { className: "grid grid-cols-2 gap-1 mb-2" }, /*#__PURE__*/React.createElement("button", { onClick: async e => { subTabAiTrackIdRef.current = st.trackId; handleAIScan(); }, disabled: analysisState.isRunning, className: "py-1 bg-purple-900 hover:bg-purple-800 text-purple-100 font-bold rounded text-xs border border-purple-700 flex items-center justify-center gap-1" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "map-pin", className: "w-3 h-3" })), " Scan"), /*#__PURE__*/React.createElement("button", { onClick: async e => { subTabAiTrackIdRef.current = st.trackId; handleAICutToNewTrack(); }, disabled: analysisState.isRunning, className: "py-1 bg-fuchsia-800 hover:bg-fuchsia-700 text-white font-bold rounded text-xs flex items-center justify-center gap-1" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "scissors", className: "w-3 h-3" })), " Cut"), /*#__PURE__*/React.createElement("button", { onClick: async e => { subTabAiTrackIdRef.current = st.trackId; if (st.buffer) { setSelectionRangeOnBuffer(st.buffer, st.selectionStart || 0, st.selectionEnd || st.buffer.duration); } handleAIAnalysicLoop(); }, disabled: analysisState.isRunning, className: "py-1 bg-violet-800 hover:bg-violet-700 text-violet-100 font-bold rounded text-xs border border-violet-600 flex items-center justify-center gap-1 col-span-2" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "sparkles", className: "w-3 h-3" })), " AI Analysic Loop")), /*#__PURE__*/React.createElement("button", { onClick: () => exportSubTabBuffer(st.id), className: "w-full py-1.5 mb-1.5 bg-blue-700 hover:bg-blue-600 text-white font-bold rounded text-[14px] flex items-center justify-center gap-1 transition" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "download", className: "w-4 h-4" })), " Export"), /*#__PURE__*/React.createElement("button", { onClick: () => applySubTab(st.id), className: "w-full py-1.5 bg-emerald-700 hover:bg-emerald-600 text-white font-bold rounded text-[14px] flex items-center justify-center gap-1 transition" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "save", className: "w-4 h-4" })), " Save"))) : /*#__PURE__*/React.createElement("div", { className: "flex-1 flex items-center justify-center text-xs text-zinc-500" }, "Track not found")), /*#__PURE__*/React.createElement("div", { ref: timelineWrapperRef, className: "flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0" }, /*#__PURE__*/React.createElement("div", { style: { width: `${subTabTimelineWidth}px` }, className: "relative flex flex-col min-h-full" }, /*#__PURE__*/React.createElement("div", { className: "sticky top-0 z-45 flex h-10 border-b border-zinc-900 bg-[#242424] shrink-0" }, /*#__PURE__*/React.createElement("div", { className: "flex-1 relative h-full flex items-center select-none cursor-ew-resize overflow-hidden", onMouseDown: handleRulerMouseDown }, Array.from({ length: Math.ceil(st.buffer ? st.buffer.duration : 0) }).map((_, i) => { const sec = i; const x = sec * zoom; return /*#__PURE__*/React.createElement("div", { key: i, className: "absolute h-full border-l border-zinc-700 pl-1 pt-1 text-[14px] font-mono text-zinc-300 pointer-events-none", style: { left: `${x}px` } }, formatTimeSimple(sec)); }))), /*#__PURE__*/React.createElement("div", { className: "sticky top-10 z-35 flex h-[40px] border-b border-purple-500/30 bg-[#1a1a2e] shrink-0" }, /*#__PURE__*/React.createElement("div", { className: "flex-1 relative h-full overflow-hidden bg-[#1a1a2e]" }, /*#__PURE__*/React.createElement(TempoTrackLane, { bpm: parseInt(bpm) || 120, zoom: zoom, timelineWidth: subTabTimelineWidth, onPlayheadSet: setCurrentTime, snapValue: snapValue }))), /*#__PURE__*/React.createElement("div", { className: "flex-1 flex flex-col relative bg-[#111111] min-h-full" }, vTrack && /*#__PURE__*/React.createElement("div", { style: { height: `${subTabHeight}px` }, className: "relative overflow-hidden border-2 border-purple-500/40 rounded bg-[#151515] mx-1 mt-1 shrink-0" }, /*#__PURE__*/React.createElement(SubTabWaveform, { buffer: st.buffer, subTabId: st.id, activeTab: activeTab, activeTool: activeTool, currentTime: st.currentTime, selectionStart: st.selectionStart, selectionEnd: st.selectionEnd, onSelectRange: (start, end) => setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, selectionStart: start, selectionEnd: end } : s)), onPlayheadSet: time => setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: time } : s)), onContextMenu: (e, clickTime) => setContextMenu({ x: e.clientX, y: e.clientY, isSubTab: true, subTabId: st.id, time: clickTime }), zoom: zoom, timelineWidth: subTabTimelineWidth, color: vTrack.color, name: vTrack.name, speed: st.speed || 1.0, volumeNodes: st.volumeNodes || [], panningNodes: st.panningNodes || [], fadeInLen: st.fadeInLen || 0, fadeOutLen: st.fadeOutLen || 0, graphMode: st.graphMode, channelInfo: st.channelInfo, selectedNodeTime: subTabSelectedNodeTime, setSelectedNodeTime: setSubTabSelectedNodeTime, onUpdateNodes: nodes => setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, [s.graphMode === 'pan' ? 'panningNodes' : 'volumeNodes']: nodes } : s)), onUpdateFade: fade => setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, fadeInLen: fade.fadeInLen ?? s.fadeInLen, fadeOutLen: fade.fadeOutLen ?? s.fadeOutLen } : s)), onModeToggle: () => setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, graphMode: s.graphMode === 'pan' ? null : 'pan' } : s)), onSpeedChange: newSpeed => { setSubTabs(prev => prev.map(s => { if (s.id !== st.id) return s; const oldSpeed = s.speed || 1.0; const ratio = oldSpeed / newSpeed; const newVolumeNodes = (s.volumeNodes || []).map(n => ({ ...n, time: n.time * ratio })); const newPanningNodes = (s.panningNodes || []).map(n => ({ ...n, time: n.time * ratio })); return { ...s, speed: newSpeed, volumeNodes: newVolumeNodes, panningNodes: newPanningNodes, fadeInLen: (s.fadeInLen || 0) * ratio, fadeOutLen: (s.fadeOutLen || 0) * ratio, currentTime: (s.currentTime || 0) * ratio, label: s.label.replace(/\s\(\d+%\)$/, '') + ` (${Math.round(newSpeed * 100)}%)` }; })); const n = activeTrackNodesRef.current[st.trackId]; if (n && n.source) n.source.playbackRate.value = newSpeed; // Reset time refs to prevent playhead jump when speed changes mid-playback const ctx = getAudioContext(); const elapsed = ctx.currentTime - startAudioTimeRef.current; const oldSpeed = activePlaybackSpeedRef.current; const ratio = oldSpeed / newSpeed; startBufferOffsetRef.current = startBufferOffsetRef.current + elapsed * oldSpeed; startOffsetTimeRef.current = (startOffsetTimeRef.current + elapsed) * ratio; startAudioTimeRef.current = ctx.currentTime; activePlaybackSpeedRef.current = newSpeed; } }), /*#__PURE__*/React.createElement("div", { onMouseDown: handleSubTabResizeMouseDown, className: "absolute bottom-0 left-0 right-0 h-1.5 cursor-ns-resize z-30 hover:bg-purple-500/50 transition-colors" })), st.selectionStart !== null && st.selectionEnd !== null && st.selectionEnd > st.selectionStart && /*#__PURE__*/React.createElement("div", { className: "absolute top-0 bottom-0 border-l border-r border-amber-500 bg-amber-500/10 z-20 pointer-events-none", style: { left: `${Math.min(st.selectionStart, st.selectionEnd) * zoom}px`, width: `${Math.abs(st.selectionEnd - st.selectionStart) * zoom}px` } }))))); })())), /*#__PURE__*/React.createElement("div", { className: "w-1 cursor-ew-resize hover:bg-cyan-500/50 active:bg-cyan-400 transition-colors shrink-0 z-10", onMouseDown: startColResize }), renderDock('right', 'Right')), renderDock('bottom', 'Bottom')); })(), /*#__PURE__*/React.createElement("div", { className: "h-6 bg-[#141414] border-t border-zinc-900 px-4 flex items-center justify-between text-xs text-zinc-500 select-none shrink-0" }, /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-4" }, /*#__PURE__*/React.createElement("span", null, "Status: ", isPlaying ? 'Playing' : 'Stopped'), activeTab !== 'main' && /*#__PURE__*/React.createElement("span", { className: "text-amber-400 font-semibold uppercase" }, "Sub-Tab"), activeTab === 'main' && /*#__PURE__*/React.createElement("span", { className: "text-cyan-400 font-semibold uppercase" }, "Track: ID ", selectedTrackId), selectionMode === 'local' && /*#__PURE__*/React.createElement("span", { className: "text-amber-400 font-semibold uppercase text-xs" }, "Local Sel"), selectionMode === 'global' && /*#__PURE__*/React.createElement("span", { className: "text-purple-400 font-semibold uppercase text-xs" }, "Global Sel"), soloedTrackId && /*#__PURE__*/React.createElement("span", { className: "text-amber-500 font-semibold" }, "Solo: ID ", soloedTrackId), isLoopingSelection && selectionMode === 'local' && /*#__PURE__*/React.createElement("span", { className: "text-emerald-400 font-semibold uppercase text-xs" }, "Solo Loop"), isLoopingSelection && selectionMode !== 'local' && /*#__PURE__*/React.createElement("span", { className: "text-cyan-400 font-semibold uppercase text-xs" }, "Master Loop")), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-2" }, /*#__PURE__*/React.createElement("button", { onClick: () => setShowExportPanel(p => !p), className: `px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showExportPanel ? 'bg-cyan-900 text-cyan-300' : 'text-zinc-500 hover:text-zinc-300'}`, title: `Export Panel (${panelPositions.export})` }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "save", className: "w-3 h-3" })), /*#__PURE__*/React.createElement("span", { className: "text-[7px] opacity-60" }, showExportPanel ? panelPositions.export[0].toUpperCase() : '')), /*#__PURE__*/React.createElement("button", { onClick: () => setShowSelectionPanel(p => !p), className: `px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showSelectionPanel ? 'bg-amber-900 text-amber-300' : 'text-zinc-500 hover:text-zinc-300'}`, title: `Selection Panel (${panelPositions.selection})` }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "sliders", className: "w-3 h-3" })), /*#__PURE__*/React.createElement("span", { className: "text-[7px] opacity-60" }, showSelectionPanel ? panelPositions.selection[0].toUpperCase() : '')), /*#__PURE__*/React.createElement("button", { onClick: () => setShowAIPanel(p => !p), className: `px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showAIPanel ? 'bg-purple-900 text-purple-300' : 'text-zinc-500 hover:text-zinc-300'}`, title: `AI Panel (${panelPositions.ai})` }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "cpu", className: "w-3 h-3" })), /*#__PURE__*/React.createElement("span", { className: "text-[7px] opacity-60" }, showAIPanel ? panelPositions.ai[0].toUpperCase() : '')), /*#__PURE__*/React.createElement("button", { onClick: () => setShowFxRack(p => !p), className: `px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showFxRack ? 'bg-rose-900 text-rose-300' : 'text-zinc-500 hover:text-zinc-300'}`, title: `FX Rack Panel (${panelPositions.fx_rack || 'bottom'})` }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "sliders", className: "w-3 h-3" })), /*#__PURE__*/React.createElement("span", { className: "text-[7px] opacity-60" }, showFxRack ? (panelPositions.fx_rack || 'bottom')[0].toUpperCase() : '')), /*#__PURE__*/React.createElement("button", { onClick: () => setShowMidiEvents(p => !p), className: `px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showMidiEvents ? 'bg-sky-900 text-sky-300' : 'text-zinc-500 hover:text-zinc-300'}`, title: `MIDI Events Panel (${panelPositions.midi_events || 'bottom'})` }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "music", className: "w-3 h-3" })), /*#__PURE__*/React.createElement("span", { className: "text-[7px] opacity-60" }, showMidiEvents ? (panelPositions.midi_events || 'bottom')[0].toUpperCase() : '')), /*#__PURE__*/React.createElement("span", { className: "w-[1px] h-3 bg-zinc-800 mx-1" }), /*#__PURE__*/React.createElement("span", { className: "flex items-center gap-1" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "info", className: "w-3 h-3 text-zinc-600" })), " Scroll: Zoom"), /*#__PURE__*/React.createElement("span", null, "|"), /*#__PURE__*/React.createElement("span", { className: "flex items-center gap-1" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "keyboard", className: "w-3 h-3 text-zinc-600" })), " Ctrl+Scroll: Playhead"))), contextMenu && (contextMenu.isSubTab ? /*#__PURE__*/React.createElement("div", { className: "fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64", style: { left: contextMenu.x, top: contextMenu.y }, onClick: e => e.stopPropagation() }, /*#__PURE__*/React.createElement("div", { className: "px-3 py-1 text-xs text-zinc-500 font-bold uppercase border-b border-zinc-800 pb-1 mb-1" }, "Selection: ", formatTime(subTabs.find(s => s.id === contextMenu.subTabId)?.selectionStart || 0), " - ", formatTime(subTabs.find(s => s.id === contextMenu.subTabId)?.selectionEnd || 0)), /*#__PURE__*/React.createElement("div", { className: "h-px bg-zinc-700 my-1" }), /*#__PURE__*/React.createElement("button", { onClick: () => { handleSubTabCut(contextMenu.subTabId); closeContextMenu(); }, className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "scissors", className: "w-3.5 h-3.5 text-rose-400 shrink-0" })), /*#__PURE__*/React.createElement("span", { className: "flex-1" }, "Cut"), /*#__PURE__*/React.createElement("span", { className: "text-purple-400 text-xs font-semibold font-mono ml-auto" }, "Ctrl+X")), /*#__PURE__*/React.createElement("button", { onClick: () => { handleSubTabCopy(contextMenu.subTabId); closeContextMenu(); }, className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "copy", className: "w-3.5 h-3.5 text-zinc-400 shrink-0" })), /*#__PURE__*/React.createElement("span", { className: "flex-1" }, "Copy"), /*#__PURE__*/React.createElement("span", { className: "text-purple-400 text-xs font-semibold font-mono ml-auto" }, "Ctrl+C")), /*#__PURE__*/React.createElement("button", { onClick: () => { handleSubTabPaste(contextMenu.subTabId); closeContextMenu(); }, className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "clipboard", className: "w-3.5 h-3.5 text-emerald-400 shrink-0" })), /*#__PURE__*/React.createElement("span", { className: "flex-1" }, "Paste"), /*#__PURE__*/React.createElement("span", { className: "text-purple-400 text-xs font-semibold font-mono ml-auto" }, "Ctrl+V")), /*#__PURE__*/React.createElement("button", { onClick: () => { handleSubTabDelete(contextMenu.subTabId); closeContextMenu(); }, className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "trash-2", className: "w-3.5 h-3.5 text-red-400 shrink-0" })), /*#__PURE__*/React.createElement("span", { className: "flex-1" }, "Delete Selected Segment"), /*#__PURE__*/React.createElement("span", { className: "text-amber-400 text-xs font-semibold font-mono ml-auto" }, "Del")), /*#__PURE__*/React.createElement("div", { className: "h-px bg-zinc-700 my-1" }), /*#__PURE__*/React.createElement("button", { onClick: () => { handleSubTabLoop(contextMenu.subTabId, 4); closeContextMenu(); }, className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "repeat", className: "w-3.5 h-3.5 text-cyan-400 shrink-0" })), /*#__PURE__*/React.createElement("span", { className: "flex-1" }, "Loop Selection 4 times"), /*#__PURE__*/React.createElement("span", { className: "text-purple-400 text-xs font-semibold font-mono ml-auto" }, "Ctrl+L"))) : /*#__PURE__*/React.createElement("div", { className: "fixed z-[60] bg-[#2a2a2a] border border-zinc-700 rounded-lg shadow-2xl py-1 w-64", style: { left: contextMenu.x, top: contextMenu.y }, onClick: e => e.stopPropagation() }, contextMenu.sectionId ? /*#__PURE__*/React.createElement("button", { onClick: contextMenuEditSection, className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "file-edit", className: "w-3.5 h-3.5 text-amber-400 shrink-0" })), /*#__PURE__*/React.createElement("span", { className: "flex-1" }, "Edit Section"), /*#__PURE__*/React.createElement("span", { className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8" }, "Ctrl+E")) : /*#__PURE__*/React.createElement("button", { onClick: contextMenuEdit, className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "file-edit", className: "w-3.5 h-3.5 text-amber-400 shrink-0" })), /*#__PURE__*/React.createElement("span", { className: "flex-1" }, "Edit"), /*#__PURE__*/React.createElement("span", { className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8" }, "Ctrl+E")), /*#__PURE__*/React.createElement("button", { onClick: contextMenuSplit, className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "scissors", className: "w-3.5 h-3.5 text-cyan-400 shrink-0" })), /*#__PURE__*/React.createElement("span", { className: "flex-1" }, "Split"), /*#__PURE__*/React.createElement("span", { className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8" }, "S")), /*#__PURE__*/React.createElement("button", { onClick: contextMenuMerge, className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "combine", className: "w-3.5 h-3.5 text-purple-400 shrink-0" })), /*#__PURE__*/React.createElement("span", { className: "flex-1" }, "Merge"), /*#__PURE__*/React.createElement("span", { className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8" }, "Ctrl+M")), /*#__PURE__*/React.createElement("div", { className: "h-px bg-zinc-700 my-1" }), /*#__PURE__*/React.createElement("button", { onClick: contextMenuCopy, className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "copy", className: "w-3.5 h-3.5 text-zinc-400 shrink-0" })), /*#__PURE__*/React.createElement("span", { className: "flex-1" }, "Copy"), /*#__PURE__*/React.createElement("span", { className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8" }, "Ctrl+C")), /*#__PURE__*/React.createElement("button", { onClick: contextMenuCut, className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "scissors", className: "w-3.5 h-3.5 text-rose-400 shrink-0" })), /*#__PURE__*/React.createElement("span", { className: "flex-1" }, "Cut"), /*#__PURE__*/React.createElement("span", { className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8" }, "Ctrl+X")), /*#__PURE__*/React.createElement("button", { onClick: contextMenuPaste, className: "w-full px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-700 text-left flex items-center gap-2" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "clipboard", className: "w-3.5 h-3.5 text-emerald-400 shrink-0" })), /*#__PURE__*/React.createElement("span", { className: "flex-1" }, "Paste"), /*#__PURE__*/React.createElement("span", { className: "text-purple-400 text-xs font-semibold font-mono ml-auto pl-8" }, "Ctrl+V")), /*#__PURE__*/React.createElement("div", { className: "h-px bg-zinc-700 my-1" }), /*#__PURE__*/React.createElement("button", { onClick: contextMenuDelete, className: "w-full px-3 py-1.5 text-xs text-red-300 hover:bg-red-950 text-left flex items-center gap-2" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "trash-2", className: "w-3.5 h-3.5 text-red-400 shrink-0" })), /*#__PURE__*/React.createElement("span", { className: "flex-1" }, "Delete"), /*#__PURE__*/React.createElement("span", { className: "text-amber-400 text-xs font-semibold font-mono ml-auto pl-8" }, "Del")))), toastMessage && /*#__PURE__*/React.createElement("div", { className: "absolute top-14 right-4 z-50 px-4 py-2.5 rounded shadow-lg text-xs font-semibold flex items-center gap-2 border bg-zinc-900 text-zinc-100 border-zinc-800" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "bell", className: `w-4 h-4 ${toastMessage.type === 'success' ? 'text-emerald-400' : toastMessage.type === 'error' ? 'text-rose-400' : toastMessage.type === 'warning' ? 'text-amber-400' : 'text-cyan-400'}` })), toastMessage.text, toastMessage.onActionClick && /*#__PURE__*/React.createElement("button", { onClick: () => { toastMessage.onActionClick(); setToastMessage(null); }, className: "ml-2 px-2 py-0.5 bg-purple-700 hover:bg-purple-600 text-white rounded text-[10px] uppercase font-bold" }, toastMessage.actionText || 'Tải về'))), /*#__PURE__*/React.createElement(AuthModal, { isOpen: authModalOpen, mode: authMode, forceMandatory: isMandatoryLogin, onClose: () => setAuthModalOpen(false), onSuccess: handleAuthSuccess }), /*#__PURE__*/React.createElement(ProfileModal, { isOpen: profileModalOpen, onClose: () => setProfileModalOpen(false), tracks: tracks, setTracks: setTracks, setSelectedTrackId: setSelectedTrackId, projectName: projectName, setProjectName: setProjectName, currentProjectId: currentProjectId, setCurrentProjectId: setCurrentProjectId, showToast: showToast }), /*#__PURE__*/React.createElement(SaveProjectModal, { isOpen: saveProjectModalOpen, onClose: () => setSaveProjectModalOpen(false), onSave: newName => { handleSaveProjectWithName(newName); } }), /*#__PURE__*/React.createElement(SaveAsModal, { isOpen: saveAsModalOpen, onClose: () => setSaveAsModalOpen(false), projectName: projectName, onSaveCloud: newName => { handleSaveAsCloud(newName); }, onSaveLocal: newName => { handleExportSFS(newName); setProjectName(newName); localStorage.setItem('sonic_project_name', newName); } }), /*#__PURE__*/React.createElement(AIConfigModal, { isOpen: aiConfigModalOpen, onClose: () => setAiConfigModalOpen(false), onConfigSaved: providers => { setAiProviders(providers); const active = providers.find(p => p.is_active) || providers[0]; if (active) setSelectedProviderId(active.id); } }), /*#__PURE__*/React.createElement(SystemManagerModal, { isOpen: systemManagerModalOpen, onClose: () => setSystemManagerModalOpen(false) })); }; const root = ReactDOM.createRoot(document.getElementById('root')); root.render(/*#__PURE__*/React.createElement(App, null)); setTimeout(() => lucide.createIcons(), 300);