const { useState, useRef, useEffect, useMemo, useCallback } = 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 midiPitchToName = pitch => { const names = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']; const octave = Math.floor(pitch / 12) - 1; return names[pitch % 12] + octave; }; 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; }; class ClientMIDIRecorder { constructor(audioContext, bpm = 120, timeSigNumerator = 4) { this.audioCtx = audioContext; this.bpm = bpm; this.timeSigNum = timeSigNumerator; this.isRecording = false; this.tempMidiItemId = null; this.activeNotes = new Map(); // Store pitch -> { noteId, startBeat, velocity } this.recordedNotes = []; this.recStartAudioTime = 0.0; this.recStartBar = 0.0; // Compute round-trip browser latency this.latencyCompSec = (this.audioCtx.baseLatency || 0) + (this.audioCtx.outputLatency || 0); } start(startBar = 0.0, selectedMidiInputId = null) { this.isRecording = true; this.recordedNotes = []; this.activeNotes.clear(); this.recStartBar = startBar; this.recStartAudioTime = this.audioCtx.currentTime; this.bindMIDIInputs(selectedMidiInputId); } bindMIDIInputs(selectedMidiInputId = null) { if (navigator.requestMIDIAccess) { navigator.requestMIDIAccess().then(midiAccess => { for (let input of midiAccess.inputs.values()) { if (selectedMidiInputId && input.id !== selectedMidiInputId) continue; input.onmidimessage = (event) => this.handleMIDIMessage(event); } }); } } handleMIDIMessage(event) { if (!this.isRecording) return; const [status, pitch, velocity] = event.data; const command = status >> 4; // Apply latency compensation formula const currentTimeSec = Math.max(0, this.audioCtx.currentTime - this.recStartAudioTime - this.latencyCompSec); const secondsPerBeat = 60.0 / this.bpm; const currentBeat = (currentTimeSec / secondsPerBeat) + (this.recStartBar * this.timeSigNum); // Command 0x9: Note On if (command === 0x9 && velocity > 0) { const noteId = `rec_${Date.now()}_${pitch}`; this.activeNotes.set(pitch, { id: noteId, pitch: pitch, start_beat: currentBeat, velocity: velocity / 127.0 }); // Fire visual feedback callback if (this.onNoteOn) { this.onNoteOn(pitch, currentBeat); } } // Command 0x8: Note Off (or Note On with velocity = 0) else if (command === 0x8 || (command === 0x9 && velocity === 0)) { if (this.activeNotes.has(pitch)) { const note = this.activeNotes.get(pitch); const durationBeats = Math.max(0.125, currentBeat - note.start_beat); // Min 1/32 note const finishedNote = { id: note.id, pitch: note.pitch, start_beat: note.start_beat, duration_beats: durationBeats, velocity: note.velocity, pan: 0.0 }; this.recordedNotes.push(finishedNote); this.activeNotes.delete(pitch); if (this.onNoteOff) { this.onNoteOff(pitch, finishedNote); } } } } stop() { this.isRecording = false; // Flush remaining active keypresses when stop is triggered const currentTimeSec = Math.max(0, this.audioCtx.currentTime - this.recStartAudioTime - this.latencyCompSec); const currentBeat = (currentTimeSec / (60.0 / this.bpm)) + (this.recStartBar * this.timeSigNum); for (let [pitch, note] of this.activeNotes.entries()) { this.recordedNotes.push({ id: note.id, pitch: note.pitch, start_beat: note.start_beat, duration_beats: Math.max(0.25, currentBeat - note.start_beat), velocity: note.velocity, pan: 0.0 }); } this.activeNotes.clear(); // Disconnect midi Access callbacks if (navigator.requestMIDIAccess) { navigator.requestMIDIAccess().then(midiAccess => { for (let input of midiAccess.inputs.values()) { input.onmidimessage = null; } }); } return this.recordedNotes; } } class ClientAudioRecorder { constructor(audioContext) { this.audioCtx = audioContext; this.mediaStream = null; this.sourceNode = null; this.workletNode = null; this.pcmChunks = []; this.isRecording = false; } async initializeInput(deviceId = null) { const constraints = { audio: { deviceId: deviceId ? { exact: deviceId } : undefined, echoCancellation: false, noiseSuppression: false, autoGainControl: false } }; this.mediaStream = await navigator.mediaDevices.getUserMedia(constraints); this.sourceNode = this.audioCtx.createMediaStreamSource(this.mediaStream); } async start(destinationTrackGainNode = null, enableMonitoring = true) { this.pcmChunks = []; this.isRecording = true; // Load Worklet Processor Module await this.audioCtx.audioWorklet.addModule('/static/processors/pcm-recorder-processor.js'); this.workletNode = new AudioWorkletNode(this.audioCtx, 'pcm-recorder-processor'); // Receive PCM data streams from AudioWorklet this.workletNode.port.onmessage = (event) => { if (this.isRecording && event.data.type === 'PCM_DATA') { const chunk = new Float32Array(event.data.buffer); this.pcmChunks.push(chunk); // VU Meter Level Callback if (this.onLevelUpdate) { let sum = 0; for (let i = 0; i < chunk.length; i++) { sum += chunk[i] * chunk[i]; } const rms = Math.sqrt(sum / chunk.length); const db = rms > 0 ? 20 * Math.log10(rms) : -96; this.onLevelUpdate(db); } // Live visualization Callback if (this.onPCMChunk) { this.onPCMChunk(chunk); } } }; // Route Audio Nodes this.sourceNode.connect(this.workletNode); // Enable Live Input Monitoring if requested if (enableMonitoring && destinationTrackGainNode) { this.sourceNode.connect(destinationTrackGainNode); } } async stop() { this.isRecording = false; if (this.sourceNode && this.workletNode) { try { this.sourceNode.disconnect(this.workletNode); } catch (e) {} } if (this.mediaStream) { this.mediaStream.getTracks().forEach(track => track.stop()); } // Concatenate PCM Float32Array chunks into a single AudioBuffer const totalSamples = this.pcmChunks.reduce((sum, chunk) => sum + chunk.length, 0); if (totalSamples === 0) return null; const audioBuffer = this.audioCtx.createBuffer(1, totalSamples, this.audioCtx.sampleRate); const channelData = audioBuffer.getChannelData(0); let offset = 0; for (const chunk of this.pcmChunks) { channelData.set(chunk, offset); offset += chunk.length; } return audioBuffer; // Return compiled AudioBuffer for timeline insertion } } 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, onEditMidiInTab, onSelectionEdgeDragStart, setSelectedClipId, selectedClipId, activeTool, onSplitTrackAtTime, onEditClipInSubTab, snapValue, bpm, scrollLeft, recordingState, recTempMidiNotes, recTempAudioBuffer, recStartTimelineTime, canvasRedrawCount }) => { 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 (recordingState === 'RECORDING' && track.isArmed && track.inputSource?.deviceType === 'MICROPHONE' && recTempAudioBuffer) { clips.push({ id: 'rec_temp_' + track.id, buffer: recTempAudioBuffer, startTime: recStartTimelineTime, name: '[GHI ÂM...]', speed: 1.0, isTemp: true }); } 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 = clip.isTemp ? 'rgba(239, 68, 68, 0.25)' : 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 = clip.isTemp ? '#ef4444' : 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); const midiNotes = midi.notes || []; if (midiNotes.length > 0) { const secondsPerBeat = 60.0 / (parseInt(bpm) || 120); const pitchMin = 36; const pitchMax = 84; midiNotes.forEach(note => { const noteStartSec = (note.start_beat || 0) * secondsPerBeat; const noteDurSec = Math.max(0.02, (note.duration_beats || 0.25) * secondsPerBeat); const noteStartLocal = (midi.startTime + noteStartSec) * zoom - scrollLeft; const nw = noteDurSec * zoom; if (noteStartLocal + nw < midiStartLocal || noteStartLocal > midiStartLocal + midiWidth) return; const pitchFrac = Math.max(0, Math.min(1, (note.pitch - pitchMin) / (pitchMax - pitchMin))); const ny = 18 + (1.0 - pitchFrac) * (height - 26); const nh = Math.max(6, (height - 26) / (pitchMax - pitchMin) * 4); ctx.fillStyle = '#a78bfa'; ctx.fillRect(Math.max(noteStartLocal, midiStartLocal + 2), ny, Math.max(2, nw), nh); }); } }); if (recordingState === 'RECORDING' && track.isArmed && track.inputSource?.deviceType === 'MIDI_KEYBOARD') { const liveMidiDuration = currentTime - recStartTimelineTime; const recStartLocal = recStartTimelineTime * zoom - scrollLeft; const recWidth = liveMidiDuration * zoom; if (recWidth > 0 && recStartLocal + recWidth >= 0 && recStartLocal <= drawWidth) { ctx.fillStyle = 'rgba(239, 68, 68, 0.2)'; ctx.fillRect(recStartLocal, 2, recWidth, height - 4); ctx.strokeStyle = '#ef4444'; ctx.lineWidth = 1.5; ctx.strokeRect(recStartLocal, 2, recWidth, height - 4); ctx.fillStyle = '#fca5a5'; ctx.font = 'bold 9px sans-serif'; ctx.fillText('[GHI MIDI...]', Math.max(recStartLocal + 4, 4), 14); if (recTempMidiNotes && recTempMidiNotes.length > 0) { const secondsPerBeat = 60.0 / (parseInt(bpm) || 120); recTempMidiNotes.forEach(note => { const noteStartSec = note.start_beat * secondsPerBeat; const noteDurSec = note.duration_beats * secondsPerBeat; const nxLocal = noteStartSec * zoom - scrollLeft; const nw = noteDurSec * zoom; const pitchMin = 36; const pitchMax = 84; const pitchFrac = Math.max(0, Math.min(1, (note.pitch - pitchMin) / (pitchMax - pitchMin))); const ny = 6 + (1.0 - pitchFrac) * (height - 18); const nh = Math.max(6, (height - 18) / (pitchMax - pitchMin) * 4); ctx.fillStyle = '#10b981'; ctx.fillRect(nxLocal, ny, Math.max(2, nw), nh); }); } } } // 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, recordingState, recTempMidiNotes, recStartTimelineTime, canvasRedrawCount]); 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 midiItems = 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 midiItems) { 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 midiItems) { 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; } // Check double-click on MIDI item next const dblMidiItems = track.midiItems || []; let dblMidiHit = null; for (const midi of dblMidiItems) { if (time >= midi.startTime && time < midi.startTime + midi.duration) { dblMidiHit = midi; break; } } if (dblMidiHit) { e.preventDefault(); e.stopPropagation(); if (onEditMidiInTab) onEditMidiInTab(track.id, dblMidiHit.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); // Detect section under cursor 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) => { setAppWarningModal({ title: "Mở dự án", message: "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.", isAlert: false, onConfirm: async () => { try { const proj = await window.SonicAPI.getCloudProject(projectId); const parsed = JSON.parse(proj.data_json); let restoredTracks = []; let restoredBpm = bpm; let restoredSessionTabs = []; if (parsed.main_session) { const result = deserializeProjectFromSchema(parsed); restoredTracks = result.tracks; restoredBpm = result.bpm; restoredSessionTabs = result.sessionTabs; } else { restoredTracks = (parsed.tracks || []).map(t => { const { height: _h, ...rest } = t; return { ...rest, buffer: null, channelInfo: t.channelInfo || null, clips: t.clips || [], serverFileId: t.serverFileId || null }; }); } setTracks(restoredTracks); setBpm(restoredBpm.toString()); setSelectedTrackId(restoredTracks[0]?.id || '1'); setProjectName(proj.name); setCurrentProjectId(proj.id); if (restoredSessionTabs.length > 0) { setSessionTabs(restoredSessionTabs); } 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"); } 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 PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNotes }) => { const [activeRollTool, setActiveRollTool] = React.useState('select'); const [snapVal, setSnapVal] = React.useState('1/16'); const [ccMode, setCcMode] = React.useState('velocity'); const [rollZoom, setRollZoom] = React.useState(60); // local horizontal zoom factor const canvasRef = React.useRef(null); const ccCanvasRef = React.useRef(null); const gridScrollRef = React.useRef(null); const keybedRef = React.useRef(null); const NoteHeight = 18; const pixelsPerBeat = rollZoom; const timeSigNum = 4; const totalBeats = (st.duration || 4) * timeSigNum; const drawWidth = totalBeats * pixelsPerBeat; const [notes, setNotes] = React.useState(st.notes || []); const [draggedNote, setDraggedNote] = React.useState(null); // { mode: 'move'|'resize', idx, startOffsetBeat, originalStart } const [hoveredResizeIdx, setHoveredResizeIdx] = React.useState(-1); React.useEffect(() => { onUpdateNotes(st.id, notes); }, [notes]); const getSnapBeat = (beat, mode) => { let q = 0.25; if (mode === 'free') return beat; if (mode === '1') q = 4.0; else if (mode === '1/2') q = 2.0; else if (mode === '1/4') q = 1.0; else if (mode === '1/8') q = 0.5; else if (mode === '1/16') q = 0.25; else if (mode === '1/32') q = 0.125; return Math.round(beat / q) * q; }; const getSnapDuration = (mode) => { if (mode === 'free') return 0.25; if (mode === '1') return 4.0; if (mode === '1/2') return 2.0; if (mode === '1/4') return 1.0; if (mode === '1/8') return 0.5; if (mode === '1/16') return 0.25; if (mode === '1/32') return 0.125; return 0.25; }; // Local Zoom Wheel Event handler to block browser page zoom React.useEffect(() => { const handleWheelRaw = (e) => { if (e.ctrlKey) { e.preventDefault(); const zoomFactor = e.deltaY < 0 ? 1.15 : 0.85; setRollZoom(prev => Math.max(15, Math.min(250, prev * zoomFactor))); } }; const container = gridScrollRef.current; if (container) { container.addEventListener('wheel', handleWheelRaw, { passive: false }); } return () => { if (container) { container.removeEventListener('wheel', handleWheelRaw); } }; }, []); React.useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; const h = 128 * NoteHeight; canvas.width = drawWidth * dpr; canvas.height = h * dpr; ctx.scale(dpr, dpr); // Draw background rows for (let pitch = 0; pitch < 128; pitch++) { const y = (127 - pitch) * NoteHeight; const isBlack = [1, 3, 6, 8, 10].includes(pitch % 12); ctx.fillStyle = isBlack ? '#1a1a1e' : '#25252a'; ctx.fillRect(0, y, drawWidth, NoteHeight); ctx.strokeStyle = '#2d2d35'; ctx.lineWidth = 0.5; ctx.beginPath(); ctx.moveTo(0, y + NoteHeight); ctx.lineTo(drawWidth, y + NoteHeight); ctx.stroke(); } // Draw snap lines let snapBeats = 0.25; if (snapVal === '1') snapBeats = 4.0; else if (snapVal === '1/2') snapBeats = 2.0; else if (snapVal === '1/4') snapBeats = 1.0; else if (snapVal === '1/8') snapBeats = 0.5; else if (snapVal === '1/16') snapBeats = 0.25; else if (snapVal === '1/32') snapBeats = 0.125; for (let beat = 0; beat <= totalBeats; beat += snapBeats) { const x = beat * pixelsPerBeat; const isBar = beat % timeSigNum === 0; ctx.strokeStyle = isBar ? '#444450' : '#2d2d35'; ctx.lineWidth = isBar ? 1.2 : 0.6; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); } // Draw notes with velocity layer representation (Hiển thị layer velocity ngay trên nốt) notes.forEach((note) => { const x = note.start_beat * pixelsPerBeat; const y = (127 - note.pitch) * NoteHeight; const w = note.duration_beats * pixelsPerBeat; // Draw background of note (lighter yellow) ctx.fillStyle = 'rgba(234, 179, 8, 0.25)'; ctx.strokeStyle = '#ca8a04'; ctx.lineWidth = 1; ctx.fillRect(x + 1, y + 1, w - 2, NoteHeight - 2); ctx.strokeRect(x + 1, y + 1, w - 2, NoteHeight - 2); // Draw velocity layer (solid yellow bar inside, proportional to velocity) const vel = note.velocity !== undefined ? note.velocity : 0.8; const velW = Math.max(2, (w - 2) * vel); ctx.fillStyle = '#eab308'; ctx.fillRect(x + 1, y + 1, velW, NoteHeight - 2); }); }, [notes, snapVal, rollZoom]); React.useEffect(() => { const canvas = ccCanvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; const h = 80; canvas.width = drawWidth * dpr; canvas.height = h * dpr; ctx.scale(dpr, dpr); ctx.fillStyle = '#161616'; ctx.fillRect(0, 0, drawWidth, h); ctx.strokeStyle = '#252525'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, h / 2); ctx.lineTo(drawWidth, h / 2); ctx.stroke(); notes.forEach((note) => { const x = note.start_beat * pixelsPerBeat + (note.duration_beats * pixelsPerBeat) / 2; let val = note.velocity !== undefined ? note.velocity : 0.8; if (ccMode === 'pan') { val = (note.pan !== undefined ? note.pan : 0.0) * 0.5 + 0.5; } const stemH = val * (h - 20) + 10; const y = h - stemH; ctx.strokeStyle = ccMode === 'pan' ? '#a78bfa' : '#fbbf24'; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(x, h); ctx.lineTo(x, y); ctx.stroke(); ctx.fillStyle = ccMode === 'pan' ? '#c084fc' : '#fbbf24'; ctx.beginPath(); ctx.arc(x, y, 3.5, 0, 2 * Math.PI); ctx.fill(); }); }, [notes, ccMode, rollZoom]); React.useEffect(() => { if (gridScrollRef.current) { gridScrollRef.current.scrollTop = (127 - 60) * NoteHeight - 150; } }, []); const handleGridMouseDown = (e) => { const canvas = canvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const beat = x / pixelsPerBeat; const pitch = 127 - Math.floor(y / NoteHeight); if (hoveredResizeIdx !== -1) { setDraggedNote({ mode: 'resize', idx: hoveredResizeIdx, originalStart: notes[hoveredResizeIdx].start_beat }); return; } const clickedNoteIdx = notes.findIndex(n => { return pitch === n.pitch && beat >= n.start_beat && beat < n.start_beat + n.duration_beats; }); if (activeRollTool === 'select') { if (clickedNoteIdx !== -1) { setDraggedNote({ mode: 'move', idx: clickedNoteIdx, startOffsetBeat: beat - notes[clickedNoteIdx].start_beat, originalPitch: notes[clickedNoteIdx].pitch }); } else { const start = getSnapBeat(beat, snapVal); const newNote = { id: 'note_' + Date.now() + Math.random().toString(36).substr(2, 5), pitch: pitch, start_beat: start, duration_beats: getSnapDuration(snapVal), velocity: 0.8, pan: 0.0 }; setNotes(prev => [...prev, newNote]); } } else if (activeRollTool === 'eraser') { if (clickedNoteIdx !== -1) { setNotes(prev => prev.filter((_, idx) => idx !== clickedNoteIdx)); } } else if (activeRollTool === 'pen') { if (clickedNoteIdx === -1) { const start = getSnapBeat(beat, snapVal); const newNote = { id: 'note_' + Date.now() + Math.random().toString(36).substr(2, 5), pitch: pitch, start_beat: start, duration_beats: getSnapDuration(snapVal), velocity: 0.8, pan: 0.0 }; setNotes(prev => [...prev, newNote]); } } }; const handleGridMouseMove = (e) => { const canvas = canvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const beat = x / pixelsPerBeat; const pitch = 127 - Math.floor(y / NoteHeight); if (!draggedNote) { // Check if mouse is near the right edge of any note let foundIdx = -1; for (let i = 0; i < notes.length; i++) { const n = notes[i]; if (pitch === n.pitch) { const noteRightBeat = n.start_beat + n.duration_beats; const pixelDist = Math.abs((noteRightBeat - beat) * pixelsPerBeat); if (pixelDist < 6 && beat >= n.start_beat) { foundIdx = i; break; } } } if (foundIdx !== -1) { canvas.style.cursor = 'ew-resize'; setHoveredResizeIdx(foundIdx); } else { canvas.style.cursor = activeRollTool === 'eraser' ? 'pointer' : 'crosshair'; setHoveredResizeIdx(-1); } return; } if (draggedNote.mode === 'resize') { const newDuration = getSnapBeat(Math.max(0.125, beat - draggedNote.originalStart), snapVal); setNotes(prev => prev.map((n, idx) => { if (idx !== draggedNote.idx) return n; return { ...n, duration_beats: newDuration }; })); } else { const newStart = getSnapBeat(Math.max(0, beat - draggedNote.startOffsetBeat), snapVal); const newPitch = Math.max(0, Math.min(127, pitch)); setNotes(prev => prev.map((n, idx) => { if (idx !== draggedNote.idx) return n; return { ...n, start_beat: newStart, pitch: newPitch }; })); } }; const handleGridMouseUp = () => { setDraggedNote(null); }; const handleCCMouseDown = (e) => { const canvas = ccCanvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const h = rect.height; const beat = x / pixelsPerBeat; // Find note near beat or closest note horizontally let noteIdx = notes.findIndex(n => beat >= n.start_beat && beat <= n.start_beat + n.duration_beats); if (noteIdx === -1) { let minDistance = Infinity; notes.forEach((n, idx) => { const center = n.start_beat + n.duration_beats / 2; const dist = Math.abs(center - beat); if (dist < minDistance) { minDistance = dist; noteIdx = idx; } }); } if (noteIdx !== -1) { const val = Math.max(0, Math.min(1, (h - y) / h)); setNotes(prev => prev.map((n, idx) => { if (idx !== noteIdx) return n; if (ccMode === 'pan') { return { ...n, pan: (val - 0.5) * 2.0 }; } else { return { ...n, velocity: val }; } })); } }; const renderKeybed = () => { const keys = []; for (let pitch = 127; pitch >= 0; pitch--) { const isBlack = [1, 3, 6, 8, 10].includes(pitch % 12); const notesArray = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; const octave = Math.floor(pitch / 12) - 1; const label = `${notesArray[pitch % 12]}${octave}`; const showLabel = pitch % 12 === 0; keys.push( /*#__PURE__*/React.createElement("div", { key: pitch, style: { height: `${NoteHeight}px` }, className: `w-[60px] shrink-0 border-b border-zinc-800 text-[9px] font-bold flex items-center justify-end pr-2 transition ${isBlack ? 'bg-zinc-950 text-slate-500 border-zinc-900' : 'bg-white text-zinc-800 border-r border-zinc-400'}` }, showLabel && label) ); } return keys; }; const handleScroll = (e) => { if (keybedRef.current) { keybedRef.current.scrollTop = e.currentTarget.scrollTop; } }; return /*#__PURE__*/React.createElement("div", { className: "flex-1 flex overflow-hidden min-h-0 bg-[#1e1e1e] flex-col" }, /*#__PURE__*/React.createElement("div", { className: "h-10 bg-[#282828] border-b border-zinc-900 flex items-center justify-between px-4 shrink-0 text-slate-200" }, /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-4" }, /*#__PURE__*/React.createElement("span", { className: "text-xs font-bold text-yellow-500 uppercase flex items-center gap-1.5" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "music", className: "w-3.5 h-3.5" }), st.label), /*#__PURE__*/React.createElement("div", { className: "flex bg-zinc-800 p-0.5 rounded border border-zinc-700 text-xs" }, ['select', 'pen', 'eraser'].map(tool => /*#__PURE__*/React.createElement("button", { key: tool, onClick: () => setActiveRollTool(tool), className: `px-2.5 py-1 rounded capitalize ${activeRollTool === tool ? 'bg-yellow-600 text-white font-bold' : 'text-zinc-400 hover:text-zinc-200'}` }, tool))), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1 text-xs" }, /*#__PURE__*/React.createElement("span", { className: "text-zinc-500 font-semibold" }, "Snap:"), /*#__PURE__*/React.createElement("select", { value: snapVal, onChange: e => setSnapVal(e.target.value), className: "bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-1.5 py-0.5 outline-none focus:border-yellow-500" }, ['free', '1', '1/2', '1/4', '1/8', '1/16', '1/32'].map(v => /*#__PURE__*/React.createElement("option", { key: v, value: v }, v)))), /*#__PURE__*/React.createElement("div", { className: "flex bg-zinc-800 p-0.5 rounded border border-zinc-700 text-xs" }, ['velocity', 'pan'].map(mode => /*#__PURE__*/React.createElement("button", { key: mode, onClick: () => setCcMode(mode), className: `px-2.5 py-1 rounded capitalize ${ccMode === mode ? 'bg-purple-900/60 text-purple-300 font-bold border border-purple-700' : 'text-zinc-400 hover:text-zinc-200'}` }, mode)))), /*#__PURE__*/React.createElement("button", { onClick: onClose, className: "px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 border border-zinc-700 rounded text-xs flex items-center gap-1 transition" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "x", className: "w-3 h-3" }), "Đóng")), /*#__PURE__*/React.createElement("div", { className: "flex-1 flex overflow-hidden min-h-0 relative" }, /*#__PURE__*/React.createElement("div", { ref: keybedRef, className: "w-[60px] overflow-hidden flex flex-col border-r border-zinc-900 shrink-0", style: { scrollbarWidth: 'none', msOverflowStyle: 'none' } }, renderKeybed()), /*#__PURE__*/React.createElement("div", { ref: gridScrollRef, onScroll: handleScroll, className: "flex-1 overflow-auto bg-[#141414] min-w-0" }, /*#__PURE__*/React.createElement("div", { style: { width: `${drawWidth}px`, height: `${128 * NoteHeight}px` }, className: "relative" }, /*#__PURE__*/React.createElement("canvas", { ref: canvasRef, onMouseDown: handleGridMouseDown, onMouseMove: handleGridMouseMove, onMouseUp: handleGridMouseUp, onMouseLeave: handleGridMouseUp, className: "absolute inset-0 cursor-crosshair" })))), /*#__PURE__*/React.createElement("div", { className: "h-20 bg-[#161616] border-t border-zinc-900 flex shrink-0" }, /*#__PURE__*/React.createElement("div", { className: "w-[60px] bg-[#222222] border-r border-zinc-900 flex items-center justify-center text-[10px] text-zinc-500 font-mono shrink-0 font-bold" }, ccMode.toUpperCase()), /*#__PURE__*/React.createElement("div", { className: "flex-1 overflow-x-hidden min-w-0" }, /*#__PURE__*/React.createElement("div", { style: { width: `${drawWidth}px`, height: '100%' }, className: "relative" }, /*#__PURE__*/React.createElement("canvas", { ref: ccCanvasRef, onMouseDown: handleCCMouseDown, className: "absolute inset-0 cursor-ns-resize" }))))); }; const serializeProjectToSchema = (projectId, name, bpmVal, tracksList, subTabsList, sessionTabsList) => { const secondsPerBar = (60.0 / parseFloat(bpmVal || 120)) * 4; const mainTracks = (tracksList || []).map(t => { let trackType = "AUDIO"; if (t.sections && t.sections.length > 0) trackType = "SECTION"; else if (t.midiItems && t.midiItems.length > 0) trackType = "MIDI"; const items = []; if (trackType === "AUDIO" && t.clips) { t.clips.forEach(c => { const durationSec = c.buffer ? c.buffer.duration : 4.0; items.push({ id: c.id, name: c.name || "Audio Clip", type: "AUDIO_ITEM", start_bar: c.startTime / secondsPerBar, duration_bars: (durationSec / (c.speed || 1.0)) / secondsPerBar, clip_start_offset_bars: 0.0, source_data: { audio_file_url: t.serverFileId ? `/static/audio/uploads/${t.serverFileId}` : "", sample_rate: c.buffer ? c.buffer.sampleRate : 44100, channels: c.buffer ? c.buffer.numberOfChannels : 2, gain: 1.0 } }); }); } else if (trackType === "MIDI" && t.midiItems) { t.midiItems.forEach(m => { items.push({ id: m.id, name: m.name || "MIDI Item", type: "MIDI_ITEM", start_bar: m.startTime / secondsPerBar, duration_bars: m.duration || 4.0, clip_start_offset_bars: 0.0, source_data: { total_buffer_bars: m.duration || 8.0, notes: (m.notes || []).map(n => ({ id: n.id || 'note_' + Math.random().toString(36).substr(2, 9), pitch: n.pitch || 60, start_beat: n.start_beat || 0.0, duration_beats: n.duration_beats || 1.0, velocity: n.velocity || 0.8, pan: n.pan || 0.0 })) } }); }); } else if (trackType === "SECTION" && t.sections) { t.sections.forEach(s => { items.push({ id: s.id, name: s.name || "Section Item", type: "SECTION_ITEM", start_bar: s.start / secondsPerBar, duration_bars: s.duration / secondsPerBar, clip_start_offset_bars: 0.0, source_data: { referenced_section_id: s.sectionId || s.id } }); }); } return { id: t.id, name: t.name, type: trackType, volume_db: t.volumeDb || 0.0, pan: t.pan || 0.0, mute: t.muted || false, solo: t.solo || false, items: items }; }); const sectionStore = {}; (sessionTabsList || []).forEach(st => { const secTracks = (st.tracks || []).map(t => { let trackType = "AUDIO"; if (t.sections && t.sections.length > 0) trackType = "SECTION"; else if (t.midiItems && t.midiItems.length > 0) trackType = "MIDI"; return { id: t.id, name: t.name, type: trackType, volume_db: t.volumeDb || 0.0, pan: t.pan || 0.0, mute: t.muted || false, solo: t.solo || false, items: [] }; }); sectionStore[st.sectionId] = { id: st.sectionId, name: st.name, is_root: false, length_bars: st.length_bars || 16.0, auto_compute_length: st.auto_compute_length !== undefined ? st.auto_compute_length : true, tracks: secTracks }; }); return { project_id: projectId || 'proj_' + Date.now(), metadata: { title: name || "Dự án mới", bpm: parseFloat(bpmVal || 120), time_signature_numerator: 4, time_signature_denominator: 4, sample_rate: 44100 }, main_session: { id: "main", name: "MAIN SESSION", is_root: true, length_bars: 16.0, auto_compute_length: true, tracks: mainTracks }, section_store: sectionStore }; }; const deserializeProjectFromSchema = (schemaObj) => { const bpmVal = schemaObj.metadata ? schemaObj.metadata.bpm : 120; const secondsPerBar = (60.0 / bpmVal) * 4; const restoredTracks = (schemaObj.main_session.tracks || []).map(t => { const clips = []; const sections = []; const midiItems = []; (t.items || []).forEach(item => { if (item.type === "AUDIO_ITEM") { const src = item.source_data || {}; const audioUrl = src.audio_file_url || ""; const serverFileId = audioUrl.split("/").pop() || null; clips.push({ id: item.id, name: item.name, startTime: item.start_bar * secondsPerBar, speed: 1.0 }); } else if (item.type === "MIDI_ITEM") { const src = item.source_data || {}; midiItems.push({ id: item.id, name: item.name, startTime: item.start_bar * secondsPerBar, duration: item.duration_bars, notes: src.notes || [] }); } else if (item.type === "SECTION_ITEM") { const src = item.source_data || {}; sections.push({ id: item.id, name: item.name, start: item.start_bar * secondsPerBar, duration: item.duration_bars * secondsPerBar, sectionId: src.referenced_section_id }); } }); return { id: t.id, name: t.name, volumeDb: t.volume_db || 0.0, pan: t.pan || 0.0, muted: t.mute || false, solo: t.solo || false, color: t.id === '1' ? '#0f766e' : '#1d4ed8', markers: [], serverFileId: t.items && t.items.find(i => i.type === "AUDIO_ITEM")?.source_data?.audio_file_url?.split("/").pop() || null, clips: clips, sections: sections, midiItems: midiItems }; }); const restoredSessionTabs = []; const sectionStore = schemaObj.section_store || {}; Object.keys(sectionStore).forEach(secId => { const secContainer = sectionStore[secId]; const secTracks = (secContainer.tracks || []).map(t => { return { id: t.id, name: t.name, volumeDb: t.volume_db || 0.0, pan: t.pan || 0.0, muted: t.mute || false, solo: t.solo || false, clips: [], sections: [], midiItems: [] }; }); restoredSessionTabs.push({ id: 'session_' + secId, name: secContainer.name, sectionId: secId, tracks: secTracks, length_bars: secContainer.length_bars || 16.0, auto_compute_length: secContainer.auto_compute_length }); }); return { bpm: bpmVal, tracks: restoredTracks, sessionTabs: restoredSessionTabs }; }; const App = () => { // ── State Definitions ── const [tracks, setTracks] = useState([{ id: '1', name: 'Track 01', buffer: null, startTime: 0, volumeDb: 0, pan: 0, muted: false, solo: false, color: '#0f766e', markers: [], serverFileId: null, clips: [], sections: [], midiItems: [], isArmed: false, monitoringEnabled: true, inputSource: { deviceType: 'NONE', deviceId: '' } }, { id: '2', name: 'Track 02', buffer: null, startTime: 0, volumeDb: 0, pan: 0, muted: false, solo: false, color: '#1d4ed8', markers: [], serverFileId: null, clips: [], sections: [], midiItems: [], isArmed: false, monitoringEnabled: true, inputSource: { deviceType: 'NONE', deviceId: '' } }]); const [appWarningModal, setAppWarningModal] = useState(null); const [bpm, setBpm] = useState(localStorage.getItem('studio_bpm') || '120'); const [draggedClip, setDraggedClip] = useState(null); // { trackId, clickOffset, buffer, name, volume, color } const [hoveredTrackId, setHoveredTrackId] = useState(null); const openPanel = id => { if (id === 'export') setShowExportPanel(true);else if (id === 'ai') setShowAIPanel(true);else if (id === 'python_tools') setShowPythonToolsPanel(true);else if (id === 'selection') setShowSelectionPanel(true); }; const [activeTool, setActiveTool] = useState('select'); // 'select' | 'grab' | 'razor' const [snapValue, setSnapValue] = useState('free'); // 'free', '1', '1/2', '1/4', '1/8', '1/16', '1/32' const snapTime = (time, snapVal, bpmVal) => { if (snapVal === 'free') return time; const beatDuration = 60 / parseFloat(bpmVal || 120); let divisor = 1; if (snapVal === '1') divisor = 1;else if (snapVal === '1/2') divisor = 0.5;else if (snapVal === '1/4') divisor = 0.25;else if (snapVal === '1/8') divisor = 0.125;else if (snapVal === '1/16') divisor = 0.0625;else if (snapVal === '1/32') divisor = 0.03125; const gridSpacing = beatDuration * divisor; return Math.round(time / gridSpacing) * gridSpacing; }; const snapValueRef = useRef(snapValue); snapValueRef.current = snapValue; const bpmRef = useRef(bpm); bpmRef.current = bpm; // BMP for Tempo Track - LOOP_EDITOR_2.md §6 const [selectedTrackId, setSelectedTrackId] = useState('1'); const [currentTime, setCurrentTime] = useState(0); const [isPlaying, setIsPlaying] = useState(false); const [selectionStart, setSelectionStart] = useState(null); const [selectionEnd, setSelectionEnd] = useState(null); const 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 [audioDevices, setAudioDevices] = useState([]); const [midiDevices, setMidiDevices] = useState([]); useEffect(() => { if (navigator.mediaDevices && navigator.mediaDevices.enumerateDevices) { navigator.mediaDevices.enumerateDevices().then(devices => { setAudioDevices(devices.filter(d => d.kind === 'audioinput')); }).catch(err => console.log('Enumerate audio devices error:', err)); } if (navigator.requestMIDIAccess) { navigator.requestMIDIAccess().then(access => { const inputs = []; for (let input of access.inputs.values()) { inputs.push(input); input.onmidimessage = msg => { if (msg.data.length < 3) return; const cmd = msg.data[0] >> 4; const pitch = msg.data[1]; const velocity = msg.data[2]; if (cmd === 0x9 && velocity > 0) { lastMidiNoteRef.current = { pitch, velocity, startTime: performance.now(), length: 0 }; setLastMidiNote({ pitch, velocity, length: 0, time: Date.now() }); } else if (cmd === 0x8 || (cmd === 0x9 && velocity === 0)) { const current = lastMidiNoteRef.current; if (current && current.pitch === pitch) { const lenSec = (performance.now() - current.startTime) / 1000; lastMidiNoteRef.current = { ...current, length: lenSec }; setLastMidiNote(prev => prev && prev.pitch === pitch ? { ...prev, length: lenSec, time: Date.now() } : prev); } } }; } setMidiDevices(inputs); access.onstatechange = () => { const inputs = []; for (let input of access.inputs.values()) { inputs.push(input); } setMidiDevices(inputs); }; }).catch(err => console.log('MIDI access error:', err)); } }, []); const [showAIConfig, setShowAIConfig] = useState(false); const [recordingState, setRecordingState] = useState('IDLE'); // 'IDLE' | 'COUNT_IN' | 'RECORDING' const [recTempMidiNotes, setRecTempMidiNotes] = useState([]); const [recTempAudioBuffer, setRecTempAudioBuffer] = useState(null); const [recStartTimelineTime, setRecStartTimelineTime] = useState(0); const [canvasRedrawCount, setCanvasRedrawCount] = useState(0); const [lastMidiNote, setLastMidiNote] = useState(null); const lastMidiNoteRef = useRef(null); const activeMIDIRecordersRef = useRef({}); const activeAudioRecordersRef = useRef({}); const recordingPCMDataRef = useRef({}); const recordingStartTimeRef = useRef(0); const recordingSyncRef = useRef(null); const recordingStateRef = useRef(recordingState); recordingStateRef.current = recordingState; const lastTempCompileTimeRef = useRef(0); const nextMetronomeBeatRef = useRef(0); 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 trackVuRefs = useRef({}); 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 activeTracksRef = useRef([]); activeTracksRef.current = activeTracks; const sessionTabsRef = useRef(sessionTabs); sessionTabsRef.current = sessionTabs; 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 { let restoredTracks = []; let restoredBpm = bpm; let restoredSessionTabs = []; if (proj.main_session) { const result = deserializeProjectFromSchema(proj); restoredTracks = result.tracks; restoredBpm = result.bpm; restoredSessionTabs = result.sessionTabs; } else { restoredTracks = (proj.tracks || []).map(t => { const { height: _h, ...rest } = t; return { ...rest, buffer: null, channelInfo: t.channelInfo || null, clips: t.clips || [], serverFileId: t.serverFileId || null }; }); } if (restoredTracks.length > 0) { setTracks(restoredTracks); setBpm(restoredBpm.toString()); if (restoredSessionTabs.length > 0) { setSessionTabs(restoredSessionTabs); } showToast(`Đã tải dự án "${proj.metadata?.title || proj.name || 'Dự án mới'}" 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(() => { return serializeProjectToSchema(currentProjectId || 'temp_project', projectName || 'Dự án tạm chưa lưu', bpm, tracks, subTabs, sessionTabs); }); }, [tracks, subTabs, sessionTabs]); // Lucide icons initialization useEffect(() => { setTimeout(() => { if (window.lucide) { window.lucide.createIcons(); } }, 50); }, [activeTool, activeTab]); const timelineWrapperRef = useRef(null); const [timelineWrapperNode, setTimelineWrapperNode] = useState(null); const handleTimelineWrapperRef = useCallback(node => { timelineWrapperRef.current = node; setTimelineWrapperNode(node); }, []); 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, volumeDb: 0, pan: 0, muted: false, solo: false, color: '#0f766e', markers: [], serverFileId: null }, { id: '2', name: 'Track 02', buffer: null, startTime: 0, volumeDb: 0, pan: 0, muted: false, solo: false, color: '#1d4ed8', markers: [], serverFileId: null }]); setSelectedTrackId('1'); showToast('New project created', 'info'); return; } if (ctrl && !alt && e.key === 's') { e.preventDefault(); handleExportSFS(); return; } if (ctrl && alt && e.key === 's' || ctrl && e.shiftKey && e.key === 's') { e.preventDefault(); handleExportSFS(); return; } if (ctrl && !alt && e.key === 'i') { e.preventDefault(); addNewTrack(); return; } if (ctrl && alt && e.key === 'i') { e.preventDefault(); showToast('Import audio', 'info'); return; } if (ctrl && !alt && e.key === 'e') { e.preventDefault(); openTempTab(); return; } if (ctrl && !alt && e.key === 'm') { e.preventDefault(); handleMergeTracks(); return; } if (ctrl && !alt && e.key === 'c') { e.preventDefault(); handleCopyTrack(); return; } if (ctrl && !alt && e.key === 'x') { e.preventDefault(); handleCutTrack(); return; } if (ctrl && !alt && e.key === 'v') { e.preventDefault(); handlePasteTrack(); return; } if (e.key === 'Delete' || e.key === 'Backspace' || e.key === 'Del') { const selClip = selectedClipIdRef.current; if (selClip) { e.preventDefault(); const { trackId, clipId } = selClip; setTracks(prev => { const track = prev.find(t => t.id === trackId); if (!track) return prev; const beforeSnap = captureTrackSnapshotRef.current ? captureTrackSnapshotRef.current(trackId) : null; const updatedClips = (track.clips || []).filter(c => c.id !== clipId); const updatedTracks = prev.map(t => { if (t.id === trackId) { return { ...t, clips: updatedClips, buffer: updatedClips.length > 0 ? updatedClips[0].buffer : null, startTime: updatedClips.length > 0 ? updatedClips[0].startTime : 0, name: updatedClips.length > 0 ? updatedClips[0].name : `Track ${t.id}` }; } return t; }); setTimeout(() => { const afterSnap = captureTrackSnapshotRef.current ? captureTrackSnapshotRef.current(trackId) : null; pushAction('DELETE_CLIP', trackId, beforeSnap, afterSnap); }, 50); return updatedTracks; }); setSelectedClipId(null); showToast('Đã xóa clip.', 'info'); return; } else { e.preventDefault(); handleDeleteTrack(); return; } } if (ctrl && !alt && e.key === 's') { e.preventDefault(); 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 = activeTracks.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); }; const handleEditMidiInTab = (trackId, midiItemId) => { const track = activeTracks.find(t => t.id === trackId); if (!track) return; const midiItem = (track.midiItems || []).find(m => m.id === midiItemId); if (!midiItem) return; const existing = subTabs.find(s => s.type === 'PIANO_ROLL' && s.target_id === midiItemId); if (existing) { setActiveTab(existing.id); showToast('Piano Roll cho nốt MIDI đã được mở.', 'info'); return; } const tabId = 'midi_' + Date.now(); const tabLabel = `Piano Roll: ${midiItem.name || 'MIDI'}`; const newTab = { id: tabId, label: tabLabel, type: 'PIANO_ROLL', trackId: trackId, target_id: midiItemId, parent_tab_id: activeTab === 'main' ? null : activeTab, notes: midiItem.notes || [], duration: midiItem.duration || 4, viewport_start_bar: 0.0, viewport_bar_width: 8.0, scroll_y_pitch: 60, snap_resolution: snapValue || "1/16", note_selection: [] }; setSubTabs(prev => [...prev, newTab]); setActiveTab(tabId); }; const handleUpdateMidiNotes = (tabId, trackId, midiItemId, updatedNotes) => { setSubTabs(prev => prev.map(s => s.id === tabId ? { ...s, notes: updatedNotes } : s)); const isSectionTrack = sessionTabs.some(s => s.tracks.some(t => t.id === trackId)); if (isSectionTrack) { setSessionTabs(prev => prev.map(s => { if (!s.tracks.some(t => t.id === trackId)) return s; return { ...s, tracks: s.tracks.map(t => { if (t.id !== trackId) return t; return { ...t, midiItems: (t.midiItems || []).map(m => { if (m.id !== midiItemId) return m; return { ...m, notes: updatedNotes }; }) }; }) }; })); } else { setTracks(prev => prev.map(t => { if (t.id !== trackId) return t; return { ...t, midiItems: (t.midiItems || []).map(m => { if (m.id !== midiItemId) return m; return { ...m, notes: updatedNotes }; }) }; })); } }; // ── 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 = activeTracks.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); } const isSectionTrack = sessionTabs.some(s => s.tracks.some(t => t.id === subTab.trackId)); if (isSectionTrack) { if (subTab.clipId) { setSessionTabs(prev => prev.map(s => { if (!s.tracks.some(t => t.id === subTab.trackId)) return s; return { ...s, tracks: s.tracks.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; } setSessionTabs(prev => prev.map(s => { if (!s.tracks.some(t => t.id === subTab.trackId)) return s; return { ...s, tracks: s.tracks.map(t => { if (t.id !== subTab.trackId) return t; return { ...t, buffer: mergedBuffer, name: t.name + ' (edited)' }; }) }; })); } } else { 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 => { const targetTab = sessionTabs.find(s => s.id === tabId); if (!targetTab) return; const activeChildTabs = subTabs.filter(s => s.parent_tab_id === tabId); if (activeChildTabs.length > 0) { setAppWarningModal({ title: "Không thể đóng Tab Section", message: `Vui lòng đóng các Tab con biên tập trước khi đóng Tab Section này:\n\n` + activeChildTabs.map(t => `• ${t.label || t.name}`).join("\n"), isAlert: true }); return; } 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 = activeTracks.find(t => t.id === contextMenu.trackId); if (track) setSelectedTrackId(contextMenu.trackId); closeContextMenu(); if (!track) return; const secondsPerBar = (60.0 / (parseInt(bpm) || 120)) * 4; // Check MIDI items first const midiItems = track.midiItems || []; const clickedMidi = midiItems.find(m => contextMenu.time >= m.startTime && contextMenu.time < m.startTime + (m.duration || 4) * secondsPerBar); if (clickedMidi) { handleEditMidiInTab(contextMenu.trackId, clickedMidi.id); return; } // Check Audio clips next 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 track = activeTracks.find(t => t.id === tid); if (!track) { closeContextMenu(); return; } const secondsPerBeat = 60.0 / (parseInt(bpm) || 120); const secondsPerBar = secondsPerBeat * 4; const time = contextMenu.time; // Check for Section item under cursor const secList = track.sections || []; const clickedSec = secList.find(s => time >= s.start && time < s.start + s.duration); // Check for MIDI item under cursor const midiItems = track.midiItems || []; const clickedMidi = midiItems.find(m => time >= m.startTime && time < m.startTime + (m.duration || 4) * secondsPerBar); // Check for Audio clip under cursor const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{ id: 'default', buffer: track.buffer, startTime: track.startTime || 0, name: track.name, speed: track.speed || 1.0 }] : []; const clickedClip = clips.find(c => time >= c.startTime && time < c.startTime + c.buffer.duration / (c.speed || 1.0)); if (clickedSec) { const beforeSnap = captureTrackSnapshot(tid); updateActiveTracks(prev => prev.map(t => { if (t.id !== tid) return t; return { ...t, sections: (t.sections || []).filter(s => s.id !== clickedSec.id) }; })); const afterSnap = captureTrackSnapshot(tid); pushAction('DELETE_SECTION', tid, beforeSnap, afterSnap); closeContextMenu(); showToast('Đã xoá section item.', 'info'); return; } if (clickedMidi) { const beforeSnap = captureTrackSnapshot(tid); updateActiveTracks(prev => prev.map(t => { if (t.id !== tid) return t; return { ...t, midiItems: (t.midiItems || []).filter(m => m.id !== clickedMidi.id) }; })); const afterSnap = captureTrackSnapshot(tid); pushAction('DELETE_MIDI', tid, beforeSnap, afterSnap); closeContextMenu(); showToast('Đã xoá MIDI item.', 'info'); return; } if (clickedClip) { const beforeSnap = captureTrackSnapshot(tid); updateActiveTracks(prev => prev.map(t => { if (t.id !== tid) return t; const updatedClips = (t.clips || []).filter(c => c.id !== clickedClip.id); return { ...t, clips: updatedClips, buffer: updatedClips[0]?.buffer || null, startTime: updatedClips[0]?.startTime || 0, name: updatedClips[0]?.name || t.name }; })); const afterSnap = captureTrackSnapshot(tid); pushAction('DELETE_CLIP', tid, beforeSnap, afterSnap); closeContextMenu(); showToast('Đã xoá audio clip.', 'info'); return; } // No item clicked under cursor -> Attempt to delete the track itself const hasClips = (track.clips && track.clips.length > 0) || !!track.buffer; const hasMidi = track.midiItems && track.midiItems.length > 0; const hasSections = track.sections && track.sections.length > 0; const isTrackEmpty = !hasClips && !hasMidi && !hasSections; if (!isTrackEmpty) { closeContextMenu(); setAppWarningModal({ title: 'Không thể xoá Track', message: 'Không thể xoá track chứa dữ liệu. Vui lòng xoá hết các item trước khi xoá track.', isAlert: true }); return; } const sessionTab = sessionTabs.find(s => s.id === activeTab); if (sessionTab) { updateActiveTracks(prev => { const filtered = prev.filter(t => t.id !== tid); if (filtered.length > 0) setSelectedTrackId(filtered[0].id); return filtered; }); closeContextMenu(); showToast('Đã xoá track.', 'info'); } else { const beforeSnap = captureTrackSnapshot(tid); setTracks(prev => { const filtered = prev.filter(t => t.id !== tid); if (filtered.length > 0) setSelectedTrackId(filtered[0].id || '1'); return filtered; }); 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 = activeTracks.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, 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(() => { if (!timelineWrapperNode) return; const observer = new ResizeObserver(entries => { for (let entry of entries) { setViewportWidth(entry.contentRect.width); } }); observer.observe(timelineWrapperNode); return () => observer.disconnect(); }, [timelineWrapperNode]); 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); } }); (t.midiItems || []).forEach(m => { max = Math.max(max, m.startTime + (m.duration || 4)); }); }); if (recordingState === 'RECORDING' || recordingState === 'COUNT_IN') { max = Math.max(max, currentTime + 60); } return max; }, [tracks, recordingState, currentTime]); 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 playMetronomeClick = (time, isDownbeat = false) => { try { const ctx = getAudioContext(); const osc = ctx.createOscillator(); const gainNode = ctx.createGain(); osc.connect(gainNode); gainNode.connect(ctx.destination); osc.frequency.setValueAtTime(isDownbeat ? 1000 : 800, time); gainNode.gain.setValueAtTime(0.08, time); gainNode.gain.exponentialRampToValueAtTime(0.001, time + 0.08); osc.start(time); osc.stop(time + 0.1); } catch (e) { console.log('Metronome click play error:', e); } }; const updatePlayhead = () => { if (recordingStateRef.current === 'RECORDING') { const audioCtx = getAudioContext(); const lookahead = 0.1; // 100ms const secondsPerBeat = 60.0 / (parseInt(bpmRef.current) || 120); // Metronome Click Scheduler while (true) { const beatNum = nextMetronomeBeatRef.current; const elapsedBeats = beatNum - (recordingStartTimeRef.current / secondsPerBeat); const beatTime = startAudioTimeRef.current + elapsedBeats * secondsPerBeat; if (beatTime < audioCtx.currentTime + lookahead) { const isDownbeat = (beatNum % 4 === 0); playMetronomeClick(beatTime, isDownbeat); nextMetronomeBeatRef.current++; } else { break; } } // Throttled Live Preview compilation (every 200ms) const now = Date.now(); if (now - lastTempCompileTimeRef.current > 200) { lastTempCompileTimeRef.current = now; // Audio preview for (let trackId in recordingPCMDataRef.current) { const data = recordingPCMDataRef.current[trackId]; if (data && data.length > 0) { const tempBuf = audioCtx.createBuffer(1, data.length, audioCtx.sampleRate); tempBuf.getChannelData(0).set(data); setRecTempAudioBuffer(tempBuf); } } // MIDI preview const armedTracks = activeTracksRef.current.filter(t => t.isArmed); for (let track of armedTracks) { const midiRec = activeMIDIRecordersRef.current[track.id]; if (midiRec) { const currentBeat = (audioCtx.currentTime - midiRec.recStartAudioTime - midiRec.latencyCompSec) / secondsPerBeat + (midiRec.recStartBar * 4); setRecTempMidiNotes([...midiRec.recordedNotes, ...Array.from(midiRec.activeNotes.values()).map(n => ({ ...n, duration_beats: currentBeat - n.start_beat }))]); } } } } 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 (recordingStateRef.current === 'RECORDING') { setCurrentTime(updatedTime); animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); return; } 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 = () => { if (recordingStateRef.current === 'RECORDING' || recordingStateRef.current === 'COUNT_IN') { stopRecordingTake(); return; } stopAllPlayback(); if (activeTab !== 'main') { setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, currentTime: 0 } : s)); } else { setCurrentTime(0); } }; const drawVuMeter = (canvas, db) => { if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const w = canvas.width; const h = canvas.height; ctx.clearRect(0, 0, w, h); const minDb = -60; const maxDb = 0; const frac = Math.max(0, Math.min(1, (db - minDb) / (maxDb - minDb))); ctx.fillStyle = '#18181b'; ctx.fillRect(0, 0, w, h); const grad = ctx.createLinearGradient(0, 0, w, 0); grad.addColorStop(0, '#10b981'); grad.addColorStop(0.7, '#eab308'); grad.addColorStop(0.95, '#ef4444'); ctx.fillStyle = grad; ctx.fillRect(0, 0, w * frac, h); if (db >= -0.5) { ctx.fillStyle = '#ff0000'; ctx.fillRect(w - 6, 0, 6, h); } }; const handleRecordClick = async () => { if (recordingState === 'RECORDING' || recordingState === 'COUNT_IN') { await stopRecordingTake(); return; } const armed = activeTracks.filter(t => t.isArmed && t.inputSource?.deviceType && t.inputSource.deviceType !== 'NONE'); if (armed.length === 0) { showToast('Vui lòng Arm (R) ít nhất một track và chọn cổng Input để ghi âm.', 'warning'); return; } setRecordingState('COUNT_IN'); const secondsPerBeat = 60.0 / (parseInt(bpm) || 120); const countInDuration = secondsPerBeat * 4; const audioCtx = getAudioContext(); const now = audioCtx.currentTime; showToast('Metronome Count-in: 4... 3... 2... 1...', 'info'); for (let i = 0; i < 4; i++) { playMetronomeClick(now + i * secondsPerBeat, i === 0); } setTimeout(() => { startRecordingTake(armed); }, countInDuration * 1000); }; const startRecordingTake = async (armedTracks) => { const context = getAudioContext(); if (context.state === 'suspended') { await context.resume(); } setRecordingState('RECORDING'); setRecTempMidiNotes([]); setRecTempAudioBuffer(null); const startTimelineTime = currentTime; setRecStartTimelineTime(startTimelineTime); recordingStartTimeRef.current = startTimelineTime; const secondsPerBeat = 60.0 / (parseInt(bpm) || 120); const startBeat = startTimelineTime / secondsPerBeat; nextMetronomeBeatRef.current = Math.ceil(startBeat); activeMIDIRecordersRef.current = {}; activeAudioRecordersRef.current = {}; recordingPCMDataRef.current = {}; startOffsetTimeRef.current = startTimelineTime; startAudioTimeRef.current = context.currentTime; startTrackPlayback(startTimelineTime); setIsPlaying(true); let midiRecList = []; for (let track of armedTracks) { if (track.inputSource.deviceType === 'MIDI_KEYBOARD') { const midiRec = new ClientMIDIRecorder(context, parseInt(bpm) || 120, 4); const tempMidiItemId = 'midi_rec_' + Date.now() + '_' + track.id; midiRec.tempMidiItemId = tempMidiItemId; updateActiveTracks(prev => prev.map(t => { if (t.id !== track.id) return t; return { ...t, midiItems: [...(t.midiItems || []), { id: tempMidiItemId, name: 'Recording...', startTime: startTimelineTime, duration: 4, notes: [] }] }; })); midiRec.onNoteOn = (pitch, currentBeat) => { const canvas = trackVuRefs.current[track.id]; if (canvas) { drawVuMeter(canvas, 0); setTimeout(() => drawVuMeter(canvas, -60), 100); } const elapsedBeats = Math.max(0, currentBeat - midiRec.recStartBar * 4); const durBars = Math.max(1, Math.ceil(elapsedBeats / 4) + 1); const allNotes = [...midiRec.recordedNotes, ...Array.from(midiRec.activeNotes.values()).map(n => ({ ...n, duration_beats: currentBeat - n.start_beat }))]; setRecTempMidiNotes(allNotes); setCanvasRedrawCount(n => n + 1); if (midiRec.tempMidiItemId) { updateActiveTracks(prev => prev.map(t => { if (t.id !== track.id) return t; return { ...t, midiItems: t.midiItems.map(m => m.id === midiRec.tempMidiItemId ? { ...m, notes: allNotes, duration: durBars } : m) }; })); } }; midiRec.onNoteOff = () => { const currentBeat = (context.currentTime - midiRec.recStartAudioTime - midiRec.latencyCompSec) / (60.0 / midiRec.bpm) + (midiRec.recStartBar * 4); const elapsedBeats = Math.max(0, currentBeat - midiRec.recStartBar * 4); const durBars = Math.max(1, Math.ceil(elapsedBeats / 4) + 1); const activeNotesArray = Array.from(midiRec.activeNotes.values()).map(n => ({ ...n, duration_beats: currentBeat - n.start_beat })); const allNotes = [...midiRec.recordedNotes, ...activeNotesArray]; setRecTempMidiNotes(allNotes); setCanvasRedrawCount(n => n + 1); if (midiRec.tempMidiItemId) { updateActiveTracks(prev => prev.map(t => { if (t.id !== track.id) return t; return { ...t, midiItems: t.midiItems.map(m => m.id === midiRec.tempMidiItemId ? { ...m, notes: allNotes, duration: durBars } : m) }; })); } }; midiRec.start(startTimelineTime / (secondsPerBeat * 4), track.inputSource.deviceId); activeMIDIRecordersRef.current[track.id] = midiRec; midiRecList.push({ trackId: track.id, midiRec }); } else if (track.inputSource.deviceType === 'MICROPHONE') { const audioRec = new ClientAudioRecorder(context); try { await audioRec.initializeInput(track.inputSource.deviceId); recordingPCMDataRef.current[track.id] = []; audioRec.onLevelUpdate = (db) => { const canvas = trackVuRefs.current[track.id]; if (canvas) { drawVuMeter(canvas, db); } }; audioRec.onPCMChunk = (chunk) => { if (recordingPCMDataRef.current[track.id]) { const currentData = recordingPCMDataRef.current[track.id]; const newData = new Float32Array(currentData.length + chunk.length); newData.set(currentData); newData.set(chunk, currentData.length); recordingPCMDataRef.current[track.id] = newData; } }; const monitorGain = track.monitoringEnabled ? context.destination : null; await audioRec.start(monitorGain, track.monitoringEnabled); activeAudioRecordersRef.current[track.id] = audioRec; } catch (err) { console.error('Failed to initialize microphone:', err); showToast('Không khởi động được micro: ' + err.message, 'warning'); } } } recordingSyncRef.current = setInterval(() => { const secondsPerBeatInt = 60.0 / (parseInt(bpm) || 120); for (let { trackId, midiRec } of midiRecList) { if (!midiRec.isRecording || !midiRec.tempMidiItemId) continue; const currentTimeSec = Math.max(0, getAudioContext().currentTime - midiRec.recStartAudioTime - midiRec.latencyCompSec); const currentBeat = currentTimeSec / secondsPerBeatInt + midiRec.recStartBar * 4; const allNotes = [...midiRec.recordedNotes, ...Array.from(midiRec.activeNotes.values()).map(n => ({ ...n, duration_beats: currentBeat - n.start_beat }))]; const elapsedBeats = Math.max(0, currentBeat - midiRec.recStartBar * 4); const durBars = Math.max(1, Math.ceil(elapsedBeats / 4) + 1); setRecTempMidiNotes(allNotes); updateActiveTracks(prev => prev.map(t => { if (t.id !== trackId) return t; return { ...t, midiItems: t.midiItems.map(m => m.id === midiRec.tempMidiItemId ? { ...m, notes: allNotes, duration: durBars } : m) }; })); } }, 100); showToast('Đang ghi âm...', 'info'); }; const stopRecordingTake = async () => { setRecordingState('IDLE'); stopAllPlayback(); const context = getAudioContext(); const secondsPerBeat = 60.0 / (parseInt(bpm) || 120); const midiRecorders = activeMIDIRecordersRef.current; const audioRecorders = activeAudioRecordersRef.current; Object.keys(trackVuRefs.current).forEach(tid => { const canvas = trackVuRefs.current[tid]; if (canvas) drawVuMeter(canvas, -60); }); let hasRecordedAnything = false; for (let trackId in midiRecorders) { const midiRec = midiRecorders[trackId]; const recordedNotes = midiRec.stop(); if (midiRec.tempMidiItemId) { const recCurrentTimeSec = Math.max(0, context.currentTime - midiRec.recStartAudioTime - midiRec.latencyCompSec); const recElapsedBeats = recCurrentTimeSec / (60.0 / midiRec.bpm); const totalDurationBeats = Math.max(4.0, recordedNotes.length > 0 ? Math.max(recElapsedBeats, ...recordedNotes.map(n => n.start_beat + n.duration_beats)) : recElapsedBeats); updateActiveTracks(prev => prev.map(t => { if (t.id !== trackId) return t; const itemIndex = (t.midiItems || []).findIndex(m => m.id === midiRec.tempMidiItemId); if (itemIndex >= 0) { const updatedItems = [...t.midiItems]; updatedItems[itemIndex] = { ...updatedItems[itemIndex], name: recordedNotes.length > 0 ? 'Recorded MIDI' : 'Empty MIDI', notes: recordedNotes, duration: Math.ceil(totalDurationBeats / 4) }; return { ...t, midiItems: updatedItems }; } const newMidiItem = { id: 'midi_rec_' + Date.now(), name: 'Recorded MIDI', startTime: recordingStartTimeRef.current, duration: Math.ceil(totalDurationBeats / 4), notes: recordedNotes }; return { ...t, midiItems: [...(t.midiItems || []), newMidiItem] }; })); if (recordedNotes.length > 0) { hasRecordedAnything = true; } } else if (recordedNotes.length > 0) { hasRecordedAnything = true; const totalDurationBeats = Math.max(4.0, ...recordedNotes.map(n => n.start_beat + n.duration_beats)); const newMidiItem = { id: 'midi_rec_' + Date.now(), name: 'Recorded MIDI', startTime: recordingStartTimeRef.current, duration: Math.ceil(totalDurationBeats / 4), notes: recordedNotes }; updateActiveTracks(prev => prev.map(t => { if (t.id !== trackId) return t; return { ...t, midiItems: [...(t.midiItems || []), newMidiItem] }; })); } } for (let trackId in audioRecorders) { const audioRec = audioRecorders[trackId]; const audioBuffer = await audioRec.stop(); if (audioBuffer && audioBuffer.duration > 0.05) { hasRecordedAnything = true; const newClip = { id: 'clip_rec_' + Date.now(), name: 'Recorded Audio.wav', buffer: audioBuffer, startTime: recordingStartTimeRef.current, speed: 1.0 }; updateActiveTracks(prev => prev.map(t => { if (t.id !== trackId) return t; const updatedClips = [...(t.clips || []), newClip]; return { ...t, clips: updatedClips, buffer: updatedClips[0]?.buffer || null, startTime: updatedClips[0]?.startTime || 0, name: updatedClips[0]?.name || t.name }; })); } } if (recordingSyncRef.current) { clearInterval(recordingSyncRef.current); recordingSyncRef.current = null; } activeMIDIRecordersRef.current = {}; activeAudioRecordersRef.current = {}; recordingPCMDataRef.current = {}; setRecTempMidiNotes([]); setRecTempAudioBuffer(null); if (hasRecordedAnything) { showToast('Đã thu và lưu bản ghi vào timeline.', 'success'); } else { showToast('Đã dừng ghi âm (không phát hiện tín hiệu đầu vào).', 'info'); } }; 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 || 140 : 140; 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); const trackList = sessionTab ? sessionTab.tracks : tracks; const track = trackList.find(t => t.id === trackId); if (!track) return; const hasClips = (track.clips && track.clips.length > 0) || !!track.buffer; const hasMidi = track.midiItems && track.midiItems.length > 0; const hasSections = track.sections && track.sections.length > 0; const isTrackEmpty = !hasClips && !hasMidi && !hasSections; if (!isTrackEmpty) { showToast('Không thể xoá track chứa dữ liệu. Vui lòng xoá hết các item trước khi xoá track.', 'warning'); return; } if (sessionTab) { updateActiveTracks(prev => { const filtered = prev.filter(t => t.id !== trackId); if (filtered.length > 0) setSelectedTrackId(filtered[0].id); return filtered; }); } else { 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); } setTimeout(() => lucide.createIcons(), 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; }); setTimeout(() => lucide.createIcons(), 50); }; 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'); }; // ── 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, volumeDb: 0, pan: 0, muted: false, solo: false, color: selectColor, markers: [], serverFileId: null, clips: [], sections: [], midiItems: [], isArmed: false, monitoringEnabled: true, inputSource: { deviceType: 'NONE', deviceId: '' } }]); showToast(`Đã thêm Track ${newId}.`, 'info'); setTimeout(() => lucide.createIcons(), 200); return newId; }; // ── Update tracks in active context (main session or section tab) ── const updateActiveTracksRef = useCallback(updater => { const currentSessionTabs = sessionTabsRef.current; const currentActiveTab = activeTabRef.current; const st = currentSessionTabs.find(s => s.id === currentActiveTab); if (st) { setSessionTabs(prev => prev.map(s => s.id === currentActiveTab ? { ...s, tracks: updater(s.tracks) } : s)); } else { setTracks(updater); } }, []); const updateActiveTracks = updateActiveTracksRef; const toggleTrackArm = trackId => { updateActiveTracks(prev => prev.map(t => { if (t.id !== trackId) return t; return { ...t, isArmed: !t.isArmed }; })); setTimeout(() => lucide.createIcons(), 50); }; const toggleTrackMonitor = trackId => { updateActiveTracks(prev => prev.map(t => { if (t.id !== trackId) return t; return { ...t, monitoringEnabled: !t.monitoringEnabled }; })); setTimeout(() => lucide.createIcons(), 50); }; const updateTrackInputSource = (trackId, type, deviceId) => { updateActiveTracks(prev => prev.map(t => { if (t.id !== trackId) return t; return { ...t, inputSource: { deviceType: type, deviceId } }; })); }; // ── 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, 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 projectSchemaObj = serializeProjectToSchema(currentProjectId || 'project_' + Date.now(), projectName, bpm, tracks, subTabs, sessionTabs); const dataJson = JSON.stringify(projectSchemaObj); 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 projectSchemaObj = serializeProjectToSchema('project_' + Date.now(), newName, bpm, tracks, subTabs, sessionTabs); const dataJson = JSON.stringify(projectSchemaObj); 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 projectSchemaObj = serializeProjectToSchema('project_' + Date.now(), newName, bpm, tracks, subTabs, sessionTabs); const dataJson = JSON.stringify(projectSchemaObj); 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 projectSchemaObj = serializeProjectToSchema(currentProjectId || 'proj_' + Date.now(), finalName, bpm, tracks, subTabs, sessionTabs); window.SonicStorage.exportProjectToSFS(projectSchemaObj); 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, 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, 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, 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: handleRecordClick, className: `w-7 h-7 flex items-center justify-center rounded border transition ${recordingState === 'RECORDING' ? 'bg-red-600 text-white border-red-500 hover:bg-red-500 animate-pulse' : recordingState === 'COUNT_IN' ? 'bg-amber-500 text-black border-amber-400 hover:bg-amber-400 animate-pulse' : 'bg-zinc-800 text-red-500 border-zinc-700 hover:bg-zinc-700 hover:text-red-400'}`, title: recordingState === 'RECORDING' ? "Đang ghi âm..." : recordingState === 'COUNT_IN' ? "Chuẩn bị ghi âm..." : "Ghi âm (Record)" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "circle", 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-[320px] shrink-0 z-20 bg-[#262626] overflow-y-auto flex flex-col border-r border-zinc-900 no-scrollbar", 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-circle", className: "w-3.5 h-3.5" })), " Thêm Track Mới")) : activeTracks.map((track, idx) => { const isSelected = selectedTrackId === track.id; const autoHeight = track.height || (track.isArmed ? 164 : 140); return /*#__PURE__*/React.createElement("div", { key: track.id, style: { height: `${autoHeight}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); }, title: "Mute", className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${track.muted ? 'bg-red-950 text-red-400 border-red-700' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}` }, /*#__PURE__*/React.createElement("i", { "data-lucide": track.muted ? "volume-x" : "volume-2", className: "w-3 h-3" })), /*#__PURE__*/React.createElement("button", { onClick: e => { e.stopPropagation(); toggleTrackSoloEvaluate(track.id); }, title: "Solo", className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${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'}` }, /*#__PURE__*/React.createElement("i", { "data-lucide": soloedTrackId === track.id || track.solo ? "headphones" : "headphones-off", className: "w-3 h-3" })), /*#__PURE__*/React.createElement("button", { onClick: e => { e.stopPropagation(); toggleTrackArm(track.id); }, title: "ARM (Record)", className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${track.isArmed ? 'bg-red-600 text-white border-red-500 hover:bg-red-500' : 'bg-zinc-800 text-zinc-400 border-transparent hover:text-zinc-200'}` }, /*#__PURE__*/React.createElement("i", { "data-lucide": "circle", className: `w-2.5 h-2.5 ${track.isArmed ? 'fill-white' : ''}` })), /*#__PURE__*/React.createElement("button", { onClick: e => { e.stopPropagation(); toggleTrackMonitor(track.id); }, title: "Input Monitor", className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${track.monitoringEnabled ? 'bg-amber-600 text-white border-amber-500 hover:bg-amber-500' : 'bg-zinc-800 text-zinc-500 border-transparent hover:text-zinc-305'}` }, /*#__PURE__*/React.createElement("i", { "data-lucide": track.monitoringEnabled ? "mic" : "mic-off", className: "w-3 h-3" })), /*#__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 h-3" }))))), /*#__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 mt-0.5" }, /*#__PURE__*/React.createElement("span", { className: "w-8 text-right text-zinc-500 text-[10px]" }, "In:"), /*#__PURE__*/React.createElement("select", { value: `${track.inputSource?.deviceType || 'NONE'}:${track.inputSource?.deviceId || ''}`, onChange: e => { const val = e.target.value; const parts = val.split(':'); const type = parts[0]; const id = parts.slice(1).join(':'); updateTrackInputSource(track.id, type, id); }, className: "flex-1 bg-[#18181b] text-zinc-300 text-[10px] rounded border border-zinc-700 focus:outline-none py-0.5 px-1 truncate max-w-[120px]" }, /*#__PURE__*/React.createElement("option", { value: "NONE:" }, "No Input"), /*#__PURE__*/React.createElement("optgroup", { label: "Microphones" }, audioDevices.map(d => /*#__PURE__*/React.createElement("option", { key: d.deviceId, value: `MICROPHONE:${d.deviceId}` }, d.label || `Microphone ${d.deviceId.slice(0, 5)}`))), /*#__PURE__*/React.createElement("optgroup", { label: "MIDI Keyboards" }, midiDevices.map(d => /*#__PURE__*/React.createElement("option", { key: d.id, value: `MIDI_KEYBOARD:${d.id}` }, d.name || `MIDI Input ${d.id.slice(0, 5)}`)))), track.isArmed && lastMidiNote && (lastMidiNote.length === 0 || Date.now() - lastMidiNote.time < 3000) && /*#__PURE__*/React.createElement("span", { className: "text-[9px] font-mono text-emerald-400 ml-0.5 truncate max-w-[60px] shrink-0", title: "MIDI Note:velocity:length" }, `${midiPitchToName(lastMidiNote.pitch)}:${lastMidiNote.velocity}:${lastMidiNote.length > 0 ? lastMidiNote.length.toFixed(2) + 's' : '...'}`)), track.isArmed && /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1 mt-0.5" }, /*#__PURE__*/React.createElement("span", { className: "w-8 text-right text-zinc-500 text-[9px]" }, "VU:"), /*#__PURE__*/React.createElement("canvas", { ref: el => { if (el) trackVuRefs.current[track.id] = el;else delete trackVuRefs.current[track.id]; }, width: 100, height: 4, className: "flex-1 bg-[#18181b] rounded h-1" }))), /*#__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: handleTimelineWrapperRef, 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; const autoHeight = track.height || (track.isArmed ? 164 : 140); return /*#__PURE__*/React.createElement("div", { key: track.id, style: { height: `${autoHeight}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, onEditMidiInTab: handleEditMidiInTab, 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, recordingState: recordingState, recTempMidiNotes: recTempMidiNotes, recTempAudioBuffer: recTempAudioBuffer, recStartTimelineTime: recStartTimelineTime, canvasRedrawCount: canvasRedrawCount }), 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; if (st.type === 'PIANO_ROLL') { return /*#__PURE__*/React.createElement(PianoRollTabEditor, { st: st, zoom: zoom, bpm: bpm, viewportWidth: viewportWidth, onClose: () => closeSubTab(st.id), onUpdateNotes: handleUpdateMidiNotes }); } 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: handleTimelineWrapperRef, 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ề'))), appWarningModal && /*#__PURE__*/React.createElement("div", { className: "fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm" }, /*#__PURE__*/React.createElement("div", { className: "bg-[#222222] border border-zinc-800 rounded-lg shadow-2xl p-6 max-w-md w-full text-slate-200" }, /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-2 mb-3 text-cyan-400 font-bold" }, /*#__PURE__*/React.createElement("h3", { className: "text-base font-bold" }, appWarningModal.title)), /*#__PURE__*/React.createElement("div", { className: "text-xs text-slate-300 mb-5 leading-relaxed whitespace-pre-line" }, appWarningModal.message), /*#__PURE__*/React.createElement("div", { className: "flex justify-end gap-2" }, appWarningModal.isAlert ? /*#__PURE__*/React.createElement("button", { onClick: () => setAppWarningModal(null), className: "px-4 py-2 bg-cyan-600 hover:bg-cyan-500 active:bg-cyan-700 text-white rounded text-xs font-semibold transition" }, "Đóng") : /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("button", { onClick: () => setAppWarningModal(null), className: "px-4 py-2 bg-zinc-700 hover:bg-zinc-600 active:bg-zinc-800 text-slate-300 rounded text-xs font-semibold transition" }, "Hủy"), /*#__PURE__*/React.createElement("button", { onClick: () => { const fn = appWarningModal.onConfirm; setAppWarningModal(null); if (fn) fn(); }, className: "px-4 py-2 bg-cyan-600 hover:bg-cyan-500 active:bg-cyan-700 text-white rounded text-xs font-semibold transition" }, "Xác nhận"))))), /*#__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);