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; let masterBus = null; // { input, compressor, analyser, output, masteringActive } function makeDistortionCurve(k) { const n_samples = 44100; const curve = new Float32Array(n_samples); for (let i = 0; i < n_samples; ++i) { const x = (i * 2) / n_samples - 1; curve[i] = Math.atan(x * k) / (Math.atan(k) || 1); } return curve; } function applyMasteringSettings(s) { if (!masterBus || !audioCtx) return; const now = audioCtx.currentTime; // 1. EQ Settings masterBus.eqLowFilter.gain.setTargetAtTime(s.eqActive ? s.eqLowGain : 0, now, 0.01); masterBus.eqMid1Filter.gain.setTargetAtTime(s.eqActive ? s.eqMid1Gain : 0, now, 0.01); masterBus.eqMid2Filter.gain.setTargetAtTime(s.eqActive ? s.eqMid2Gain : 0, now, 0.01); masterBus.eqHighFilter.gain.setTargetAtTime(s.eqActive ? s.eqHighGain : 0, now, 0.01); // 2. Imager Settings (Mid/Side matrix width control for each band) const updateImagerBand = (w, active, gainLL, gainRL, gainLR, gainRR) => { const widthVal = (s.imagerActive && active) ? w : 0; const g1 = 1 + widthVal / 200; const g2 = -widthVal / 200; gainLL.gain.setTargetAtTime(g1, now, 0.01); gainRR.gain.setTargetAtTime(g1, now, 0.01); gainRL.gain.setTargetAtTime(g2, now, 0.01); gainLR.gain.setTargetAtTime(g2, now, 0.01); }; updateImagerBand(s.w1, true, masterBus.gainLL1, masterBus.gainRL1, masterBus.gainLR1, masterBus.gainRR1); updateImagerBand(s.w2, true, masterBus.gainLL2, masterBus.gainRL2, masterBus.gainLR2, masterBus.gainRR2); updateImagerBand(s.w3, true, masterBus.gainLL3, masterBus.gainRL3, masterBus.gainLR3, masterBus.gainRR3); updateImagerBand(s.w4, true, masterBus.gainLL4, masterBus.gainRL4, masterBus.gainLR4, masterBus.gainRR4); // 3. Maximizer Settings const boostLinear = (s.maximizerActive) ? Math.pow(10, s.maxGain / 20) : 1.0; masterBus.maximizerBoostGain.gain.setTargetAtTime(boostLinear, now, 0.01); // Soft Clipper if (s.maximizerActive && s.maxSoftClip > 0) { const k = 1 + (s.maxSoftClip / 100) * 10; masterBus.maximizerSoftClipper.curve = makeDistortionCurve(k); } else { masterBus.maximizerSoftClipper.curve = null; } // Upward Compressor const upwardGainLinear = (s.maximizerActive && s.maxUpward > 0) ? (Math.pow(10, s.maxUpward / 20) - 1.0) : 0.0; masterBus.upwardGain.gain.setTargetAtTime(upwardGainLinear, now, 0.01); // Limiter Threshold const ceilingVal = s.maximizerActive ? s.ceiling : -0.1; masterBus.maximizerCompressor.threshold.setTargetAtTime(ceilingVal, now, 0.01); } function initMasterBus(ctx) { if (masterBus) return masterBus; // Create EQ filters const eqLowFilter = ctx.createBiquadFilter(); eqLowFilter.type = 'lowshelf'; eqLowFilter.frequency.value = 100; const eqMid1Filter = ctx.createBiquadFilter(); eqMid1Filter.type = 'peaking'; eqMid1Filter.frequency.value = 822; eqMid1Filter.Q.value = 0.7; const eqMid2Filter = ctx.createBiquadFilter(); eqMid2Filter.type = 'peaking'; eqMid2Filter.frequency.value = 3200; eqMid2Filter.Q.value = 1.2; const eqHighFilter = ctx.createBiquadFilter(); eqHighFilter.type = 'highshelf'; eqHighFilter.frequency.value = 10000; // Create Stereo Imager nodes const imagerInput = ctx.createGain(); const imagerOutput = ctx.createGain(); // Imager Crossover Filters const f1_lp = ctx.createBiquadFilter(); f1_lp.type = 'lowpass'; f1_lp.frequency.value = 100; const f2_hp = ctx.createBiquadFilter(); f2_hp.type = 'highpass'; f2_hp.frequency.value = 100; const f2_lp = ctx.createBiquadFilter(); f2_lp.type = 'lowpass'; f2_lp.frequency.value = 1000; const f3_hp = ctx.createBiquadFilter(); f3_hp.type = 'highpass'; f3_hp.frequency.value = 1000; const f3_lp = ctx.createBiquadFilter(); f3_lp.type = 'lowpass'; f3_lp.frequency.value = 6000; const f4_hp = ctx.createBiquadFilter(); f4_hp.type = 'highpass'; f4_hp.frequency.value = 6000; const split1 = ctx.createChannelSplitter(2); const split2 = ctx.createChannelSplitter(2); const split3 = ctx.createChannelSplitter(2); const split4 = ctx.createChannelSplitter(2); const merge1 = ctx.createChannelMerger(2); const merge2 = ctx.createChannelMerger(2); const merge3 = ctx.createChannelMerger(2); const merge4 = ctx.createChannelMerger(2); const gainLL1 = ctx.createGain(); const gainRL1 = ctx.createGain(); const gainLR1 = ctx.createGain(); const gainRR1 = ctx.createGain(); const gainLL2 = ctx.createGain(); const gainRL2 = ctx.createGain(); const gainLR2 = ctx.createGain(); const gainRR2 = ctx.createGain(); const gainLL3 = ctx.createGain(); const gainRL3 = ctx.createGain(); const gainLR3 = ctx.createGain(); const gainRR3 = ctx.createGain(); const gainLL4 = ctx.createGain(); const gainRL4 = ctx.createGain(); const gainLR4 = ctx.createGain(); const gainRR4 = ctx.createGain(); // Connections for Imager DSP imagerInput.connect(f1_lp); imagerInput.connect(f2_hp); f2_hp.connect(f2_lp); imagerInput.connect(f3_hp); f3_hp.connect(f3_lp); imagerInput.connect(f4_hp); // Band 1 f1_lp.connect(split1); split1.connect(gainLL1, 0); split1.connect(gainLR1, 0); split1.connect(gainRL1, 1); split1.connect(gainRR1, 1); gainLL1.connect(merge1, 0, 0); gainRL1.connect(merge1, 0, 0); gainLR1.connect(merge1, 0, 1); gainRR1.connect(merge1, 0, 1); merge1.connect(imagerOutput); // Band 2 f2_lp.connect(split2); split2.connect(gainLL2, 0); split2.connect(gainLR2, 0); split2.connect(gainRL2, 1); split2.connect(gainRR2, 1); gainLL2.connect(merge2, 0, 0); gainRL2.connect(merge2, 0, 0); gainLR2.connect(merge2, 0, 1); gainRR2.connect(merge2, 0, 1); merge2.connect(imagerOutput); // Band 3 f3_lp.connect(split3); split3.connect(gainLL3, 0); split3.connect(gainLR3, 0); split3.connect(gainRL3, 1); split3.connect(gainRR3, 1); gainLL3.connect(merge3, 0, 0); gainRL3.connect(merge3, 0, 0); gainLR3.connect(merge3, 0, 1); gainRR3.connect(merge3, 0, 1); merge3.connect(imagerOutput); // Band 4 f4_hp.connect(split4); split4.connect(gainLL4, 0); split4.connect(gainLR4, 0); split4.connect(gainRL4, 1); split4.connect(gainRR4, 1); gainLL4.connect(merge4, 0, 0); gainRL4.connect(merge4, 0, 0); gainLR4.connect(merge4, 0, 1); gainRR4.connect(merge4, 0, 1); merge4.connect(imagerOutput); // Maximizer nodes const maximizerBoostGain = ctx.createGain(); const maximizerSoftClipper = ctx.createWaveShaper(); maximizerSoftClipper.curve = null; maximizerSoftClipper.oversample = '4x'; const upwardCompressor = ctx.createDynamicsCompressor(); upwardCompressor.threshold.value = -30; upwardCompressor.knee.value = 10; upwardCompressor.ratio.value = 4; upwardCompressor.attack.value = 0.01; upwardCompressor.release.value = 0.1; const upwardGain = ctx.createGain(); upwardGain.gain.value = 0.0; const upwardSummingGain = ctx.createGain(); maximizerBoostGain.connect(maximizerSoftClipper); maximizerSoftClipper.connect(upwardSummingGain); maximizerBoostGain.connect(upwardCompressor); upwardCompressor.connect(upwardGain); upwardGain.connect(upwardSummingGain); const maximizerCompressor = ctx.createDynamicsCompressor(); maximizerCompressor.threshold.value = -0.1; maximizerCompressor.knee.value = 0.0; maximizerCompressor.ratio.value = 20.0; maximizerCompressor.attack.value = 0.001; maximizerCompressor.release.value = 0.05; upwardSummingGain.connect(maximizerCompressor); // Setup Analysers const inputAnalyser = ctx.createAnalyser(); inputAnalyser.fftSize = 2048; const outputAnalyser = ctx.createAnalyser(); outputAnalyser.fftSize = 2048; // Global fader / output const output = ctx.createGain(); output.gain.value = 1.0; const analyser = ctx.createAnalyser(); analyser.fftSize = 256; const leftAnalyser = ctx.createAnalyser(); leftAnalyser.fftSize = 2048; const rightAnalyser = ctx.createAnalyser(); rightAnalyser.fftSize = 2048; const splitter = ctx.createChannelSplitter(2); output.connect(splitter); splitter.connect(leftAnalyser, 0); splitter.connect(rightAnalyser, 1); masterBus = { input: ctx.createGain(), compressor: ctx.createDynamicsCompressor(), analyser, output, masteringActive: false, // Analysers for metering inputAnalyser, outputAnalyser, leftAnalyser, rightAnalyser, // Mastering nodes eqLowFilter, eqMid1Filter, eqMid2Filter, eqHighFilter, imagerInput, imagerOutput, gainLL1, gainRL1, gainLR1, gainRR1, gainLL2, gainRL2, gainLR2, gainRR2, gainLL3, gainRL3, gainLR3, gainRR3, gainLL4, gainRL4, gainLR4, gainRR4, maximizerBoostGain, maximizerSoftClipper, upwardCompressor, upwardGain, upwardSummingGain, maximizerCompressor }; // Connect EQ chain eqLowFilter.connect(eqMid1Filter); eqMid1Filter.connect(eqMid2Filter); eqMid2Filter.connect(eqHighFilter); // Connect EQ to Imager eqHighFilter.connect(imagerInput); // Connect Imager to Maximizer imagerOutput.connect(maximizerBoostGain); // Setup default non-mastered routing: // input -> compressor -> inputAnalyser -> outputAnalyser -> output -> analyser -> destination masterBus.input.connect(masterBus.compressor); masterBus.compressor.connect(masterBus.inputAnalyser); masterBus.inputAnalyser.connect(masterBus.outputAnalyser); masterBus.outputAnalyser.connect(masterBus.output); masterBus.output.connect(masterBus.analyser); masterBus.analyser.connect(ctx.destination); window.masterBus = masterBus; return masterBus; } function setMasterVolume(linear) { if (masterBus) masterBus.output.gain.setValueAtTime(linear, audioCtx.currentTime); } function toggleMasteringOnMaster(activate, isBypassed) { if (!masterBus) return; // Disconnect the dynamic junction masterBus.inputAnalyser.disconnect(); masterBus.maximizerCompressor.disconnect(); if (activate && !isBypassed) { // Active routing: inputAnalyser -> EQ -> Imager -> Maximizer -> outputAnalyser masterBus.inputAnalyser.connect(masterBus.eqLowFilter); masterBus.maximizerCompressor.connect(masterBus.outputAnalyser); masterBus.masteringActive = true; } else { // Bypassed routing: inputAnalyser -> outputAnalyser masterBus.inputAnalyser.connect(masterBus.outputAnalyser); masterBus.masteringActive = false; } } 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(); } if (!masterBus) { initMasterBus(audioCtx); } if (window.currentMasteringSettings) { toggleMasteringOnMaster(window.currentMasteringSettings.masterConnected, window.currentMasteringSettings.isBypassed); applyMasteringSettings(window.currentMasteringSettings); } if (window.SonicSF && window.SonicSF.init) { window.SonicSF.init(audioCtx); } 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 "0.1.1"; const beatDuration = 60 / bpmVal; const barDuration = beatDuration * 4; const bar = Math.floor(secs / barDuration); 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; this.selectedMidiInputId = null; // 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.selectedMidiInputId = selectedMidiInputId; } handleMIDIMessage(event, sourceInputId = null) { if (!this.isRecording) return; if (this.selectedMidiInputId && this.selectedMidiInputId !== 'ALL' && sourceInputId && sourceInputId !== this.selectedMidiInputId) { console.log(`[DevLog] [MIDI Rec] Ignoring input message from "${sourceInputId}" (Selected: "${this.selectedMidiInputId}")`); 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; // Command 0x9: Note On if (command === 0x9 && velocity > 0) { const noteId = `rec_${Date.now()}_${pitch}`; const scaledVel = Math.min(1.0, Math.max(0.5, Math.round(velocity / 127.0 * 100) / 100)); const newActiveNote = { id: noteId, pitch: pitch, start_beat: currentBeat, velocity: scaledVel }; this.activeNotes.set(pitch, newActiveNote); console.log(`[DevLog] [MIDI Rec] Note On - Pitch: ${pitch}, Velocity: ${velocity}, StartBeat: ${currentBeat.toFixed(3)}, latencyCompSec: ${this.latencyCompSec.toFixed(3)}, noteObj:`, newActiveNote); // 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); console.log(`[DevLog] [MIDI Rec] Note Off - Pitch: ${pitch}, DurationBeats: ${durationBeats.toFixed(3)}, FinishedNote:`, finishedNote); 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); for (let [pitch, note] of this.activeNotes.entries()) { const durationBeats = Math.max(0.25, currentBeat - note.start_beat); 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); console.log(`[DevLog] [MIDI Rec] Flushing active keypress on stop - Pitch: ${pitch}, DurationBeats: ${durationBeats.toFixed(3)}, FinishedNote:`, finishedNote); } this.activeNotes.clear(); console.log(`[DevLog] [MIDI Rec] Recording stopped. Total notes: ${this.recordedNotes.length}. List:`, this.recordedNotes); 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 MixerStrip = ({ track, index, onUpdateTrack, trackVuRefs }) => { const dbLabel = (track.volumeDb == null || track.volumeDb <= -50) ? '-inf' : (track.volumeDb > 0 ? '+' : '') + (track.volumeDb || 0).toFixed(1) + 'dB'; const isMuted = track.muted; const isSoloed = track.solo; const vol = track.volumeDb != null ? track.volumeDb : 0; var pct = Math.max(0, Math.min(100, (vol + 60) / 72 * 100)); var vuColor = pct >= 80 ? '#ef4444' : pct >= 50 ? '#eab308' : '#22c55e'; var trackColor = track.color || '#06b6d4'; return React.createElement("div", { className: "flex flex-col items-stretch w-[84px] shrink-0 bg-[#2b2b2b] border border-black/70 overflow-hidden rounded-sm" }, React.createElement("div", { className: "flex items-center justify-between px-1 py-0.5 bg-[#222] border-b border-black/60 shrink-0" }, React.createElement("span", { className: "text-[9px] font-mono font-bold text-zinc-400" }, (index + 1))), React.createElement("div", { className: "flex items-center justify-center gap-1 py-0.5 shrink-0" }, React.createElement("button", { onClick: e => { e.stopPropagation(); if (onUpdateTrack) onUpdateTrack(track.id, { muted: !track.muted }); }, title: "Mute", className: "w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition " + (isMuted ? 'bg-orange-500 text-black border-orange-400' : 'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100') }, "M"), React.createElement("button", { onClick: e => { e.stopPropagation(); if (onUpdateTrack) onUpdateTrack(track.id, { solo: !track.solo }); }, title: "Solo", className: "w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition " + (isSoloed ? 'bg-yellow-400 text-black border-yellow-300' : 'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100') }, "S")), React.createElement("div", { className: "flex-1 flex items-stretch justify-center gap-1 px-1 py-1 min-h-0" }, React.createElement("div", { className: "w-[30px] rounded-sm relative overflow-hidden bg-[#0d0d0d] border border-black/60 flex flex-col items-center cursor-pointer", onMouseDown: function(e) { e.preventDefault(); var rect = e.currentTarget.getBoundingClientRect(); var tid = track.id; function onMove(ev) { var pct = 1 - Math.max(0, Math.min(1, (ev.clientY - rect.top) / rect.height)); var val = Math.round((pct * 72 - 60) * 2) / 2; if (onUpdateTrack) onUpdateTrack(tid, { volumeDb: val }); } function onUp() { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); } document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); onMove(e); } }, /* 0dB reference line */ React.createElement("div", { className: "absolute w-full h-px bg-amber-400/60 z-10 pointer-events-none", style: { bottom: '83.333%' } }), /* Background gradient */ React.createElement("div", { className: "absolute inset-0", style: { background: 'linear-gradient(to top, #22c55e, #eab308, #ef4444)' } }), /* Level overlay - dark at TOP, gradient visible at bottom */ React.createElement("div", { className: "absolute top-0 w-full transition-all duration-75 bg-[#0d0d0d]", style: { height: (100 - pct) + '%' } })), React.createElement("canvas", { ref: el => { if (el) trackVuRefs.current[track.id + '_mixer'] = el; else delete trackVuRefs.current[track.id + '_mixer']; }, width: 15, height: 120, className: "w-[15px] rounded-sm bg-[#0d0d0d] border border-black/60 block h-full" })), React.createElement("div", { className: "text-center text-[9px] font-mono font-bold py-0.5 " + (vol > 0 ? 'text-orange-400' : 'text-zinc-300') + " bg-[#1c1c1c] border-t border-black/50 shrink-0" }, dbLabel), React.createElement("div", { className: "text-[8px] font-mono truncate w-full text-center px-1 py-0.5 bg-[#222] border-t border-black/60 shrink-0", style: { color: trackColor } }, track.name)); }; // ── Master Strip Console Component (from md/47_MASTER_STRIP_CONSOLE.md) ── const MasterStripConsole = ({ masterVolume, setMasterVolume, showMasteringModal, setShowMasteringModal, masteringSettings, setMasteringSettings, isPlaying }) => { const [isFxActive, setIsFxActive] = React.useState(true); const [isTestPlaying, setIsTestPlaying] = React.useState(false); const [isMuted, setIsMuted] = React.useState(false); const [isMono, setIsMono] = React.useState(false); const [pan, setPan] = React.useState(0.0); const [panText, setPanText] = React.useState('center'); const vuCanvasRef = React.useRef(null); const animFrameRef = React.useRef(null); const rmsValRef = React.useRef(null); const peakLRef = React.useRef(null); const peakRRef = React.useRef(null); const panPointerRef = React.useRef(null); const isMasterActive = masteringSettings ? masteringSettings.masterConnected : false; const panStartYRef = React.useRef(0); const startPanValRef = React.useRef(0); const ensureAudio = () => { getAudioContext(); }; const handleFaderChange = (val) => { setMasterVolume(val); ensureAudio(); if (masterBus && masterBus.output) { const linear = val <= -50 ? 0 : Math.pow(10, val / 20); masterBus.output.gain.setTargetAtTime(linear, audioCtx.currentTime, 0.01); } }; const handlePanPointerDown = (e) => { isPanDraggingRef.current = true; panStartYRef.current = e.clientY; startPanValRef.current = pan; e.currentTarget.setPointerCapture(e.pointerId); }; const handlePanPointerMove = (e) => { if (!isPanDraggingRef.current) return; const deltaY = panStartYRef.current - e.clientY; let newPan = startPanValRef.current + (deltaY / 80); newPan = Math.min(1.0, Math.max(-1.0, newPan)); setPan(newPan); const angle = newPan * 120; if (panPointerRef.current) panPointerRef.current.style.transform = `rotate(${angle}deg)`; if (newPan === 0) setPanText('center'); else if (newPan < 0) setPanText('L' + Math.abs(Math.round(newPan * 100))); else setPanText('R' + Math.round(newPan * 100)); }; const handlePanPointerUp = (e) => { isPanDraggingRef.current = false; try { e.currentTarget.releasePointerCapture(e.pointerId); } catch(err) {} }; React.useEffect(() => { const canvas = vuCanvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); function render() { animFrameRef.current = requestAnimationFrame(render); canvas.width = canvas.clientWidth; canvas.height = canvas.clientHeight; const w = canvas.width; const h = canvas.height; ctx.clearRect(0, 0, w, h); let levelL = 0; let levelR = 0; if (masterBus && masterBus.leftAnalyser && masterBus.rightAnalyser) { const leftData = new Uint8Array(256); const rightData = new Uint8Array(256); masterBus.leftAnalyser.getByteTimeDomainData(leftData); masterBus.rightAnalyser.getByteTimeDomainData(rightData); let peakL = 0; let peakR = 0; for (let i = 0; i < leftData.length; i++) { const v = Math.abs(leftData[i] - 128) / 128; if (v > peakL) peakL = v; } for (let i = 0; i < rightData.length; i++) { const v = Math.abs(rightData[i] - 128) / 128; if (v > peakR) peakR = v; } levelL = peakL; levelR = peakR; } const padding = 4; const gap = 4; const barW = Math.max(4, (w - padding * 2 - gap) / 2); const grad = ctx.createLinearGradient(0, h, 0, 0); grad.addColorStop(0, '#10b981'); grad.addColorStop(0.7, '#f59e0b'); grad.addColorStop(0.95, '#ef4444'); ctx.fillStyle = grad; ctx.fillRect(padding, h - levelL * h, barW, levelL * h); ctx.fillRect(padding + barW + gap, h - levelR * h, barW, levelR * h); const maxLevel = Math.max(levelL, levelR); if (rmsValRef.current) { rmsValRef.current.innerText = maxLevel > 0 ? (20 * Math.log10(maxLevel) - 3.2).toFixed(1) + ' dB' : '-inf'; } if (peakLRef.current) { peakLRef.current.innerText = levelL > 0 ? (20 * Math.log10(levelL)).toFixed(1) + 'dB' : '-inf'; } if (peakRRef.current) { peakRRef.current.innerText = levelR > 0 ? (20 * Math.log10(levelR)).toFixed(1) + 'dB' : '-inf'; } } render(); return () => { if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); }; }, [masterVolume, isMuted, isMono]); return React.createElement("div", { className: "flex flex-col items-stretch w-[300px] shrink-0 strip-bg rounded-lg p-2 text-slate-300 select-none shadow-2xl relative overflow-hidden" }, React.createElement("div", { className: "space-y-1.5 mb-2" }, React.createElement("button", { onClick: () => setShowMasteringModal(true), className: "w-full bg-slate-800 hover:bg-slate-700 text-[10px] font-semibold py-1 rounded border border-slate-700 text-slate-300 tracking-tight" }, "MASTERING PANEL"), React.createElement("div", { className: "flex items-center justify-between bg-slate-950 border border-slate-800 rounded px-1.5 py-0.5 text-[10px] font-mono" }, React.createElement("span", { className: "text-slate-400 truncate" }, "Output 1 / Output 2"), React.createElement("i", { className: "fa-solid fa-circle-notch text-[9px] text-slate-500" }) ) ), React.createElement("div", { className: "flex flex-col items-center my-0.5" }, React.createElement("span", { className: "text-[9px] text-slate-400 font-mono" }, panText || 'center'), React.createElement("div", { className: "flex items-center gap-1" }, React.createElement("span", { className: "text-[8px] font-mono text-slate-500 w-5 text-right" }, pan < 0 ? 'L' + Math.abs(Math.round(pan * 100)) : '' ), React.createElement("div", { id: "panDial", className: "w-6 h-6 rounded-full bg-slate-800 border-2 border-slate-600 relative flex items-center justify-center shadow-inner cursor-pointer", title: "Kéo chuột để chỉnh Pan (Left/Right)", onPointerDown: handlePanPointerDown, onPointerMove: handlePanPointerMove, onPointerUp: handlePanPointerUp, onWheel: function(e) { e.preventDefault(); var step = e.deltaY > 0 ? -0.05 : 0.05; var newPan = Math.max(-1, Math.min(1, pan + step)); newPan = Math.round(newPan * 100) / 100; setPan(newPan); if (panPointerRef.current) panPointerRef.current.style.transform = 'rotate(' + (newPan * 120) + 'deg)'; if (newPan === 0) setPanText('center'); else if (newPan < 0) setPanText('L' + Math.abs(Math.round(newPan * 100))); else setPanText('R' + Math.round(newPan * 100)); }, onDoubleClick: function() { setPan(0); setPanText('center'); if (panPointerRef.current) panPointerRef.current.style.transform = 'rotate(0deg)'; } }, React.createElement("div", { ref: panPointerRef, id: "panPointer", className: "w-0.5 h-2 bg-slate-200 rounded absolute top-0.5 transition-transform", style: { transform: 'rotate(' + (pan * 120) + 'deg)' } })), React.createElement("span", { className: "text-[8px] font-mono text-slate-500 w-5 text-left" }, pan > 0 ? 'R' + Math.round(pan * 100) : '' ), React.createElement("span", { ref: React.createRef ? null : null, className: "text-[10px] font-bold font-mono text-slate-200 ml-8", onDoubleClick: function() { handleFaderChange(0); } }, masterVolume <= -50 ? '-inf' : (masterVolume > 0 ? '+' : '') + masterVolume.toFixed(1) ) ) ), React.createElement("div", { className: "flex gap-1 my-1 justify-between items-stretch min-h-0", style: {flex: '1 1 0%'} }, React.createElement("div", { className: "flex-1 flex flex-col bg-black/80 p-1.5 rounded border border-slate-800/90 relative shadow-inner" }, React.createElement("div", { className: "flex-1 flex items-stretch justify-between min-h-0" }, React.createElement("div", { className: "flex flex-col h-full text-[8px] font-mono text-slate-400 select-none pr-1 text-right border-r border-slate-800/60 overflow-hidden" }, React.createElement("span", { className: "flex-1 flex items-center justify-end" }, "+12"), React.createElement("span", { className: "flex-1 flex items-center justify-end" }, "+6"), React.createElement("span", { className: "flex-1 flex items-center justify-end" }, "0"), React.createElement("span", { className: "flex-1 flex items-center justify-end" }, "-6"), React.createElement("span", { className: "flex-1 flex items-center justify-end" }, "-12"), React.createElement("span", { className: "flex-1 flex items-center justify-end" }, "-18"), React.createElement("span", { className: "flex-1 flex items-center justify-end" }, "-24"), React.createElement("span", { className: "flex-1 flex items-center justify-end" }, "-30"), React.createElement("span", { className: "flex-1 flex items-center justify-end" }, "-36"), React.createElement("span", { className: "flex-1 flex items-center justify-end" }, "-42"), React.createElement("span", { className: "flex-1 flex items-center justify-end" }, "-54") ), React.createElement("div", { className: "flex-1 relative bg-slate-950 rounded overflow-hidden mx-1.5 border border-slate-900" }, React.createElement("canvas", { ref: vuCanvasRef, className: "w-[100px] h-full block" }), React.createElement("div", { className: "absolute bottom-0.5 inset-x-0 flex justify-around text-[7px] font-mono text-slate-500 font-bold pointer-events-none bg-black/60 py-0.5" }, React.createElement("span", null, "L"), React.createElement("span", null, "R") ) ), React.createElement("div", { className: "flex flex-col h-full text-[8px] font-mono text-slate-400 select-none pl-1 text-left border-l border-slate-800/60 overflow-hidden" }, React.createElement("span", { className: "flex-1 flex items-center justify-start" }, "+12"), React.createElement("span", { className: "flex-1 flex items-center justify-start" }, "+6"), React.createElement("span", { className: "flex-1 flex items-center justify-start" }, "0"), React.createElement("span", { className: "flex-1 flex items-center justify-start" }, "-6"), React.createElement("span", { className: "flex-1 flex items-center justify-start" }, "-12"), React.createElement("span", { className: "flex-1 flex items-center justify-start" }, "-18"), React.createElement("span", { className: "flex-1 flex items-center justify-start" }, "-24"), React.createElement("span", { className: "flex-1 flex items-center justify-start" }, "-30"), React.createElement("span", { className: "flex-1 flex items-center justify-start" }, "-36"), React.createElement("span", { className: "flex-1 flex items-center justify-start" }, "-42"), React.createElement("span", { className: "flex-1 flex items-center justify-start" }, "-54") ) ), React.createElement("div", { className: "flex justify-between text-[9px] font-mono text-slate-400 mt-0.5" }, React.createElement("span", { ref: peakLRef }, "-inf"), React.createElement("span", { ref: peakRRef }, "-inf") ) ), React.createElement("div", { className: "w-16 flex items-stretch gap-1 bg-slate-900/60 p-1 rounded border border-slate-800" }, React.createElement("div", { className: "flex-1 flex flex-col items-center justify-center relative fader-track rounded", onWheel: function(e) { e.preventDefault(); var delta = e.deltaY > 0 ? -0.5 : 0.5; handleFaderChange(Math.max(-60, Math.min(12, masterVolume + delta))); } }, React.createElement("div", { className: "w-0.5 h-full bg-slate-700 absolute left-1/2 -translate-x-1/2 top-0" }), React.createElement("input", { id: "masterFader", type: "range", min: "-60", max: "12", step: "0.5", value: masterVolume, className: "fader-slider w-full z-10", orient: "vertical", onChange: function(e) { handleFaderChange(parseFloat(e.target.value)); }, onDoubleClick: function() { handleFaderChange(0); } }) ), React.createElement("div", { className: "relative w-5 text-[7px] font-mono text-slate-500 select-none overflow-hidden" }, React.createElement("span", { className: "absolute", style: {top: '0%', right: '2px'} }, "+12"), React.createElement("span", { className: "absolute", style: {top: '8.3%', right: '2px'} }, "+6"), React.createElement("span", { className: "absolute", style: {top: '16.7%', right: '2px'} }, "0"), React.createElement("span", { className: "absolute", style: {top: '25%', right: '2px'} }, "-6"), React.createElement("span", { className: "absolute", style: {top: '33.3%', right: '2px'} }, "-12"), React.createElement("span", { className: "absolute", style: {top: '50%', right: '2px'} }, "-24"), React.createElement("span", { className: "absolute", style: {top: '66.7%', right: '2px'} }, "-36"), React.createElement("span", { className: "absolute", style: {top: '91.7%', right: '2px'} }, "-54") ) ), React.createElement("div", { className: "w-8 flex flex-col justify-between text-[10px] font-bold" }, React.createElement("button", { id: "monoBtn", onClick: function() { setIsMono(function(p) { return !p; }); }, className: "btn-daw h-[18px] rounded flex flex-col items-center justify-center text-[8px]" + (isMono ? " btn-mono-active" : ""), title: "Mono Switch" }, React.createElement("i", { className: "fa-solid fa-circle-half-stroke text-[9px]" }), React.createElement("span", null, "MONO") ), React.createElement("button", { id: "muteBtn", onClick: function() { setIsMuted(function(p) { return !p; }); }, className: "btn-daw h-[18px] rounded text-amber-500 font-bold hover:text-amber-400" + (isMuted ? " btn-mute-active" : ""), title: "Mute Master Output" }, "M"), React.createElement("button", { id: "soloBtn", className: "btn-daw h-[18px] rounded text-yellow-400 font-bold hover:text-yellow-300", title: "Solo Master" }, "S"), React.createElement("button", { className: "btn-daw h-[18px] rounded text-slate-400 hover:text-slate-200", title: "Route Matrix" }, React.createElement("i", { className: "fa-solid fa-diagram-project text-[9px]" }) ), React.createElement("button", { id: "fxBtn", className: "btn-daw h-[18px] rounded font-extrabold text-[10px] transition-all", onClick: function() { setShowMasteringModal(true); }, title: "Mở MASTERING PANEL để chỉnh sửa" }, "FX"), React.createElement("button", { id: "powerBtn", className: "btn-daw h-[18px] rounded text-xs transition-all" + (isMasterActive ? " btn-teal-active" : ""), onClick: function() { if (setMasteringSettings) { setMasteringSettings(function(prev) { return Object.assign({}, prev, { masterConnected: !prev.masterConnected, isBypassed: false }); }); } }, title: "Bật/Tắt MASTERING PANEL Bypass" }, [React.createElement("i", { className: "fa-solid fa-power-off" + (isFxActive ? " text-emerald-400" : " text-slate-500"), key: "ico" }), React.createElement("span", { key: "lbl", className: "text-[7px] font-bold" + (isFxActive ? " text-emerald-300" : " text-slate-400") }, "PWR")]), React.createElement("button", { className: "btn-daw h-[18px] rounded text-slate-400 text-[8px]", title: "Trim Envelope" }, "TRIM"), React.createElement("button", { className: "btn-daw h-[18px] rounded text-slate-400 text-[9px]", title: "Session Info" }, React.createElement("i", { className: "fa-solid fa-info" }) ) ) ), React.createElement("div", { className: "text-center text-[10px] font-bold font-mono text-slate-200 shrink-0" }, masterVolume <= -50 ? '-inf' : (masterVolume > 0 ? '+' : '') + masterVolume.toFixed(1) ), React.createElement("div", { className: "shrink-0 border-t border-slate-800 flex flex-col items-center" }, React.createElement("div", { className: "flex justify-between w-full text-[9px] font-mono py-0.5" }, React.createElement("span", { className: "text-emerald-400" }, "RMS"), React.createElement("span", { ref: rmsValRef, className: "text-emerald-400 font-bold" }, "-inf") ), React.createElement("div", { className: "w-full text-center bg-black/80 py-0.5 rounded border border-slate-800 text-[10px] font-extrabold tracking-widest text-slate-100 uppercase" }, React.createElement("span", null, "MAIN OUT") ) ) ); }; // ── Track Strip Console Component (from md/48_TRACK_STRIP_COMPONENT.md) ── const TrackStripConsole = ({ track, index, onUpdateTrack, trackVuRefs }) => { var vol = track.volumeDb != null ? track.volumeDb : 0; var trackColor = track.color || '#06b6d4'; var isMuted = track.muted; var isSoloed = track.solo; var isArmed = track.isArmed; var trackName = track.name || 'Track ' + (index + 1); var isMicActive = track.inputSource?.deviceType === 'MICROPHONE'; const [pan, setPan] = React.useState(0.0); const [panLabel, setPanLabel] = React.useState('center'); const [isPhaseInverted, setIsPhaseInverted] = React.useState(false); const [isFxActive, setIsFxActive] = React.useState(true); const panPointerRef = React.useRef(null); const setVuCanvas = React.useCallback(function(el) { if (el) trackVuRefs.current[track.id + '_mixer'] = el; else delete trackVuRefs.current[track.id + '_mixer']; }, [track.id, trackVuRefs]); const handlePanPointerDown = (e) => { e.currentTarget._panStartY = e.clientY; e.currentTarget._startPan = pan; e.currentTarget.setPointerCapture(e.pointerId); function onMove(ev) { if (!e.currentTarget) return; var deltaY = e.currentTarget._panStartY - ev.clientY; var newPan = Math.min(1.0, Math.max(-1.0, e.currentTarget._startPan + (deltaY / 80))); setPan(newPan); var angle = newPan * 120; if (panPointerRef.current) panPointerRef.current.style.transform = 'rotate(' + angle + 'deg)'; if (newPan === 0) setPanLabel('center'); else if (newPan < 0) setPanLabel('L' + Math.abs(Math.round(newPan * 100))); else setPanLabel('R' + Math.round(newPan * 100)); } function onUp() { document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); } document.addEventListener('pointermove', onMove); document.addEventListener('pointerup', onUp); }; return React.createElement("div", { className: "flex flex-col items-stretch w-[145px] shrink-0 strip-bg-track rounded-md text-slate-300 select-none shadow-2xl border border-slate-900 overflow-hidden" }, /* 1. Top Track Color Accent Bar */ React.createElement("div", { className: "h-1.5 w-full shrink-0 transition-colors", style: { backgroundColor: trackColor } }), /* 2. Pan Rotary Dial Area */ React.createElement("div", { className: "h-[46px] shrink-0 py-1 px-2 flex flex-col items-center justify-center border-b border-slate-700/40", style: { backgroundColor: trackColor + '15' } }, React.createElement("div", { className: "w-6 h-6 rounded-full bg-slate-800 border-2 border-slate-400 relative flex items-center justify-center cursor-pointer shadow-md", title: "Kéo chuột lên/xuống để chỉnh Pan", onPointerDown: handlePanPointerDown, onDoubleClick: function() { setPan(0); setPanLabel('center'); if (panPointerRef.current) panPointerRef.current.style.transform = 'rotate(0deg)'; }, onWheel: function(e) { e.preventDefault(); var step = e.deltaY > 0 ? -0.05 : 0.05; var newPan = Math.max(-1, Math.min(1, pan + step)); newPan = Math.round(newPan * 100) / 100; setPan(newPan); if (panPointerRef.current) panPointerRef.current.style.transform = 'rotate(' + (newPan * 120) + 'deg)'; if (newPan === 0) setPanLabel('center'); else if (newPan < 0) setPanLabel('L' + Math.abs(Math.round(newPan * 100))); else setPanLabel('R' + Math.round(newPan * 100)); } }, React.createElement("div", { ref: panPointerRef, className: "w-0.5 h-2 rounded absolute top-0.5 transition-transform", style: { backgroundColor: trackColor, transform: 'rotate(0deg)' } }) ), React.createElement("span", { className: "text-[8px] font-mono mt-0.5 font-semibold", style: { color: trackColor } }, panLabel) ), /* 3. Center Area: Peak dB + Fader + VU + Button Stack */ React.createElement("div", { className: "flex-1 p-1 flex gap-1 justify-between items-stretch min-h-0" }, /* Left Fader & VU Column */ React.createElement("div", { className: "flex-1 flex flex-col justify-between items-center bg-black/60 p-1 rounded border border-slate-800/80" }, React.createElement("div", { className: "w-full flex justify-center text-[8px] font-mono text-slate-400 h-4 items-center" }, React.createElement("span", null, vol <= -50 ? '-inf' : (vol > 0 ? '+' : '') + vol.toFixed(1) + 'dB') ), React.createElement("div", { className: "flex items-stretch justify-around w-full flex-1 relative py-1" }, /* Fader Rail */ React.createElement("div", { className: "relative fader-track-bg w-3 flex-1 rounded flex items-center justify-center overflow-hidden", }, React.createElement("div", { className: "w-0.5 h-full bg-slate-700 absolute" }), React.createElement("input", { type: "range", min: "-60", max: "12", step: "0.5", value: vol, className: "fader-slider w-full z-10", onChange: function(e) { var val = parseFloat(e.target.value); if (onUpdateTrack) onUpdateTrack(track.id, { volumeDb: val }); }, onWheel: function(e) { e.preventDefault(); var step = e.deltaY > 0 ? -0.5 : 0.5; var newVol = Math.max(-60, Math.min(12, vol + step)); if (onUpdateTrack) onUpdateTrack(track.id, { volumeDb: newVol }); }, onDoubleClick: function() { if (onUpdateTrack) onUpdateTrack(track.id, { volumeDb: 0 }); } }) ), /* VU Meter */ React.createElement("div", { className: "w-2.5 flex-1 bg-slate-950 rounded border border-slate-900 overflow-hidden relative", title: "Peak VU Meter" }, React.createElement("canvas", { ref: setVuCanvas, className: "w-full h-full block" }) ) ) ), /* Right Button Stack */ React.createElement("div", { className: "w-7 flex flex-col justify-between text-[8px] font-bold shrink-0" }, React.createElement("button", { onClick: function(e) { e.stopPropagation(); if (onUpdateTrack) onUpdateTrack(track.id, { muted: !track.muted }); }, className: "btn-daw h-[24px] rounded text-amber-500 font-extrabold flex items-center justify-center" + (isMuted ? " btn-mute-active" : ""), title: "Mute Track" }, "M"), React.createElement("button", { onClick: function(e) { e.stopPropagation(); if (onUpdateTrack) onUpdateTrack(track.id, { solo: !track.solo }); }, className: "btn-daw h-[24px] rounded text-yellow-400 font-extrabold flex items-center justify-center" + (isSoloed ? " btn-solo-active" : ""), title: "Solo Track" }, "S"), React.createElement("button", { className: "btn-daw h-[24px] rounded text-emerald-400 flex items-center justify-center", title: "Routing Matrix" }, React.createElement("i", { className: "fa-solid fa-bars-staggered text-[8px]" })), React.createElement("button", { className: "btn-daw h-[24px] rounded text-slate-300 font-semibold flex items-center justify-center text-[7px]", title: "Track FX Chain" }, "FX"), React.createElement("button", { onClick: function() { setIsFxActive(function(p) { return !p; }); }, className: "btn-daw h-[24px] rounded flex items-center justify-center text-[8px]" + (isFxActive ? " text-emerald-400" : " text-slate-500"), title: "Toggle FX Power" }, React.createElement("i", { className: "fa-solid fa-power-off" })), React.createElement("button", { className: "btn-daw h-[24px] rounded text-slate-400 flex items-center justify-center", title: "Automation Envelopes" }, React.createElement("i", { className: "fa-solid fa-chart-line text-[8px]" })), React.createElement("button", { onClick: function() { setIsPhaseInverted(function(p) { return !p; }); }, className: "btn-daw h-[24px] rounded flex items-center justify-center text-[9px]" + (isPhaseInverted ? " bg-amber-600 text-white" : " text-slate-400"), title: "Phase Invert" }, "\u00D8") ) ), /* 4. Record Arm Button Row */ React.createElement("div", { className: "h-[28px] shrink-0 flex items-center justify-between mx-1.5 px-1.5 bg-black/40 rounded border border-slate-800/60" }, React.createElement("button", { onClick: function(e) { e.stopPropagation(); if (!onUpdateTrack) return; if (track.inputSource?.deviceType === 'MICROPHONE') { onUpdateTrack(track.id, { inputSource: { deviceType: 'NONE', deviceId: '' } }); } else { onUpdateTrack(track.id, { inputSource: { deviceType: 'MICROPHONE', deviceId: 'default' } }); } }, className: "w-4 h-4 rounded flex items-center justify-center text-[7px] font-bold transition-all" + (isMicActive ? " bg-sky-600 text-white border border-sky-400 shadow-sm" : " bg-slate-800 text-slate-500 border border-slate-700"), title: isMicActive ? "Mic Input ON" : "Mic Input OFF" }, "MIC"), React.createElement("button", { onClick: function(e) { e.stopPropagation(); if (onUpdateTrack) onUpdateTrack(track.id, { isArmed: !track.isArmed }); }, className: "w-5 h-5 rounded-full flex items-center justify-center transition-all shadow-inner" + (isArmed ? " btn-arm-active border-red-400" : " bg-red-950 border-2 border-red-800 text-red-500"), title: "Arm for Recording" }, React.createElement("i", { className: "fa-solid fa-circle text-[8px]" })) ), /* 5. Track Name Identifier */ React.createElement("div", { className: "h-[26px] shrink-0 mx-1.5 flex items-center justify-center bg-slate-950/80 rounded border border-slate-800/80" }, React.createElement("span", { className: "text-[11px] font-bold tracking-wider font-sans uppercase", style: { color: trackColor } }, trackName) ), /* 6. Footer Bar */ React.createElement("div", { className: "h-[22px] shrink-0 w-full text-slate-950 flex items-center justify-center font-extrabold text-xs font-mono tracking-widest transition-colors", style: { backgroundColor: trackColor } }, index + 1) ); }; const WaveformLane = ({ track, zoom, timelineWidth, viewportWidth, onSelectRange, onPlayheadSet, isSelected, onSelectTrack, markers, selectionMode, localSelectionTrackId, localSelectionStart, currentTime, getLocalAnchor, onClearLocalSelection, onDeselectItem, onAddToSelection, onSetPendingDrag, onSetSelectionMode, onSetSelectionStart, onSetSelectionEnd, onSetCurrentTime, onSetLocalSelectionTrackId, onSetLocalSelectionStart, onSetLocalSelectionEnd, localSelLeft, localSelRight, onTrackLaneMouseDown, onContextMenu, onClipDragStart, onClipStretchStart, onSectionItemDragStart, onSectionItemResizeStart, onEditSectionInTab, onEditMidiInTab, onSelectionEdgeDragStart, setSelectedClipId, selectedClipId, activeTool, onSplitTrackAtTime, onEditClipInSubTab, selectedItemIds, onClearSelection, onSweepSelectStart, snapValue, bpm, scrollLeft, recordingState, recTempMidiNotes, recTempAudioBuffer, recStartTimelineTime, canvasRedrawCount }) => { const canvasRef = useRef(null); const drawWidth = Math.min(timelineWidth, viewportWidth); const leadInMargin = 0; useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; let scrollLeftVal = scrollLeft || 0; let el = canvas.parentElement; while (el) { if (el.scrollLeft !== undefined && (el.scrollWidth > el.clientWidth || el.scrollLeft > 0)) { scrollLeftVal = el.scrollLeft; break; } el = el.parentElement; } 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 beatDuration = (60.0 / (parseFloat(bpm) || 120)); const barDuration = beatDuration * 4; const leadIn = 0; const CLIP_BUFFER = Math.max(400, barDuration * zoom + 200); const PADDING_LEFT = 0; const tStart = (scrollLeftVal - leadIn * zoom) / zoom - PADDING_LEFT; const tEnd = (scrollLeftVal + drawWidth - leadIn * zoom) / zoom + CLIP_BUFFER / zoom; let snapDivisor = 1; if (snapValue && snapValue !== 'free') { if (snapValue === '4') snapDivisor = 4; else if (snapValue === '1') snapDivisor = 1; else if (snapValue === '1/2') snapDivisor = 0.5; else if (snapValue === '1/4') snapDivisor = 0.25; else if (snapValue === '1/8') snapDivisor = 0.125; else if (snapValue === '1/16') snapDivisor = 0.0625; else if (snapValue === '1/32') snapDivisor = 0.03125; } const snapInterval = beatDuration * snapDivisor; const firstSnap = Math.floor(tStart / snapInterval) * snapInterval; for (let t = firstSnap; t <= tEnd; t += snapInterval) { const localX = (t - tStart) * zoom; if (localX < -CLIP_BUFFER || localX > drawWidth + CLIP_BUFFER) continue; const barNum = Math.round(t / barDuration); const onBar = Math.abs(t - barNum * barDuration) < 0.001; ctx.strokeStyle = onBar ? 'rgba(255, 255, 255, 0.12)' : 'rgba(255, 255, 255, 0.04)'; ctx.lineWidth = onBar ? 1.2 : 0.5; ctx.beginPath(); ctx.moveTo(localX, 0); ctx.lineTo(localX, height); ctx.stroke(); if (onBar && zoom >= 2) { ctx.fillStyle = 'rgba(255, 255, 255, 0.15)'; ctx.font = 'bold 7px Inter, sans-serif'; ctx.textAlign = 'left'; ctx.fillText(`${barNum}`, localX + 2, 10); } } // 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 hasBuffer = !!clip.buffer; const clipSpeed = clip.speed || 1.0; const originalDuration = hasBuffer ? clip.buffer.duration / clipSpeed : 4.0; const duration = originalDuration; 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 - scrollLeftVal; const xEndLocal = xStartLocal + wClip; // 1. Draw Clip Layer Background & Border (always draw even without buffer) const clipIdentifier = clip.id === 'default' ? 'default_' + track.id : clip.id; const isClipSelected = (selectedClipId && selectedClipId.trackId === track.id && selectedClipId.clipId === clipIdentifier) || (selectedItemIds && selectedItemIds.has(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 - scrollLeftVal; if (mxLocal >= 0 && mxLocal <= drawWidth) { ctx.fillStyle = '#fbbf24'; ctx.fillRect(mxLocal - 1, 0, 2, height); } }); } if (!hasBuffer) { ctx.fillStyle = 'rgba(255, 255, 255, 0.15)'; ctx.font = 'italic 8px sans-serif'; ctx.fillText('(audio chưa được tải)', Math.max(xStartLocal + 8, 8), clipTop + clipHeight - 6); return; } 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; // 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 - scrollLeftVal; // use secStartLocal NOT secStart const secWidth = sec.duration * zoom; if (secStartLocal + secWidth < 0 || secStartLocal > drawWidth) return; var isSecSelected = selectedItemIds && selectedItemIds.has(sec.id); ctx.fillStyle = isSecSelected ? 'rgba(245, 158, 11, 0.35)' : (sec.color ? sec.color + '44' : 'rgba(6, 182, 212, 0.25)'); ctx.fillRect(secStartLocal, 2, secWidth, height - 4); ctx.strokeStyle = isSecSelected ? '#f59e0b' : (sec.color || '#06b6d4'); ctx.lineWidth = isSecSelected ? 2.5 : 1; ctx.setLineDash(isSecSelected ? [] : [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 sub-tracks within section const subTracks = sec.tracks || []; const subTrackCount = Math.min(subTracks.length, 4); const subTrackHeight = (height - 20) / Math.max(1, subTrackCount); const subColors = ['#fbbf24', '#a78bfa', '#ec4899', '#10b981']; ctx.save(); ctx.beginPath(); ctx.rect(secStartLocal, 2, secWidth, height - 4); ctx.clip(); for (let stIdx = 0; stIdx < subTrackCount; stIdx++) { const sub = subTracks[stIdx]; if (!sub) continue; const subY = 20 + stIdx * subTrackHeight; ctx.fillStyle = sub.color ? sub.color + '22' : subColors[stIdx] + '22'; ctx.fillRect(secStartLocal + 1, subY, secWidth - 2, subTrackHeight - 1); // Draw clips as waveform bars const subClips = sub.clips || []; subClips.forEach(cl => { if (!cl.buffer) return; const sr = cl.buffer.sampleRate; const bufData = cl.buffer.getChannelData(0); const bufLen = bufData.length; const clStartLocal = cl.startTime || 0; const clDurLocal = bufLen / sr / (cl.speed || 1.0); const clStartMain = secStartLocal + clStartLocal * zoom; const clW = Math.max(1, clDurLocal * zoom); const peakSamples = Math.max(10, Math.min(100, Math.floor(clW / 3))); const step = Math.max(1, Math.floor(bufLen / peakSamples)); for (let p = 0; p < peakSamples; p++) { const sIdx = p * step; let maxVal = 0; for (let j = 0; j < step && sIdx + j < bufLen; j++) { const abs = Math.abs(bufData[sIdx + j]); if (abs > maxVal) maxVal = abs; } const bx = clStartMain + (p / peakSamples) * clW; const barH = Math.max(1, maxVal * (subTrackHeight * 0.7)); const barY = subY + (subTrackHeight - barH) / 2; ctx.fillStyle = subColors[stIdx] + '99'; ctx.fillRect(bx, barY, Math.max(1, clW / peakSamples), barH); } }); // Draw MIDI items as colored note bars const subMidi = sub.midiItems || []; subMidi.forEach(item => { const itemStartLocal = secStartLocal + (item.startTime || 0) * zoom; const itemDurLocal = (item.duration || 1) * zoom; const notes = item.notes || []; const pitchMin = 36; const pitchMax = 84; notes.forEach(note => { const beatSec = 60.0 / (parseInt(bpm) || 120); const noteStartSec = (note.start_beat || 0) * beatSec; const noteDurSec = Math.max(0.02, (note.duration_beats || 0.25) * beatSec); const noteStartLocal = itemStartLocal + noteStartSec * zoom; const nw = noteDurSec * zoom; const pitchFrac = Math.max(0, Math.min(1, (note.pitch - pitchMin) / (pitchMax - pitchMin))); const ny = subY + 2 + (1.0 - pitchFrac) * (subTrackHeight - 6); const nh = Math.max(4, (subTrackHeight - 6) / (pitchMax - pitchMin) * 3); ctx.fillStyle = subColors[stIdx] + 'cc'; ctx.fillRect(Math.max(noteStartLocal, secStartLocal + 2), ny, Math.max(2, nw), nh); }); }); } ctx.restore(); }); // Draw MIDI items const midiItems = track.midiItems || []; midiItems.forEach(midi => { const midiStartLocal = midi.startTime * zoom - scrollLeftVal; const midiWidth = midi.duration * zoom; if (midiStartLocal + midiWidth < 0 || midiStartLocal > drawWidth) return; const isRecordingItem = midi.name === 'Recording...'; if (isRecordingItem && Math.random() < 0.05) { console.log(`[DevLog] [Canvas Draw] Rendering MIDI item: ID=${midi.id}, Name="${midi.name}", Start=${midiStartLocal.toFixed(1)}px, Width=${midiWidth.toFixed(1)}px, NotesCount=${midi.notes?.length || 0}`); } var isMidiSelected = selectedItemIds && selectedItemIds.has(midi.id); ctx.fillStyle = isMidiSelected ? 'rgba(245, 158, 11, 0.35)' : (isRecordingItem ? 'rgba(239, 68, 68, 0.2)' : '#a78bfa33'); ctx.fillRect(midiStartLocal, 2, midiWidth, height - 4); ctx.strokeStyle = isMidiSelected ? '#f59e0b' : (isRecordingItem ? '#ef4444' : '#a78bfa'); ctx.lineWidth = isMidiSelected ? 2.5 : 1.5; ctx.strokeRect(midiStartLocal, 2, midiWidth, height - 4); ctx.fillStyle = isRecordingItem ? '#fca5a5' : '#c4b5fd'; ctx.font = 'bold 9px sans-serif'; ctx.fillText(isRecordingItem ? '[Ghi MIDI...]' : (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 - scrollLeftVal; 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 = isRecordingItem ? '#10b981' : '#a78bfa'; ctx.fillRect(Math.max(noteStartLocal, midiStartLocal + 2), 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 - scrollLeftVal; 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, currentTime, selectedItemIds]); 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 = Math.max(0, x / zoom - leadInMargin); 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 - leadInMargin); 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.shiftKey) { e.preventDefault(); e.stopPropagation(); if (e.ctrlKey) { // Ctrl+Click: toggle selection (add/remove from group) + set pending copy-drag var preToggle = selectedItemIds ? new Set(selectedItemIds) : new Set(); if (selectedItemIds && selectedItemIds.has(hitItem.id)) { if (onDeselectItem) onDeselectItem(hitItem.id); } else { if (onAddToSelection) onAddToSelection(hitItem.id); } if (onSetPendingDrag) onSetPendingDrag(track.id, hitItem.type, hitItem.id, time - hitItem.start, e.nativeEvent || e, preToggle); } else { // Click: select this item (clear others if not already selected), then start drag if (!selectedItemIds || !selectedItemIds.has(hitItem.id)) { if (onClearSelection) onClearSelection(); if (onAddToSelection) onAddToSelection(hitItem.id); } // Drag all currently selected items (or just this one) var dragIds = (selectedItemIds && selectedItemIds.has(hitItem.id) && selectedItemIds.size > 1) ? selectedItemIds : new Set([hitItem.id]); if (onSectionItemDragStart) onSectionItemDragStart(track.id, hitItem.type, hitItem.id, time - hitItem.start, false, dragIds); } 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 (e.ctrlKey) { // Ctrl+Click: toggle selection, store pending drag w/ pre-toggle snapshot var clipCanonicalId = clickedClip.id === 'default' ? 'default_' + track.id : clickedClip.id; var clipPreToggleSnapshot = selectedItemIds ? new Set(selectedItemIds) : new Set(); if (selectedItemIds && selectedItemIds.has(clipCanonicalId)) { if (onDeselectItem) onDeselectItem(clipCanonicalId); } else if (onAddToSelection) { onAddToSelection(clipCanonicalId); } if (onSetPendingDrag) onSetPendingDrag(track.id, 'clip', clipCanonicalId, time - clickedClip.startTime, e.nativeEvent || e, clipPreToggleSnapshot); } else { // Alt+Click: move immediately if (onClipDragStart) onClipDragStart(track.id, clickedClip.id, time - clickedClip.startTime, false); } return; } // Ctrl+Click on empty space: deselect all on click, marquee on drag if (e.ctrlKey && !clickedClip && !hitItem) { e.preventDefault(); e.stopPropagation(); // Start a pending sweep: mouseup with no drag → deselect all; drag → marquee if (onSweepSelectStart) onSweepSelectStart(track.id, time, e.clientY); 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 - leadInMargin); 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 - leadInMargin); // 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 TimelineRuler = ({ bpm, zoom, timelineWidth, viewportWidth, onPlayheadSet, snapValue, onRulerMouseDown, scrollLeft, canvasRedrawCount }) => { const canvasRef = useRef(null); const RULER_HEIGHT = 40; const drawWidth = Math.min(timelineWidth, viewportWidth); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; let scrollLeftVal = scrollLeft || 0; const height = RULER_HEIGHT; 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 = '#242424'; ctx.fillRect(0, 0, drawWidth, height); ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, height - 0.5); ctx.lineTo(drawWidth, height - 0.5); ctx.stroke(); const CLIP_BUFFER = 400; const PADDING_LEFT = 0; const tStart = scrollLeftVal / zoom - PADDING_LEFT; const tEnd = (scrollLeftVal + drawWidth) / zoom + CLIP_BUFFER / zoom; // Draw bar markers (aligned with TempoTrackLane) const beatDuration = 60 / bpm; const barDuration = beatDuration * 4; const firstBarNum = Math.floor(tStart / barDuration); const lastBarNum = Math.ceil(tEnd / barDuration); for (let bn = firstBarNum; bn <= lastBarNum; bn++) { const t = bn * barDuration; const localX = (t - tStart) * zoom; if (localX < -CLIP_BUFFER || localX > drawWidth + CLIP_BUFFER) continue; ctx.strokeStyle = 'rgba(255, 255, 255, 0.25)'; ctx.lineWidth = 1.2; ctx.beginPath(); ctx.moveTo(localX, 0); ctx.lineTo(localX, height); ctx.stroke(); ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; ctx.font = 'bold 10px Inter, sans-serif'; ctx.textAlign = 'center'; ctx.fillText(`${bn}`, localX, 32); } // Draw time duration labels with drag-selection markers const minTimePx = 60; const rawSecInt = Math.max(1, Math.ceil(minTimePx / zoom)); const timePowers = [1, 2, 5, 10, 30, 60]; let secInterval = timePowers.find(p => p >= rawSecInt) || 120; if (secInterval * zoom < minTimePx) secInterval = Math.ceil(minTimePx / zoom); const firstSec = Math.floor(tStart / secInterval) * secInterval; for (let t = firstSec; t <= tEnd; t += secInterval) { const localX = (t - tStart) * zoom; if (localX < -CLIP_BUFFER || localX > drawWidth + CLIP_BUFFER) continue; ctx.strokeStyle = 'rgba(255, 180, 100, 0.12)'; ctx.lineWidth = 0.8; ctx.beginPath(); ctx.moveTo(localX, 0); ctx.lineTo(localX, height); ctx.stroke(); ctx.strokeStyle = 'rgba(255, 180, 100, 0.3)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(localX, 0); ctx.lineTo(localX, 10); ctx.stroke(); ctx.fillStyle = 'rgba(255, 180, 100, 0.7)'; ctx.font = 'bold 11px monospace'; ctx.textAlign = 'center'; ctx.fillText(formatTimeSimple(t), localX, 24); } }, [bpm, zoom, timelineWidth, viewportWidth, scrollLeft, canvasRedrawCount]); return React.createElement(React.Fragment, null, React.createElement("div", { key: "virtual-spacer-ruler", style: { width: `${timelineWidth}px`, height: '1px', pointerEvents: 'none' } }), 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); } })); }; const TempoTrackLane = ({ bpm, zoom, timelineWidth, viewportWidth, onPlayheadSet, snapValue, onRulerMouseDown, scrollLeft, canvasRedrawCount, leadInMargin: propLeadIn }) => { 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; // Use scrollLeft prop directly (DOM traversal broken by sticky wrapper) let scrollLeftVal = scrollLeft || 0; const height = 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 leadIn = propLeadIn !== undefined ? propLeadIn : 0; const CLIP_BUFFER = Math.max(400, barDuration * zoom + 200); const PADDING_LEFT = 0; const tStart = (scrollLeftVal - leadIn * zoom) / zoom - PADDING_LEFT; const tEnd = (scrollLeftVal + drawWidth - leadIn * zoom) / zoom + CLIP_BUFFER / zoom; const firstBeatNum = Math.floor(tStart / beatDuration); const lastBeatNum = Math.ceil(tEnd / beatDuration); for (let bn = firstBeatNum; bn <= lastBeatNum; bn++) { const t = bn * beatDuration; const beatNum = bn + 1; const isBar = beatNum % 4 === 1; const localX = (t - tStart) * zoom; if (localX < -CLIP_BUFFER || localX > drawWidth + CLIP_BUFFER) continue; 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.floor((beatNum - 1) / 4)}`, localX + 3, 11); } else { ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(localX, 0); ctx.lineTo(localX, height); ctx.stroke(); } ctx.fillStyle = 'rgba(255, 255, 255, 0.35)'; ctx.font = '7px Inter, sans-serif'; const bar = Math.floor((beatNum - 1) / 4); const beat = ((beatNum - 1) % 4) + 1; ctx.fillText(`${bar}:${beat}`, localX + 2, height - 3); } // 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 === '4') divisor = 4; else 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 firstSnapNum = Math.floor(tStart / snapInterval); const lastSnapNum = Math.ceil(tEnd / snapInterval); for (let sn = firstSnapNum; sn <= lastSnapNum; sn++) { const t = sn * 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(); } } } } // Draw time duration labels (restored from original time ruler) const minTimePx = 60; const rawSecInterval = Math.max(1, Math.ceil(minTimePx / zoom)); const timePowers = [1, 2, 5, 10, 30, 60]; let secInterval = timePowers.find(p => p >= rawSecInterval) || 120; if (secInterval * zoom < minTimePx) secInterval = Math.ceil(minTimePx / zoom); const firstSec = Math.floor(tStart / secInterval) * secInterval; for (let t = firstSec; t <= tEnd; t += secInterval) { const localX = (t - tStart) * zoom; if (localX < -CLIP_BUFFER || localX > drawWidth + CLIP_BUFFER) continue; ctx.strokeStyle = 'rgba(255, 180, 100, 0.15)'; ctx.lineWidth = 0.8; ctx.beginPath(); ctx.moveTo(localX, 16); ctx.lineTo(localX, height); ctx.stroke(); ctx.fillStyle = 'rgba(255, 180, 100, 0.5)'; ctx.font = '8px monospace'; ctx.textAlign = 'center'; ctx.fillText(formatTimeSimple(t), localX, 12); } 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, canvasRedrawCount, propLeadIn]); 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 snapValue = 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 = snapValue(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 ? +snapValue(valFromY(moveEvent.clientY - rect.top)).toFixed(2) : +snapValue(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: +snapValue(valFromY(e.clientY - rect.top)).toFixed(2) } : { time: +snapTime(startTime).toFixed(3), db: +snapValue(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 = snapValue(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: "4" }, "4"), /*#__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 rearrangeNewIdx = sorted.findIndex(n => n.time === newNode.time && (graphMode === 'pan' ? n.pan : n.db) === (graphMode === 'pan' ? newNode.pan : newNode.db)); isDraggingNode.current = true; dragNodeIdx.current = rearrangeNewIdx; 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 rearrangeNewId = 'provider_' + Date.now(); setProviders(prev => [...prev, { id: rearrangeNewId, 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(rearrangeNewId); }, 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 PluginManagerModal = ({ isOpen, onClose, pluginsData }) => { if (!isOpen) return null; const [localData, setLocalData] = React.useState(pluginsData); const [sfUploadStatus, setSfUploadStatus] = React.useState(''); const [sfToDelete, setSfToDelete] = React.useState(null); React.useEffect(() => { if (isOpen) { window.SonicAPI.listPlugins() .then(data => setLocalData(data)) .catch(() => setLocalData({ vst_instruments: [], soundfonts: [] })); setTimeout(() => { try { window.lucide.createIcons(); } catch(e) {} }, 50); } }, [isOpen]); const handleUploadSF = async (e) => { const file = e.target.files?.[0]; if (!file) return; setSfUploadStatus('Uploading...'); try { const result = await window.SonicAPI.uploadSoundFont(file); setSfUploadStatus('Uploaded: ' + result.name); // Refresh plugin list and catalog const data = await window.SonicAPI.listPlugins(); setLocalData(data); try { const cat = await window.SonicAPI.getSoundfontCatalog(); window.__soundfontCatalog = cat; } catch (_) {} } catch (err) { setSfUploadStatus('Error: ' + err.message); } }; const [pmTab, setPmTab] = React.useState('soundfont'); return React.createElement('div', { className: 'fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm', onClick: onClose }, React.createElement('div', { className: 'bg-[#1e1e1e] border border-[#383838] rounded-xl shadow-2xl w-full max-w-3xl p-0 text-slate-200 overflow-hidden flex flex-col', style: { maxHeight: '80vh' }, onClick: e => e.stopPropagation() }, // Header React.createElement('div', { className: 'flex items-center justify-between px-5 py-3 bg-[#252525] border-b border-[#383838]' }, React.createElement('h3', { className: 'text-base font-bold text-cyan-400 flex items-center gap-2' }, React.createElement('i', { 'data-lucide': 'zap', className: 'w-4 h-4' }), 'Plugin Manager (SoundFont / VSTi)'), React.createElement('button', { onClick: onClose, className: 'text-zinc-500 hover:text-zinc-200 transition' }, React.createElement('i', { 'data-lucide': 'x', className: 'w-4 h-4' })) ), // Left-right body React.createElement('div', { className: 'flex flex-1 overflow-hidden', style: { minHeight: '300px' } }, // Left sidebar React.createElement('div', { className: 'w-40 shrink-0 border-r border-[#383838] bg-[#1a1a1a] p-3 flex flex-col gap-2' }, ['vst', 'soundfont'].map(tab => React.createElement('button', { key: tab, onClick: () => setPmTab(tab), className: `w-full py-2 text-xs font-bold rounded transition border ${pmTab === tab ? (tab === 'vst' ? 'bg-violet-900 border-violet-700 text-violet-200' : 'bg-amber-900 border-amber-700 text-amber-200') : 'bg-zinc-800 border-transparent text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700'} flex items-center gap-2 px-3` }, React.createElement('i', { 'data-lucide': tab === 'vst' ? 'cpu' : 'music', className: 'w-3.5 h-3.5' }), tab === 'vst' ? 'VST Instruments' : 'SoundFonts') ) ), // Right content React.createElement('div', { className: 'flex-1 overflow-y-auto p-4 bg-[#1e1e1e]' }, !localData ? React.createElement('div', { className: 'flex items-center justify-center h-full text-zinc-500 text-xs' }, 'Loading...') : React.createElement('div', { className: 'space-y-2' }, pmTab === 'vst' ? (localData.vst_instruments?.length === 0 ? React.createElement('div', { className: 'flex items-center justify-center h-32 text-zinc-500 text-xs' }, 'No VST instruments found on server.') : localData.vst_instruments.map((v, i) => React.createElement('div', { key: i, className: 'flex items-center justify-between bg-[#252525] px-4 py-3 rounded-lg border border-[#333] hover:border-violet-800/50 transition' }, React.createElement('div', { className: 'flex items-center gap-3' }, React.createElement('div', { className: 'w-8 h-8 rounded bg-violet-900/30 flex items-center justify-center' }, React.createElement('i', { 'data-lucide': 'cpu', className: 'w-4 h-4 text-violet-400' }) ), React.createElement('div', null, React.createElement('div', { className: 'text-xs font-semibold text-slate-200' }, v.name || v.id), React.createElement('div', { className: 'text-[10px] text-zinc-500' }, v.type || 'VST3') ) ), React.createElement('span', { className: 'text-[10px] bg-violet-950/40 text-violet-400 px-2 py-0.5 rounded-full border border-violet-800/30' }, v.type || 'VST3') ) ) ) : (localData.soundfonts?.length === 0 ? React.createElement('div', { className: 'flex items-center justify-center h-32 text-zinc-500 text-xs' }, 'No SoundFonts found. Upload one below.') : localData.soundfonts.map((sf, i) => React.createElement('div', { key: i, className: 'flex items-center justify-between bg-[#252525] px-4 py-3 rounded-lg border border-[#333] hover:border-amber-800/50 transition group' }, React.createElement('div', { className: 'flex items-center gap-3' }, React.createElement('div', { className: 'w-8 h-8 rounded bg-amber-900/30 flex items-center justify-center' }, React.createElement('i', { 'data-lucide': 'music', className: 'w-4 h-4 text-amber-400' }) ), React.createElement('div', null, React.createElement('div', { className: 'text-xs font-semibold text-slate-200' }, sf.display || sf.name || sf.id), React.createElement('div', { className: 'text-[10px] text-zinc-500' }, sf.file || sf.name) ) ), React.createElement('div', { className: 'flex items-center gap-2' }, React.createElement('button', { onClick: () => setSfToDelete(sf), className: 'text-[10px] text-zinc-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition px-2 py-1' }, 'Delete') ) ) )) ), // Upload section (bottom of right panel) React.createElement('div', { className: 'pt-4 mt-4 border-t border-[#383838]' }, React.createElement('h4', { className: 'text-xs font-bold text-zinc-400 mb-3 uppercase' }, pmTab === 'vst' ? 'Add VST Directory' : 'Upload SoundFont' ), pmTab === 'vst' ? React.createElement('div', { className: 'flex gap-2' }, React.createElement('input', { type: 'text', placeholder: '/opt/daw_engine/vst3', className: 'flex-1 bg-zinc-800 border border-zinc-700 rounded px-3 py-2 text-xs text-zinc-300 focus:outline-none focus:border-violet-600' }), React.createElement('button', { className: 'px-4 py-2 bg-violet-800 hover:bg-violet-700 text-white text-xs font-semibold rounded transition', onClick: async () => { try { const data = await window.SonicAPI.listPlugins(); setLocalData(data); showToast('Scanned VST directory.', 'info'); } catch (err) { showToast('Scan failed: ' + err.message, 'error'); } } }, 'Scan') ) : React.createElement('div', { className: 'space-y-2' }, React.createElement('label', { className: 'flex items-center gap-3 px-4 py-3 border-2 border-dashed border-zinc-700 rounded-lg cursor-pointer hover:border-amber-600/50 bg-zinc-800/40 transition' }, React.createElement('i', { 'data-lucide': 'upload', className: 'w-5 h-5 text-zinc-400' }), React.createElement('span', { className: 'text-xs text-zinc-400' }, 'Click to upload .sf2 / .sf3 file'), React.createElement('input', { type: 'file', accept: '.sf2,.sf3', onChange: async (e) => { const file = e.target.files?.[0]; if (!file) return; setSfUploadStatus('Uploading...'); try { await window.SonicAPI.uploadSoundFont(file); setSfUploadStatus('Uploaded: ' + file.name); const data = await window.SonicAPI.listPlugins(); setLocalData(data); } catch (err) { setSfUploadStatus('Error: ' + err.message); } }, className: 'hidden' }) ), sfUploadStatus && React.createElement('p', { className: 'text-[10px] text-zinc-500' }, sfUploadStatus) ) ) ) ) ), // Status bar at bottom React.createElement('div', { className: 'px-5 py-2 bg-[#1a1a1a] border-t border-[#383838] flex items-center justify-between text-[10px] text-zinc-500' }, React.createElement('span', null, 'VST: ', localData?.vst_instruments?.length || 0, ' | SoundFonts: ', localData?.soundfonts?.length || 0), React.createElement('span', null, 'Last scanned: ', new Date().toLocaleTimeString()) ), // Delete confirmation modal sfToDelete && React.createElement('div', { className: 'fixed inset-0 z-[60] flex items-center justify-center bg-black/70', onClick: () => setSfToDelete(null) }, React.createElement('div', { className: 'bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-sm p-5 text-slate-200', onClick: e => e.stopPropagation() }, React.createElement('h3', { className: 'text-sm font-bold text-red-400 mb-3' }, 'Delete SoundFont?'), React.createElement('p', { className: 'text-xs text-zinc-400 mb-1' }, 'Are you sure you want to delete:'), React.createElement('p', { className: 'text-sm font-semibold text-slate-200 mb-4' }, sfToDelete.display || sfToDelete.name || sfToDelete.id), React.createElement('div', { className: 'flex justify-end gap-2' }, React.createElement('button', { onClick: () => setSfToDelete(null), className: 'px-4 py-2 text-xs rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300 transition' }, 'Cancel'), React.createElement('button', { onClick: async () => { try { if (window.SonicAPI.deleteSoundFont) { await window.SonicAPI.deleteSoundFont(sfToDelete.id); } const data = await window.SonicAPI.listPlugins(); setLocalData(data); setSfToDelete(null); window.showToast && window.showToast('SoundFont deleted.', 'info'); } catch (err) { window.showToast && window.showToast('Delete failed: ' + err.message, 'error'); setSfToDelete(null); } }, className: 'px-4 py-2 text-xs rounded bg-red-700 hover:bg-red-600 text-white font-semibold transition' }, 'Delete') ) ) ) ); }; const ProfileModal = ({ isOpen, onClose, tracks, setTracks, setSelectedTrackId, projectName, setProjectName, currentProjectId, setCurrentProjectId, showToast, loadAudioBuffersForTracks }) => { 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); // Backup state const [expandedBackupId, setExpandedBackupId] = useState(null); const [backupsMap, setBackupsMap] = useState({}); // project_id -> [backups] const [loadingBackups, setLoadingBackups] = useState({}); // project_id -> bool const [backupMaxCount, setBackupMaxCount] = useState(() => parseInt(localStorage.getItem('sonic_backup_max_count') || '10')); const [showBackupConfig, setShowBackupConfig] = useState(false); 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 fetchBackups = async (projectId) => { setLoadingBackups(prev => ({ ...prev, [projectId]: true })); try { const data = await window.SonicAPI.listBackups(projectId); setBackupsMap(prev => ({ ...prev, [projectId]: data || [] })); } catch (e) { showToast(e.message || 'Không thể tải danh sách backup', 'error'); } finally { setLoadingBackups(prev => ({ ...prev, [projectId]: false })); } }; const handleDeleteBackup = async (backupId) => { try { await window.SonicAPI.deleteBackup(backupId); setBackupsMap(prev => { const next = { ...prev }; Object.keys(next).forEach(pid => { next[pid] = next[pid].filter(b => b.id !== backupId); }); return next; }); fetchProjects(); showToast('Đã xóa bản backup', 'info'); } catch (e) { showToast(e.message || 'Lỗi xóa backup', 'error'); } }; const handleCleanupBackups = async () => { try { const res = await window.SonicAPI.cleanupBackups(backupMaxCount); setBackupsMap({}); fetchProjects(); showToast(`Đã dọn dẹp ${res.deleted} bản backup cũ (giữ lại ${res.keep})`, 'info'); } catch (e) { showToast(e.message || 'Lỗi dọn dẹp backup', 'error'); } }; const handleOpenProject = async (projectId, projectName) => { 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 = []; let restoredSubTabs = []; if (parsed.main_session) { const result = deserializeProjectFromSchema(parsed); restoredTracks = result.tracks; restoredBpm = result.bpm; restoredSessionTabs = result.sessionTabs; restoredSubTabs = result.subTabs; if (result.masteringSettings) { setMasteringSettings(result.masteringSettings); } else { setMasteringSettings({ masterConnected: false, activeModule: 'eq', eqActive: true, imagerActive: true, maximizerActive: true, eqLowGain: 1.5, eqMid1Gain: -1.0, eqMid2Gain: 2.0, eqHighGain: 1.8, w1: 0, w2: 15, w3: 35, w4: 50, maxGain: 5.4, maxUpward: 2.0, maxSoftClip: 15, maxTransient: 25, ceiling: -0.1, isBypassed: false }); } } 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); loadAudioBuffersForTracks(restoredTracks).catch(function(err) { console.warn('loadAudioBuffersForTracks error:', err); }); setBpm(restoredBpm.toString()); setSelectedTrackId(restoredTracks[0]?.id || '1'); setProjectName(proj.name); setCurrentProjectId(proj.id); if (restoredSessionTabs.length > 0) { setSessionTabs(restoredSessionTabs); } if (restoredSubTabs.length > 0) { setSubTabs(restoredSubTabs); } 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" }, confirmModal.cancelText || "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" }, confirmModal.confirmText || "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(React.Fragment, { key: "list" }, [ /*#__PURE__*/React.createElement("div", { key: "backup-config-bar", className: "flex items-center justify-between bg-zinc-900/60 p-2 rounded border border-zinc-800 mb-2 text-xs" }, [ /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-2" }, [ /*#__PURE__*/React.createElement("span", { className: "text-zinc-400 text-[10px]" }, "⚙️ Tự động lưu 5 phút / Backup 30 phút"), /*#__PURE__*/React.createElement("button", { onClick: (e) => { e.stopPropagation(); setShowBackupConfig(!showBackupConfig); }, className: "px-2 py-0.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded text-[10px] border border-zinc-700" }, showBackupConfig ? "ẨN" : "CẤU HÌNH") ]), /*#__PURE__*/React.createElement("button", { onClick: (e) => { e.stopPropagation(); handleCleanupBackups(); }, className: "px-2 py-0.5 bg-amber-800 hover:bg-amber-700 text-amber-200 rounded text-[10px] border border-amber-700" }, "🧹 DỌN BACKUP") ]), showBackupConfig && /*#__PURE__*/React.createElement("div", { key: "backup-config-detail", className: "bg-[#18181b] border border-zinc-800 rounded p-3 mb-2 text-xs" }, [ /*#__PURE__*/React.createElement("div", { className: "flex items-center justify-between mb-2" }, [ /*#__PURE__*/React.createElement("label", { className: "text-zinc-300 font-semibold" }, "Số bản backup tối đa:"), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-2" }, [ /*#__PURE__*/React.createElement("input", { type: "range", min: 5, max: 20, value: backupMaxCount, onChange: (e) => { const v = parseInt(e.target.value); setBackupMaxCount(v); localStorage.setItem('sonic_backup_max_count', v.toString()); }, className: "w-24 accent-amber-500" }), /*#__PURE__*/React.createElement("span", { className: "text-amber-400 font-bold w-6 text-center" }, backupMaxCount) ]) ]), /*#__PURE__*/React.createElement("p", { className: "text-[10px] text-zinc-500" }, "Mỗi dự án sẽ giữ tối đa số bản backup này. Backup cũ nhất sẽ tự động bị xóa khi vượt quá giới hạn.") ]), /*#__PURE__*/React.createElement("div", { className: "space-y-1.5" }, projectsList.map(proj => /*#__PURE__*/React.createElement("div", { key: proj.id }, [ /*#__PURE__*/React.createElement("div", { 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()}`, proj.backup_count > 0 && ` | Backup: ${proj.backup_count}` ]) ]), /*#__PURE__*/React.createElement("div", { key: "actions", className: "flex items-center gap-1.5" }, [ /*#__PURE__*/React.createElement("button", { onClick: (e) => { e.stopPropagation(); if (expandedBackupId === proj.id) { setExpandedBackupId(null); } else { setExpandedBackupId(proj.id); fetchBackups(proj.id); } }, className: `px-2 py-1 rounded font-semibold text-[10px] border ${expandedBackupId === proj.id ? 'bg-amber-800/80 text-amber-200 border-amber-700' : 'bg-zinc-800 hover:bg-zinc-700 text-zinc-400 border-zinc-700'}` }, `Backup (${proj.backup_count})`), /*#__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") ]) ]), expandedBackupId === proj.id && /*#__PURE__*/React.createElement("div", { key: "backup-list", className: "ml-4 pl-3 border-l-2 border-amber-800/50 bg-[#161618] rounded-b p-2 mb-1" }, [ loadingBackups[proj.id] ? /*#__PURE__*/React.createElement("div", { className: "text-[10px] text-zinc-500 py-2 text-center" }, "Đang tải...") : (!backupsMap[proj.id] || backupsMap[proj.id].length === 0) ? /*#__PURE__*/React.createElement("div", { className: "text-[10px] text-zinc-500 py-2 text-center" }, "Chưa có bản backup nào.") : /*#__PURE__*/React.createElement("div", { className: "space-y-1 max-h-48 overflow-y-auto" }, backupsMap[proj.id].map(b => /*#__PURE__*/React.createElement("div", { key: b.id, className: "flex items-center justify-between py-1.5 px-2 bg-[#1e1e22] rounded border border-zinc-800" }, [ /*#__PURE__*/React.createElement("div", { key: "info", className: "flex-1 min-w-0" }, [ /*#__PURE__*/React.createElement("div", { className: "text-[10px] text-zinc-300 truncate" }, b.name), /*#__PURE__*/React.createElement("div", { className: "text-[9px] text-zinc-500 mt-0.5" }, `${b.size_mb} MB | ${new Date(b.created_at * 1000).toLocaleString()}`) ]), /*#__PURE__*/React.createElement("button", { onClick: (e) => { e.stopPropagation(); handleDeleteBackup(b.id); }, className: "px-1.5 py-0.5 bg-rose-950 hover:bg-rose-900 text-rose-300 rounded text-[9px] border border-rose-900 ml-2 shrink-0" }, "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, onSaveCloud, onSaveLocal, projectName }) => { if (!isOpen) return null; const [name, setName] = useState(projectName || ''); const [saveType, setSaveType] = useState(localStorage.getItem('sonic_token') ? 'cloud' : 'local'); const [cloudProjects, setCloudProjects] = useState([]); const [loading, setLoading] = useState(false); const [selectedExisting, setSelectedExisting] = useState(null); const [confirmOverwriteProject, setConfirmOverwriteProject] = useState(null); React.useEffect(function() { if (!isOpen) return; if (saveType === 'cloud' && window.SonicAPI && window.SonicAPI.listCloudProjects) { setLoading(true); window.SonicAPI.listCloudProjects().then(function(data) { setCloudProjects(data || []); }).catch(function() { setCloudProjects([]); }).finally(function() { setLoading(false); }); } }, [isOpen, saveType]); const handleSubmit = (e) => { e.preventDefault(); if (!name.trim()) return; var matched = null; for (var i = 0; i < cloudProjects.length; i++) { if (cloudProjects[i].name === name.trim()) { matched = cloudProjects[i]; break; } } if (matched) { setConfirmOverwriteProject(matched); } else { if (saveType === 'cloud') { onSaveCloud(name.trim(), null); onClose(); } else { onSaveLocal(name.trim()); onClose(); } } }; if (confirmOverwriteProject) { return React.createElement("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" }, React.createElement("div", { className: "bg-[#262626] border border-[#383838] rounded-lg shadow-2xl p-5 max-w-sm w-full text-slate-200" }, React.createElement("h4", { className: "text-sm font-bold text-amber-400 mb-2" }, "Ghi \u0111\u00E8 d\u1EF1 \u00E1n"), React.createElement("p", { className: "text-xs text-slate-300 mb-4" }, "D\u1EF1 \u00E1n \"", confirmOverwriteProject.name, "\" \u0111\u00E3 t\u1ED3n t\u1EA1i tr\u00EAn Cloud. B\u1EA1n c\u00F3 ch\u1EAFc ch\u1EAFn mu\u1ED1n ghi \u0111\u00E8 n\u00F3 kh\u00F4ng?"), React.createElement("div", { className: "flex justify-end gap-2" }, React.createElement("button", { type: "button", onClick: function() { setConfirmOverwriteProject(null); }, className: "px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 text-slate-300 rounded text-xs font-semibold" }, "Quay l\u1EA1i"), React.createElement("button", { type: "button", onClick: function() { setConfirmOverwriteProject(null); onSaveCloud(name.trim(), confirmOverwriteProject.id); onClose(); }, className: "px-3 py-1.5 bg-rose-700 hover:bg-rose-600 text-white rounded text-xs font-semibold" }, "X\u00E1c nh\u1EADn ghi \u0111\u00E8")))); } var filtered = cloudProjects.filter(function(p) { return p.name.toLowerCase().includes(name.toLowerCase()); }); return /*#__PURE__*/React.createElement("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" }, /*#__PURE__*/React.createElement("div", { className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-lg p-6 text-slate-200" }, /*#__PURE__*/React.createElement("h3", { className: "text-sm font-bold text-teal-400 mb-4 uppercase" }, "L\u01B0u d\u1EF1 \u00E1n"), /*#__PURE__*/React.createElement("form", { onSubmit: handleSubmit, className: "space-y-4" }, [ /*#__PURE__*/React.createElement("div", { key: "type-block" }, [ /*#__PURE__*/React.createElement("div", { className: "grid grid-cols-2 gap-2 text-xs" }, [ /*#__PURE__*/React.createElement("button", { key: "btn-cloud", type: "button", onClick: function() { setSaveType('cloud'); setSelectedExisting(null); }, 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" }, "\u2601\uFE0F L\u01B0u Cloud"), /*#__PURE__*/React.createElement("span", { key: "desc", className: "text-[9px] font-normal text-zinc-500" }, "L\u01B0u l\u00EAn server")]), /*#__PURE__*/React.createElement("button", { key: "btn-local", type: "button", onClick: function() { setSaveType('local'); setSelectedExisting(null); }, 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" }, "\U0001F5C2\uFE0F L\u01B0u Local"), /*#__PURE__*/React.createElement("span", { key: "desc", className: "text-[9px] font-normal text-zinc-500" }, "L\u01B0u xu\u1ED1ng t\u1EC7p .sfs")]) ]) ]), /*#__PURE__*/React.createElement("div", { key: "name-block" }, [ /*#__PURE__*/React.createElement("label", { className: "block text-[10px] text-zinc-400 uppercase font-bold mb-1" }, "T\u00EAn d\u1EF1 \u00E1n"), /*#__PURE__*/React.createElement("input", { key: "name-input", type: "text", placeholder: "Nh\u1EADp t\u00EAn d\u1EF1 \u00E1n...", required: true, value: name, onChange: function(e) { setName(e.target.value); setSelectedExisting(null); }, 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 }) ]), saveType === 'cloud' && /*#__PURE__*/React.createElement("div", { key: "cloud-list", className: "max-h-40 overflow-y-auto -mx-1" }, loading ? [/*#__PURE__*/React.createElement("div", { key: "l", className: "text-center py-3 text-xs text-zinc-500" }, "\u0110ang t\u1EA3i danh s\u00E1ch d\u1EF1 \u00E1n...")] : filtered.length === 0 ? [/*#__PURE__*/React.createElement("div", { key: "e", className: "text-center py-3 text-xs text-zinc-500" }, "Ch\u01B0a c\u00F3 d\u1EF1 \u00E1n n\u00E0o.")] : filtered.map(function(p) { var isSelected = selectedExisting && selectedExisting.id === p.id; var isExactMatch = p.name === name.trim(); return /*#__PURE__*/React.createElement("div", { key: p.id, onClick: function() { setName(p.name); setSelectedExisting(p); }, className: "flex items-center justify-between px-2 py-1.5 rounded cursor-pointer text-xs border transition " + (isSelected ? 'bg-amber-950/60 border-amber-600 text-amber-300' : 'bg-[#1e1e1e] hover:bg-zinc-800 border-transparent text-slate-300') }, [/*#__PURE__*/React.createElement("span", { key: "n", className: "font-semibold truncate" }, p.name), /*#__PURE__*/React.createElement("span", { key: "t", className: "text-[9px] text-zinc-500 shrink-0 ml-2" }, isExactMatch ? "S\u1EBD ghi \u0111\u00E8" : "Ch\u1ECDn")]); }) ), /*#__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\u1EE7y"), selectedExisting ? /*#__PURE__*/React.createElement("button", { key: "back", type: "button", onClick: function() { setName(''); setSelectedExisting(null); }, className: "px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 text-slate-300 rounded text-xs font-semibold" }, "Quay l\u1EA1i") : null, /*#__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" }, selectedExisting ? "Ghi \u0111\u00E8" : "L\u01B0u") ]) ]))); }; const OpenProjectModal = ({ isOpen, onClose, onOpenCloud, onOpenLocal }) => { if (!isOpen) return null; const [tab, setTab] = useState('cloud'); const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(false); React.useEffect(function() { if (!isOpen) return; if (tab === 'cloud') { setLoading(true); var api = window.SonicAPI; if (api && api.listCloudProjects) { api.listCloudProjects().then(function(data) { setProjects(data || []); }).catch(function() { setProjects([]); }).finally(function() { setLoading(false); }); } else { setLoading(false); } } }, [isOpen, tab]); return React.createElement("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm" }, React.createElement("div", { className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-lg p-6 text-slate-200" }, React.createElement("h3", { className: "text-sm font-bold text-teal-400 mb-4 uppercase" }, "Mở dự án"), React.createElement("div", { className: "grid grid-cols-2 gap-2 text-xs mb-4" }, [ React.createElement("button", { key: "cloud-tab", type: "button", onClick: function() { setTab('cloud'); }, className: "py-2 rounded border flex flex-col items-center gap-1 font-semibold transition " + (tab === 'cloud' ? 'bg-teal-950/60 border-teal-500 text-teal-300' : 'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400') }, [React.createElement("span", { key: "t" }, "☁️ Cloud"), React.createElement("span", { key: "d", className: "text-[9px] font-normal text-zinc-500" }, "Dự án trên server")]), React.createElement("button", { key: "local-tab", type: "button", onClick: function() { setTab('local'); }, className: "py-2 rounded border flex flex-col items-center gap-1 font-semibold transition " + (tab === 'local' ? 'bg-teal-950/60 border-teal-500 text-teal-300' : 'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400') }, [React.createElement("span", { key: "t" }, "💾 Local"), React.createElement("span", { key: "d", className: "text-[9px] font-normal text-zinc-500" }, "Tập tin .sfs trên máy")]) ]), tab === 'cloud' ? React.createElement("div", { className: "space-y-1.5 max-h-64 overflow-y-auto" }, loading ? [React.createElement("div", { key: "l", className: "text-center py-8 text-xs text-zinc-500" }, "Đang tải danh sách dự án...")] : projects.length === 0 ? [React.createElement("div", { key: "e", className: "text-center py-8 text-xs text-zinc-500" }, "Bạn chưa có dự án nào trên Cloud.")] : projects.map(function(p) { return React.createElement("div", { key: p.id, onClick: function() { onOpenCloud(p.id, p.name); }, 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" }, [ React.createElement("div", { key: "meta" }, [ React.createElement("div", { className: "font-bold text-slate-200 group-hover:text-teal-400" }, p.name), React.createElement("div", { className: "text-[10px] text-zinc-500 mt-0.5" }, "Dung lượng: " + (p.size_mb || 0) + " MB | Cập nhật: " + new Date((p.updated_at || 0) * 1000).toLocaleString()) ]), React.createElement("button", { onClick: function(e) { e.stopPropagation(); onOpenCloud(p.id, p.name); }, className: "px-2.5 py-1 bg-teal-800/80 hover:bg-teal-700 text-teal-200 rounded font-semibold text-[10px]" }, "MỞ") ]); }) ) : React.createElement("div", { className: "py-4 text-center text-xs text-zinc-400 space-y-3" }, [React.createElement("div", { key: "d", className: "text-zinc-500" }, "Chọn tệp .sfs để mở dự án từ Local."), React.createElement("button", { key: "b", onClick: function() { onOpenLocal(); }, className: "px-4 py-2 bg-teal-600 hover:bg-teal-500 text-white rounded font-semibold text-xs transition" }, "Chọn tệp .sfs ...")] ), React.createElement("div", { className: "flex justify-end gap-2 text-xs mt-4 pt-3 border-t border-zinc-800" }, React.createElement("button", { type: "button", onClick: onClose, className: "px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded" }, "Hủy")))); }; 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 AIPresetModal = ({ isOpen, onClose }) => { if (!isOpen) return null; const mgrRef = React.useRef(null); if (!mgrRef.current) mgrRef.current = new window.PromptTemplateManager(); const mgr = mgrRef.current; const [presets, setPresets] = React.useState(() => [...mgr.getPresets()]); const [search, setSearch] = React.useState(''); const [filterCategory, setFilterCategory] = React.useState('ALL'); const [showFavoritesOnly, setShowFavoritesOnly] = React.useState(false); const [editingPreset, setEditingPreset] = React.useState(null); const [syncing, setSyncing] = React.useState(false); const [formName, setFormName] = React.useState(''); const [formKeywords, setFormKeywords] = React.useState(''); const [formCategory, setFormCategory] = React.useState('Orchestral / Film Score'); const [formBars, setFormBars] = React.useState(8); const [formBpm, setFormBpm] = React.useState(120); const [formScale, setFormScale] = React.useState('C Minor'); const [formTemplate, setFormTemplate] = React.useState(''); const [showGeneratorModal, setShowGeneratorModal] = React.useState(false); const [selectedCategory, setSelectedCategory] = React.useState('Orchestral / Film Score'); const [isAddingCategory, setIsAddingCategory] = React.useState(false); const [newCategoryValue, setNewCategoryValue] = React.useState(''); const [categoriesVersion, setCategoriesVersion] = React.useState(0); const presetCategories = React.useMemo(() => { const fromPresets = [...new Set(presets.map(p => p.category).filter(Boolean))]; let saved = []; try { const raw = localStorage.getItem('midi_prompt_categories'); saved = raw ? JSON.parse(raw) : []; } catch (e) { saved = []; } return [...new Set([...fromPresets, ...saved])].sort(); }, [presets, categoriesVersion]); const addNewCategory = (cat) => { const val = (cat || '').trim(); if (!val) return; setFormCategory(val); setSelectedCategory(val); try { const raw = localStorage.getItem('midi_prompt_categories'); const list = raw ? JSON.parse(raw) : []; if (!list.includes(val)) { list.push(val); localStorage.setItem('midi_prompt_categories', JSON.stringify(list)); setCategoriesVersion(v => v + 1); } } catch (e) {} }; const refreshPresets = () => { setPresets([...mgr.getPresets()]); }; // Sync from backend on mount — merge into local presets, never overwrite React.useEffect(() => { if (!window.SonicAPI) return; setSyncing(true); window.SonicAPI.getAIPresets() .then(data => { if (!data || !data.presets || data.presets.length === 0) return; var existing = mgr.presets; var existingIds = new Set(existing.map(function(p) { return p.id; })); var merged = existing.slice(); data.presets.forEach(function(bp) { if (!existingIds.has(bp.id)) { merged.push(bp); existingIds.add(bp.id); } }); mgr.presets = merged; setPresets(merged); }) .catch(function() {}) .finally(function() { setSyncing(false); }); }, []); // Listen for GENERATOR_PRESET_DATA from the iframe generator modal React.useEffect(() => { const handleMessage = (event) => { if (event.data && event.data.type === 'GENERATOR_PRESET_DATA') { const d = event.data; setFormName(d.name || ''); setFormCategory(d.category || 'Orchestral / Film Score'); setSelectedCategory(d.category || 'Orchestral / Film Score'); setFormKeywords(d.keywords || ''); setFormBars(parseInt(d.default_bars) || 8); setFormBpm(parseInt(d.default_bpm) || 120); setFormScale(d.default_scale || 'C Minor'); setFormTemplate(d.template || ''); setEditingPreset('new'); setShowGeneratorModal(false); setIsAddingCategory(false); setNewCategoryValue(''); refreshPresets(); } }; window.addEventListener('message', handleMessage); return () => window.removeEventListener('message', handleMessage); }, []); const savePresets = (newPresets) => { setPresets(newPresets); mgr.presets = newPresets; mgr.savePresets(); // Sync to backend if available const userDefined = newPresets.filter(p => p.is_user_defined); if (window.SonicAPI && userDefined.length > 0) { userDefined.forEach(p => { window.SonicAPI.saveAIPreset(p).catch(() => {}); }); } }; const handleEdit = (p) => { setEditingPreset(p); setFormName(p.name); setFormKeywords(p.keywords.join(', ')); setFormCategory(p.category); setSelectedCategory(p.category); setFormBars(p.default_bars); setFormBpm(p.default_bpm); setFormScale(p.default_scale); setFormTemplate(p.system_instruction_template); setIsAddingCategory(false); setNewCategoryValue(''); }; const handleNew = () => { setEditingPreset('new'); setFormName(''); setFormKeywords(''); setFormCategory('Orchestral / Film Score'); setSelectedCategory('Orchestral / Film Score'); setFormBars(8); setFormBpm(120); setFormScale('C Minor'); setFormTemplate(''); setIsAddingCategory(false); setNewCategoryValue(''); }; const handleNewStructured = () => { refreshPresets(); setShowGeneratorModal(true); }; const handleToggleFav = (id) => { mgr.toggleFavorite(id); setPresets([...mgr.getPresets()]); const p = mgr.presets.find(x => x.id === id); if (p && p.is_user_defined && window.SonicAPI) { window.SonicAPI.saveAIPreset(p).catch(() => {}); } }; const handleDelete = (id) => { const p = presets.find(x => x.id === id); if (p && p.is_user_defined && window.SonicAPI) { window.SonicAPI.deleteAIPreset(id).catch(() => {}); } mgr.deletePreset(id); setPresets([...mgr.getPresets()]); showToast('Đã xóa preset.', 'info'); }; const handleSaveForm = (e) => { e.preventDefault(); if (!formName.trim() || !formTemplate.trim()) { showToast('Vui lòng điền đầy đủ tên và mẫu gợi ý.', 'warning'); return; } const keywordsArray = formKeywords.split(',').map(k => k.trim()).filter(Boolean); const presetObj = { id: editingPreset === 'new' ? 'preset_' + Date.now() : editingPreset.id, name: formName.trim(), keywords: keywordsArray, category: formCategory, default_bars: parseInt(formBars) || 8, default_bpm: parseInt(formBpm) || 120, default_scale: formScale, system_instruction_template: formTemplate.trim(), is_user_defined: true, is_favorite: editingPreset === 'new' ? false : (editingPreset.is_favorite || false), created_at: editingPreset === 'new' ? new Date().toISOString() : editingPreset.created_at }; mgr.saveUserPreset(presetObj); setPresets([...mgr.getPresets()]); setEditingPreset(null); if (window.SonicAPI) { window.SonicAPI.saveAIPreset(presetObj).catch(() => {}); } showToast('Đã lưu preset thành công!', 'success'); }; const categories = ['ALL', '★ Yêu thích', 'Người dùng', ...new Set(presets.map(p => p.category))]; const filtered = presets.filter(p => { const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase()) || p.keywords.some(k => k.toLowerCase().includes(search.toLowerCase())); let matchesCategory; if (filterCategory === 'ALL') { matchesCategory = true; } else if (filterCategory === '★ Yêu thích') { matchesCategory = p.is_favorite; } else if (filterCategory === 'Người dùng') { matchesCategory = p.is_user_defined; } else { matchesCategory = p.category === filterCategory; } if (showFavoritesOnly) matchesCategory = matchesCategory && p.is_favorite; return matchesSearch && matchesCategory; }); return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-black/75 backdrop-blur-sm p-4 animate-fade-in" }, /*#__PURE__*/React.createElement("div", { className: "bg-[#18181b] border border-zinc-800 rounded-xl shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col overflow-hidden text-zinc-100" }, /*#__PURE__*/React.createElement("div", { className: "p-4 border-b border-zinc-800 flex items-center justify-between shrink-0 bg-[#202024]" }, /*#__PURE__*/React.createElement("h2", { className: "text-sm font-bold tracking-wider uppercase text-purple-400 flex items-center gap-1.5" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "sliders", className: "w-4 h-4" }), "AI Prompt Preset Manager", syncing && /*#__PURE__*/React.createElement("span", { className: "text-[10px] text-zinc-500 ml-2" }, "đang đồng bộ...")), /*#__PURE__*/React.createElement("button", { onClick: () => { setEditingPreset(null); onClose(); }, className: "text-zinc-400 hover:text-zinc-200 transition" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "x", className: "w-4 h-4" }))), /*#__PURE__*/React.createElement("div", { className: "flex-1 overflow-y-auto p-4 flex gap-4 min-h-0" }, !editingPreset ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { className: "flex-1 flex flex-col min-w-0" }, /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-2 mb-3 shrink-0" }, /*#__PURE__*/React.createElement("input", { type: "text", placeholder: "Tìm kiếm preset hoặc từ khóa...", value: search, onChange: e => setSearch(e.target.value), className: "flex-1 bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs outline-none focus:border-purple-600" }), /*#__PURE__*/React.createElement("select", { value: filterCategory, onChange: e => setFilterCategory(e.target.value), className: "bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs outline-none focus:border-purple-600" }, categories.map(c => /*#__PURE__*/React.createElement("option", { key: c, value: c }, c === 'ALL' ? 'Tất cả danh mục' : c))), /*#__PURE__*/React.createElement("button", { onClick: () => setShowFavoritesOnly(!showFavoritesOnly), className: `px-2.5 py-1 rounded text-xs font-bold transition shrink-0 ${showFavoritesOnly ? 'bg-yellow-700 text-yellow-300' : 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700'}`, title: "Chỉ hiện yêu thích" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "star", className: "w-3.5 h-3.5 inline-block mr-1" }), "★"), /*#__PURE__*/React.createElement("button", { onClick: handleNew, className: "px-3 py-1 bg-purple-700 hover:bg-purple-600 text-white rounded text-xs font-bold flex items-center gap-1.5 shadow transition shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "plus", className: "w-3.5 h-3.5" }), "Tạo mới")), /*#__PURE__*/React.createElement("div", { className: "flex-1 border border-zinc-800 rounded bg-[#0f0f12] overflow-y-auto" }, filtered.length === 0 ? /*#__PURE__*/React.createElement("div", { className: "p-8 text-center text-zinc-500 text-xs italic" }, "Không tìm thấy preset nào.") : /*#__PURE__*/React.createElement("table", { className: "w-full text-left text-xs border-collapse" }, /*#__PURE__*/React.createElement("thead", { className: "bg-[#1f1f23] text-zinc-400 font-bold border-b border-zinc-800" }, /*#__PURE__*/React.createElement("tr", null, /*#__PURE__*/React.createElement("th", { className: "p-2.5 w-8" }, ""), /*#__PURE__*/React.createElement("th", { className: "p-2.5 w-1/4" }, "Tên Preset"), /*#__PURE__*/React.createElement("th", { className: "p-2.5 w-1/4" }, "Từ khóa kích hoạt"), /*#__PURE__*/React.createElement("th", { className: "p-2.5 w-1/6" }, "Số Bar"), /*#__PURE__*/React.createElement("th", { className: "p-2.5 w-1/6" }, "BPM"), /*#__PURE__*/React.createElement("th", { className: "p-2.5 w-1/6 text-right" }, "Hành động"))), /*#__PURE__*/React.createElement("tbody", null, filtered.map(p => /*#__PURE__*/React.createElement("tr", { key: p.id, className: "border-b border-zinc-800/50 hover:bg-zinc-850" }, /*#__PURE__*/React.createElement("td", { className: "p-2.5 text-center" }, /*#__PURE__*/React.createElement("button", { onClick: () => handleToggleFav(p.id), className: `transition ${p.is_favorite ? 'text-yellow-400' : 'text-zinc-600 hover:text-zinc-400'}`, title: p.is_favorite ? 'Bỏ yêu thích' : 'Đánh dấu yêu thích' }, p.is_favorite ? "★" : "☆")), /*#__PURE__*/React.createElement("td", { className: "p-2.5 font-semibold text-purple-300" }, p.name), /*#__PURE__*/React.createElement("td", { className: "p-2.5 text-zinc-400 font-mono text-[11px] truncate max-w-[150px]" }, p.keywords.join(', ')), /*#__PURE__*/React.createElement("td", { className: "p-2.5 text-zinc-400" }, p.default_bars, " Bars"), /*#__PURE__*/React.createElement("td", { className: "p-2.5 text-zinc-400" }, p.default_bpm, " BPM"), /*#__PURE__*/React.createElement("td", { className: "p-2.5 text-right flex items-center justify-end gap-1.5 h-full" }, /*#__PURE__*/React.createElement("button", { type: "button", onClick: () => handleEdit(p), className: "px-2 py-0.5 bg-zinc-850 hover:bg-zinc-700 text-zinc-300 rounded border border-zinc-700 text-[10px]" }, "Sửa"), p.is_user_defined && /*#__PURE__*/React.createElement("button", { type: "button", onClick: () => handleDelete(p.id), className: "px-2 py-0.5 bg-red-950/40 hover:bg-red-800 text-red-400 rounded border border-red-900 text-[10px]" }, "Xóa"))))))))) : /*#__PURE__*/React.createElement("form", { onSubmit: handleSaveForm, className: "flex-1 flex flex-col gap-3 min-w-0" }, /*#__PURE__*/React.createElement("div", { className: "text-xs font-bold text-zinc-400 shrink-0 border-b border-zinc-800 pb-1" }, editingPreset === 'new' ? "TẠO PRESET MỚI" : `SỬA PRESET: ${editingPreset.name}`), /*#__PURE__*/React.createElement("div", { className: "grid grid-cols-2 gap-3" }, /*#__PURE__*/React.createElement("div", { className: "flex flex-col gap-1" }, /*#__PURE__*/React.createElement("label", { className: "text-[10px] uppercase font-bold text-zinc-500" }, "Tên Preset"), /*#__PURE__*/React.createElement("input", { type: "text", value: formName, onChange: e => setFormName(e.target.value), className: "bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs outline-none focus:border-purple-600 text-zinc-200" })), /*#__PURE__*/React.createElement("div", { className: "flex flex-col gap-1" }, /*#__PURE__*/React.createElement("label", { className: "text-[10px] uppercase font-bold text-zinc-500" }, "Danh mục"), isAddingCategory ? /*#__PURE__*/React.createElement("div", { className: "flex gap-2" }, /*#__PURE__*/React.createElement("input", { type: "text", value: newCategoryValue, onChange: e => setNewCategoryValue(e.target.value), onBlur: () => { if (newCategoryValue.trim()) { addNewCategory(newCategoryValue.trim()); } setIsAddingCategory(false); }, onKeyDown: e => { if (e.key === 'Enter') { e.preventDefault(); if (newCategoryValue.trim()) { addNewCategory(newCategoryValue.trim()); } setIsAddingCategory(false); } else if (e.key === 'Escape') { setIsAddingCategory(false); } }, autoFocus: true, className: "flex-1 bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs outline-none focus:border-purple-600 text-zinc-200", placeholder: "Nhập danh mục mới..." })) : /*#__PURE__*/React.createElement("select", { value: selectedCategory, onChange: e => { if (e.target.value === '__add_new__') { setIsAddingCategory(true); setNewCategoryValue(''); } else { setSelectedCategory(e.target.value); setFormCategory(e.target.value); } }, className: "bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs outline-none focus:border-purple-600 text-zinc-200" }, presetCategories.map(c => /*#__PURE__*/React.createElement("option", { key: c, value: c }, c)), /*#__PURE__*/React.createElement("option", { value: '__add_new__' }, "+ Nhập danh mục mới...")))), /*#__PURE__*/React.createElement("div", { className: "flex flex-col gap-1" }, /*#__PURE__*/React.createElement("label", { className: "text-[10px] uppercase font-bold text-zinc-500" }, "Từ khóa kích hoạt (ngăn cách bằng dấu phẩy)"), /*#__PURE__*/React.createElement("input", { type: "text", value: formKeywords, onChange: e => setFormKeywords(e.target.value), placeholder: "Ví dụ: epic orchestra, hoành tráng, nhạc phim epic", className: "bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs outline-none focus:border-purple-600 text-zinc-200" })), /*#__PURE__*/React.createElement("div", { className: "grid grid-cols-3 gap-3" }, /*#__PURE__*/React.createElement("div", { className: "flex flex-col gap-1" }, /*#__PURE__*/React.createElement("label", { className: "text-[10px] uppercase font-bold text-zinc-500" }, "Số Bars mặc định"), /*#__PURE__*/React.createElement("input", { type: "number", value: formBars, onChange: e => setFormBars(e.target.value), className: "bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs outline-none focus:border-purple-600 text-zinc-200" })), /*#__PURE__*/React.createElement("div", { className: "flex flex-col gap-1" }, /*#__PURE__*/React.createElement("label", { className: "text-[10px] uppercase font-bold text-zinc-500" }, "BPM mặc định"), /*#__PURE__*/React.createElement("input", { type: "number", value: formBpm, onChange: e => setFormBpm(e.target.value), className: "bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs outline-none focus:border-purple-600 text-zinc-200" })), /*#__PURE__*/React.createElement("div", { className: "flex flex-col gap-1" }, /*#__PURE__*/React.createElement("label", { className: "text-[10px] uppercase font-bold text-zinc-500" }, "Âm giai (Scale) mặc định"), /*#__PURE__*/React.createElement("input", { type: "text", value: formScale, onChange: e => setFormScale(e.target.value), className: "bg-zinc-900 border border-zinc-700 rounded px-2.5 py-1 text-xs outline-none focus:border-purple-600 text-zinc-200" }))), /*#__PURE__*/React.createElement("div", { className: "flex flex-col gap-1 flex-1 min-h-0" }, /*#__PURE__*/React.createElement("label", { className: "text-[10px] uppercase font-bold text-zinc-500" }, "System Prompt Template / Luật soạn nhạc"), /*#__PURE__*/React.createElement("textarea", { value: formTemplate, onChange: e => setFormTemplate(e.target.value), rows: 6, className: "flex-1 bg-zinc-900 border border-zinc-700 rounded p-2.5 text-xs outline-none focus:border-purple-600 text-zinc-200 font-mono resize-none" })), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-2 shrink-0 pt-2 border-t border-zinc-800" }, /*#__PURE__*/React.createElement("button", { type: "button", onClick: handleNewStructured, className: "px-3 py-1.5 bg-emerald-800 hover:bg-emerald-700 text-emerald-300 border border-emerald-700 rounded text-xs transition" }, "Tạo preset với cấu trúc"), /*#__PURE__*/React.createElement("div", { className: "flex-1" }), /*#__PURE__*/React.createElement("button", { type: "button", onClick: () => setEditingPreset(null), className: "px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 border border-zinc-700 rounded text-xs transition" }, "Quay lại"), /*#__PURE__*/React.createElement("button", { type: "submit", className: "px-3 py-1.5 bg-purple-700 hover:bg-purple-600 text-white rounded text-xs font-bold shadow transition" }, "Lưu Preset")))), /*#__PURE__*/React.createElement("div", { className: "p-4 border-t border-zinc-800 flex justify-end shrink-0 bg-[#202024]" }, /*#__PURE__*/React.createElement("button", { onClick: () => { setEditingPreset(null); onClose(); }, className: "px-4 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 border border-zinc-700 rounded text-xs font-bold shadow transition" }, "Đóng")))), showGeneratorModal ? /*#__PURE__*/React.createElement("div", { className: "fixed inset-0 z-[60] flex items-center justify-center bg-black/80 p-4", onClick: e => { if (e.target === e.currentTarget) { refreshPresets(); setShowGeneratorModal(false); } } }, /*#__PURE__*/React.createElement("div", { className: "w-full h-full max-w-6xl max-h-[90vh] bg-[#13141a] border border-zinc-800 rounded-2xl shadow-2xl overflow-hidden flex flex-col" }, /*#__PURE__*/React.createElement("div", { className: "flex items-center justify-between p-3 border-b border-zinc-800 shrink-0" }, /*#__PURE__*/React.createElement("h3", { className: "text-sm font-bold text-purple-400" }, "AI Prompt Generator"), /*#__PURE__*/React.createElement("button", { onClick: () => { refreshPresets(); setShowGeneratorModal(false); }, className: "text-zinc-400 hover:text-white" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "x", className: "w-4 h-4" }))), /*#__PURE__*/React.createElement("iframe", { src: "/ai-prompt-generator", className: "flex-1 w-full border-0 bg-white", title: "AI Prompt Generator" }))) : null); }; const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClose, onUpdateNotes, onSaveNotes, setSubTabs, onPlayPause, onStop, isPlaying, playPreviewNote, showToast, midiDevices, recordingState, recTempMidiNotes, onRecord, selectedMidiInputId, onMidiInputSelect, activeMidiPitches, onInstrumentSelect, onRescheduleMidi, onSeekPlayhead, snapValue, onSnapChange, onRealtimePlay }) => { const [activeRollTool, setActiveRollTool] = React.useState('select'); const [renderTick, setRenderTick] = React.useState(0); const [ccMode, setCcMode] = React.useState('velocity'); const [rollZoom, setRollZoom] = React.useState(60); // local horizontal zoom factor const [aiBarStart, setAiBarStart] = React.useState(0); const [aiBarEnd, setAiBarEnd] = React.useState(4); const canvasRef = React.useRef(null); const ccCanvasRef = React.useRef(null); const ccWrapperRef = React.useRef(null); const gridScrollRef = React.useRef(null); const keybedRef = React.useRef(null); const keybedMouseDownRef = React.useRef(false); const rulerScrollRef = React.useRef(null); React.useEffect(() => { const up = () => { keybedMouseDownRef.current = false; }; window.addEventListener('mouseup', up); return () => window.removeEventListener('mouseup', up); }, []); const NoteHeight = 18; const PITCH_START = 0; // C0 (render all 128 keys) const KeybedPixelHeight = (128 - PITCH_START) * NoteHeight; const pixelsPerBeat = rollZoom; const timeSigNum = 4; const noteMaxBeat = (st.notes || []).reduce((max, n) => Math.max(max, (n.start_beat || 0) + (n.duration_beats || 1)), 0); const [selectionMarquee, setSelectionMarquee] = React.useState(null); // { startBeat, startPitch, currentBeat, currentPitch } const [draggedNote, setDraggedNote] = React.useState(null); // { mode: 'move'|'resize', idx, startOffsetBeat, originalStart } const draggedNoteRef = React.useRef(draggedNote); draggedNoteRef.current = draggedNote; const [hoveredResizeIdx, setHoveredResizeIdx] = React.useState(-1); const [rollBeats, setRollBeats] = React.useState(Math.max(noteMaxBeat + 16, 64)); const rollBeatsRef = React.useRef(rollBeats); rollBeatsRef.current = rollBeats; const [showGhostNotes, setShowGhostNotes] = React.useState(true); const [sessionSyncMode, setSessionSyncMode] = React.useState(true); const [activePlayTrackIds, setActivePlayTrackIds] = React.useState([]); const [focusItemId, setFocusItemId] = React.useState(st.target_id); const allMidiItems = React.useMemo(() => { const result = []; (activeTracks || []).forEach(t => { if (!t.midiItems || !t.midiItems.length) return; t.midiItems.forEach(m => { var extended = Object.assign({}, m, { _trackId: t.id, _trackName: t.name }); result.push(extended); }); }); return result; }, [activeTracks]); const ghostLayers = React.useMemo(function() { if (!activeTracks || !st || !st.target_id) return []; var fn = window.SonicGhost && window.SonicGhost.extractGhostLayers; return fn ? fn(activeTracks, st.trackId, st.target_id, parseInt(bpm) || 120) : []; }, [activeTracks, st.trackId, st.target_id, bpm]); const secondsPerBar = (60.0 / (parseInt(bpm) || 120)) * 4; const activeTargetItem = React.useMemo(function() { if (!activeTracks || !st) return null; var trk = activeTracks.find(function(t) { return t.id === st.trackId; }); return trk ? (trk.midiItems || []).find(function(m) { return m.id === st.target_id; }) : null; }, [activeTracks, st.trackId, st.target_id]); var activeParentTrackName = ''; if (st.target_id && activeTracks) { var aptTrk = window.SonicPianoRoll ? window.SonicPianoRoll.getParentTrackByItemId(st.target_id, activeTracks) : null; if (!aptTrk) aptTrk = activeTracks.find(function(t) { return t.id === st.trackId; }); if (!aptTrk && activeTargetItem) aptTrk = activeTracks.find(function(t) { return (t.midiItems || []).some(function(m) { return m.id === st.target_id; }); }); if (aptTrk) activeParentTrackName = aptTrk.name || aptTrk.id; } const sessionStartBar = 0; const renderBeatOffset = sessionSyncMode && activeTargetItem ? (activeTargetItem.startTime / secondsPerBar) * timeSigNum : 0; const sessionLengthBars = React.useMemo(function() { var maxSec = 0; (activeTracks || []).forEach(function(tr) { (tr.midiItems || []).forEach(function(m) { var end = (m.startTime || 0) + (m.duration || 4); if (end > maxSec) maxSec = end; }); }); return Math.ceil((maxSec || 4) / secondsPerBar); }, [activeTracks, secondsPerBar]); const handleSwitchMidiItem = function(itemId) { if (itemId === st.target_id) return; var match = allMidiItems.find(function(m) { return m.id === itemId; }); if (!match) return; var scope = window.SonicPianoRoll ? window.SonicPianoRoll.buildActiveScope(itemId, activeTracks) : null; var trk = scope ? null : (activeTracks || []).find(function(t) { return t.id === match._trackId; }); var newBeatOff = (match.startTime / secondsPerBar) * timeSigNum; var spb = 60.0 / (parseInt(bpm) || 120); var newTime = 0; setSubTabs(function(prev) { return prev.map(function(s) { if (s.id !== st.id) return s; return Object.assign({}, s, { trackId: scope ? scope.parent_track_id : (match._trackId || trk?.id), target_id: match.id, label: 'Piano Roll: ' + (match.name || 'MIDI'), notes: match.notes || [], duration: match.duration || 4, instrumentProgram: scope ? scope.instrument_program : (trk ? trk.instrumentProgram : undefined), instrumentName: scope ? scope.instrument_name : (trk ? trk.instrumentName : undefined), active_scope: scope || null, note_selection: [], currentTime: newTime }); }); }); setSelectedNoteIds([]); }; const rawTotalBeats = Math.max(rollBeats, noteMaxBeat + 16, 64); const drawWidth = rawTotalBeats * pixelsPerBeat; const [rollViewWidth, setRollViewWidth] = React.useState(800); const viewWidth = Math.max(drawWidth, rollViewWidth); const viewBeats = Math.ceil(viewWidth / pixelsPerBeat) + 4; const totalBeats = Math.max(rawTotalBeats, viewBeats + 32); const [notes, setNotes] = React.useState(st.notes || []); const notesRef = React.useRef(notes); notesRef.current = notes; React.useEffect(() => { if (!draggedNoteRef.current) setNotes(st.notes || []); }, [st.notes]); const brushVelocityRef = React.useRef(0.8); const lastNoteDurationRef = React.useRef(null); const previewPitchRef = React.useRef(null); const previewNodesRef = React.useRef(null); var stopPreviewNote = function() { var pn = previewNodesRef.current; if (pn) { try { pn.osc.stop(); } catch(e) {} try { pn.osc.disconnect(); } catch(e) {} try { pn.gain.disconnect(); } catch(e) {} previewNodesRef.current = null; } }; const [selectedNoteIds, setSelectedNoteIds] = React.useState([]); const [loopStartBeat, setLoopStartBeat] = React.useState(null); const [loopEndBeat, setLoopEndBeat] = React.useState(null); const [isLooping, setIsLooping] = React.useState(false); const rulerDragRef = React.useRef(null); React.useEffect(() => { const handler = (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'a') { const target = e.target; if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return; e.preventDefault(); setSelectedNoteIds(notes.map(n => n.id)); } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [notes, setSelectedNoteIds]); // Undo/redo stacks const undoStackRef = React.useRef([]); const redoStackRef = React.useRef([]); const notesBeforeDragRef = React.useRef(null); const pushToUndo = React.useCallback((prevNotes) => { undoStackRef.current.push(JSON.parse(JSON.stringify(prevNotes))); redoStackRef.current = []; if (undoStackRef.current.length > 50) undoStackRef.current.shift(); }, []); const handleUndo = React.useCallback(() => { const prev = undoStackRef.current.pop(); if (!prev) return; redoStackRef.current.push(JSON.parse(JSON.stringify(notes))); setNotes(prev); setSelectedNoteIds([]); }, [notes]); const handleRedo = React.useCallback(() => { const next = redoStackRef.current.pop(); if (!next) return; undoStackRef.current.push(JSON.parse(JSON.stringify(notes))); setNotes(next); setSelectedNoteIds([]); }, [notes]); React.useEffect(() => { const handler = e => { if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); handleUndo(); } else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); handleRedo(); } else if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); onSaveNotes(st.id, st.trackId, st.target_id, notes); showToast('Đã lưu MIDI notes', 'info'); } else if (e.key === 'Delete' || e.key === 'Backspace') { if (selectedNoteIds.length > 0 && e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') { e.preventDefault(); pushToUndo(notes); setNotes(prev => prev.filter(n => !selectedNoteIds.includes(n.id))); setSelectedNoteIds([]); showToast(`Đã xóa ${selectedNoteIds.length} nốt!`, 'info'); } } else if (e.key === 'F7') { e.preventDefault(); e.stopPropagation(); var toggleMixer = window.__toggleMixerRef; if (toggleMixer) toggleMixer(); } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [handleUndo, handleRedo, notes, selectedNoteIds, onSaveNotes, showToast]); React.useEffect(() => { onUpdateNotes(st.id, notes); }, [notes]); const getSnapBeat = (beat, mode) => { let q = 0.25; if (mode === 'free') return beat; if (mode === '1') q = 1.0; else if (mode === '1/2') q = 0.5; else if (mode === '1/4') q = 0.25; else if (mode === '1/8') q = 0.125; else if (mode === '1/16') q = 0.0625; else if (mode === '4') q = 4.0; else if (mode === '1/32') q = 0.03125; return Math.round(beat / q) * q; }; const getSnapDuration = (mode) => { if (mode === 'free') return 0.25; if (mode === '1') return 1.0; if (mode === '1/2') return 0.5; if (mode === '1/4') return 0.25; if (mode === '1/8') return 0.125; if (mode === '1/16') return 0.0625; if (mode === '4') return 4.0; if (mode === '1/32') return 0.03125; 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); } }; }, []); // Alt + Scroll event listener: fast‑forward playhead + play notes React.useEffect(() => { const handleCanvasWheel = (e) => { const canvas = canvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; const pitch = 127 - Math.floor(my / NoteHeight); if (e.shiftKey) { e.preventDefault(); // Shift+scroll on note → change velocity of single note or all selected const scrollBeat = (mx / pixelsPerBeat) - renderBeatOffset; const clickedNote = notes.find(n => pitch === n.pitch && scrollBeat >= n.start_beat && scrollBeat < (n.start_beat + n.duration_beats)); if (clickedNote) { const delta = e.deltaY < 0 ? 0.05 : -0.05; if (selectedNoteIds.length > 0) { setNotes(prev => prev.map(n => selectedNoteIds.includes(n.id) ? { ...n, velocity: Math.max(0.05, Math.min(1, (n.velocity || 0.8) + delta)) } : n)); } else { setNotes(prev => prev.map(n => n.id === clickedNote.id ? { ...n, velocity: Math.max(0.05, Math.min(1, (n.velocity || 0.8) + delta)) } : n)); } } else { // Shift+scroll on empty space → horizontal scroll const container = gridScrollRef.current; if (container) container.scrollLeft += e.deltaY; } return; } if (e.altKey) { e.preventDefault(); const scrollDelta = e.deltaY; const beatSec = 60.0 / (parseInt(bpm) || 120); const step = scrollDelta < 0 ? -0.25 : 0.25; const currentBeat = (st.currentTime || 0) / beatSec; const maxBeats = totalBeats; const newBeat = Math.max(0, Math.min(maxBeats, currentBeat + step)); const newTime = newBeat * beatSec; setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: newTime } : s)); if (window.SonicSF) { const ctx = getAudioContext(); const playing = notes.filter(n => currentBeat < n.start_beat && newBeat >= n.start_beat ); var pvTrk = activeTracks.find(function(t) { return t.id === st.trackId; }); var pvCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(pvTrk, activeTracks) : (pvTrk ? pvTrk.midiChannel : 0); playing.forEach(n => { window.SonicSF.playNote(n.pitch, (n.velocity || 0.8) * 127, 200, ctx.currentTime, st.instrumentProgram, null, pvCh, pvTrk ? pvTrk.synth_engine : undefined); }); } } }; const canvas = canvasRef.current; if (canvas) { canvas.addEventListener('wheel', handleCanvasWheel, { passive: false }); } return () => { if (canvas) { canvas.removeEventListener('wheel', handleCanvasWheel); } }; }, [notes, st.currentTime, pixelsPerBeat, st.id, totalBeats, bpm]); React.useLayoutEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; const h = 128 * NoteHeight; canvas.width = viewWidth * 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, viewWidth, NoteHeight); ctx.strokeStyle = '#2d2d35'; ctx.lineWidth = 0.5; ctx.beginPath(); ctx.moveTo(0, y + NoteHeight); ctx.lineTo(viewWidth, y + NoteHeight); ctx.stroke(); } // Draw snap lines let snapBeats = 0.25; if (snapValue === '1') snapBeats = 1.0; else if (snapValue === '1/2') snapBeats = 0.5; else if (snapValue === '1/4') snapBeats = 0.25; else if (snapValue === '1/8') snapBeats = 0.125; else if (snapValue === '1/16') snapBeats = 0.0625; else if (snapValue === '4') snapBeats = 4.0; else if (snapValue === '1/32') snapBeats = 0.03125; for (let beat = 0; beat <= viewBeats; beat += snapBeats) { const x = beat * pixelsPerBeat; if (x > viewWidth) break; 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(); } // Determine which MIDI item is focused (clicked/selected note → its item; playing → item under playhead) var focusedItemId = focusItemId || st.target_id; if (selectedNoteIds && selectedNoteIds.length > 0) { // Most recently selected note wins: main note → opened item; dim same-track note → its item focusedItemId = st.target_id; for (var s2 = selectedNoteIds.length - 1; s2 >= 0; s2--) { var sid = selectedNoteIds[s2]; if (notes.some(function(n) { return n.id === sid; })) { focusedItemId = st.target_id; break; } var foundGhost2 = false; for (var sg2 = 0; sg2 < ghostLayers.length; sg2++) { var sl2 = ghostLayers[sg2]; if (!sl2.isSameTrack || !sl2.notes) continue; var gnote2 = sl2.notes.find(function(g) { return g.id === sid; }); if (gnote2) { focusedItemId = gnote2.item_id || st.target_id; foundGhost2 = true; break; } } if (foundGhost2) break; } } else if (st.isPlaying && st.currentTime != null && activeTracks) { var curSec = st.currentTime || 0; var fidxTrk = activeTracks.find(function(t) { return t.id === st.trackId; }); var itemsArr = fidxTrk ? (fidxTrk.midiItems || []) : []; for (var fi = 0; fi < itemsArr.length; fi++) { var fit = itemsArr[fi]; var fStart = fit.startTime || 0; var fEnd = fStart + (fit.duration || 4); if (curSec >= fStart && curSec < fEnd) { focusedItemId = fit.id; break; } } } // Layer 2: Ghost Notes (same-track: focused item bright, siblings dim; other tracks faint) if (showGhostNotes && sessionSyncMode && ghostLayers.length > 0) { ghostLayers.forEach(function(layer) { ctx.save(); var isSameTrackLayer = layer.isSameTrack; layer.notes.forEach(function(note) { var isFocusedNote = isSameTrackLayer && note.item_id === focusedItemId; var isGhostSelected = selectedNoteIds.indexOf(note.id) !== -1; if (isSameTrackLayer) { ctx.globalAlpha = isFocusedNote ? 0.7 : 0.3; ctx.fillStyle = isGhostSelected ? 'rgba(96, 165, 250, 0.65)' : (isFocusedNote ? 'rgba(253, 224, 71, 0.7)' : 'rgba(251, 191, 36, 0.3)'); ctx.strokeStyle = isGhostSelected ? '#60a5fa' : (isFocusedNote ? '#fde047' : 'rgba(245, 158, 11, 0.6)'); } else { ctx.globalAlpha = 0.25; ctx.fillStyle = layer.track_color || '#888'; ctx.strokeStyle = layer.track_color || '#888'; } var snapStart = snapValue !== 'free' ? getSnapBeat(note.relative_start_beat, snapValue) : note.relative_start_beat; var rawEnd = note.relative_start_beat + note.duration_beats; var snapEnd = snapValue !== 'free' ? getSnapBeat(rawEnd, snapValue) : rawEnd; var x = (renderBeatOffset + snapStart) * pixelsPerBeat; var y = (127 - note.pitch) * NoteHeight; var w = Math.max(2, (snapEnd - snapStart) * pixelsPerBeat); var h = NoteHeight - 1; ctx.fillRect(x, y, w, h); if (isSameTrackLayer && (isFocusedNote || isGhostSelected)) ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1); }); ctx.restore(); }); } // Layer 3: Active notes with velocity layer representation notes.forEach((note) => { const snapStart = snapValue !== 'free' ? getSnapBeat(note.start_beat, snapValue) : note.start_beat; const rawEnd = note.start_beat + note.duration_beats; const snapEnd = snapValue !== 'free' ? getSnapBeat(rawEnd, snapValue) : rawEnd; const x = (renderBeatOffset + snapStart) * pixelsPerBeat; const y = (127 - note.pitch) * NoteHeight; const w = Math.max(2, (snapEnd - snapStart) * pixelsPerBeat); const isSelected = selectedNoteIds.includes(note.id); // Draw background of note (focused item = brighter, siblings = dim) const playingNow = st.isPlaying; const mainFocused = focusedItemId === st.target_id; ctx.fillStyle = isSelected ? 'rgba(96, 165, 250, 0.6)' : (mainFocused ? (playingNow ? 'rgba(254, 240, 138, 0.75)' : 'rgba(253, 224, 71, 0.6)') : 'rgba(120, 100, 40, 0.35)'); ctx.strokeStyle = isSelected ? '#60a5fa' : (mainFocused ? (playingNow ? '#fef08a' : '#fde047') : '#8a7504'); ctx.lineWidth = isSelected ? 1.5 : (mainFocused ? 1.2 : 0.8); 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/blue bar inside, proportional to velocity) const vel = note.velocity !== undefined ? note.velocity : 0.8; const velW = Math.max(2, (w - 2) * vel); ctx.fillStyle = isSelected ? '#60a5fa' : (mainFocused ? (playingNow ? '#fde047' : '#facc15') : '#7a6a10'); ctx.fillRect(x + 1, y + 1, velW, NoteHeight - 2); }); // Draw real-time recording notes if (recordingState === 'RECORDING' && recTempMidiNotes && recTempMidiNotes.length > 0) { recTempMidiNotes.forEach(note => { const snapStart = snapValue !== 'free' ? getSnapBeat(note.start_beat, snapValue) : note.start_beat; const rawEnd = note.start_beat + (note.duration_beats || 0.25); const snapEnd = snapValue !== 'free' ? getSnapBeat(rawEnd, snapValue) : rawEnd; const x = (renderBeatOffset + snapStart) * pixelsPerBeat; const y = (127 - note.pitch) * NoteHeight; const w = Math.max(2, (snapEnd - snapStart) * pixelsPerBeat); ctx.fillStyle = 'rgba(255, 100, 100, 0.35)'; ctx.strokeStyle = '#ff6464'; ctx.lineWidth = 1; ctx.fillRect(x + 1, y + 1, Math.max(2, w - 2), NoteHeight - 2); ctx.strokeRect(x + 1, y + 1, Math.max(2, w - 2), NoteHeight - 2); const vel = Math.min(1, note.velocity || 0.8); ctx.fillStyle = '#ff6464'; ctx.fillRect(x + 1, y + 1, Math.max(2, (w - 2) * vel), NoteHeight - 2); }); } // Draw selection marquee if active if (selectionMarquee) { const minBeat = Math.min(selectionMarquee.startBeat, selectionMarquee.currentBeat); const maxBeat = Math.max(selectionMarquee.startBeat, selectionMarquee.currentBeat); const minPitch = Math.min(selectionMarquee.startPitch, selectionMarquee.currentPitch); const maxPitch = Math.max(selectionMarquee.startPitch, selectionMarquee.currentPitch); const mx = minBeat * pixelsPerBeat; const my = (127 - maxPitch) * NoteHeight; const mw = (maxBeat - minBeat) * pixelsPerBeat; const mh = (maxPitch - minPitch + 1) * NoteHeight; ctx.fillStyle = 'rgba(59, 130, 246, 0.15)'; ctx.strokeStyle = '#3b82f6'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); ctx.fillRect(mx, my, mw, mh); ctx.strokeRect(mx, my, mw, mh); ctx.setLineDash([]); } // Draw playhead if (st.currentTime !== undefined && st.currentTime !== null) { const phBeat = st.currentTime / (60.0 / (parseInt(bpm) || 120)); const phX = phBeat * pixelsPerBeat; if (phX >= 0 && phX <= viewWidth) { ctx.strokeStyle = '#f59e0b'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(phX, 0); ctx.lineTo(phX, (128 - PITCH_START) * NoteHeight); ctx.stroke(); } } }, [notes, snapValue, rollZoom, selectedNoteIds, selectionMarquee, st.currentTime, bpm, viewWidth, viewBeats, recordingState, recTempMidiNotes, showGhostNotes, sessionSyncMode, ghostLayers, renderBeatOffset, renderTick, activeTracks, focusItemId]); React.useLayoutEffect(() => { const canvas = ccCanvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); const dpr = window.devicePixelRatio || 1; const h = ccHeight; canvas.width = viewWidth * dpr; canvas.height = h * dpr; ctx.scale(dpr, dpr); ctx.fillStyle = '#161616'; ctx.fillRect(0, 0, viewWidth, h); ctx.strokeStyle = '#252525'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, h / 2); ctx.lineTo(viewWidth, h / 2); ctx.stroke(); notes.forEach((note) => { const x = (renderBeatOffset + note.start_beat) * pixelsPerBeat; const isSelected = selectedNoteIds.includes(note.id); 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' ? (isSelected ? '#60a5fa' : '#a78bfa') : (isSelected ? '#3b82f6' : '#fbbf24'); ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(x, h); ctx.lineTo(x, y); ctx.stroke(); ctx.fillStyle = ccMode === 'pan' ? (isSelected ? '#3b82f6' : '#c084fc') : (isSelected ? '#3b82f6' : '#fbbf24'); ctx.beginPath(); ctx.arc(x, y, 3.5, 0, 2 * Math.PI); ctx.fill(); }); }, [notes, ccMode, rollZoom, viewWidth, selectedNoteIds, renderBeatOffset]); React.useEffect(() => { const scrollToC3 = () => { if (gridScrollRef.current) { const ch = gridScrollRef.current.clientHeight || 400; gridScrollRef.current.scrollTop = Math.max(0, (127 - 48) * NoteHeight + NoteHeight - ch); } }; scrollToC3(); const timer = setTimeout(scrollToC3, 100); return () => clearTimeout(timer); }, []); // Sync ghost play data to subTab state for playback integration React.useEffect(function() { if (!sessionSyncMode || !showGhostNotes || !ghostLayers.length) { setSubTabs(function(prev) { return prev.map(function(s) { if (s.id !== st.id) return s; return Object.assign({}, s, { ghostPlayLayers: [] }); }); }); return; } var layers = []; ghostLayers.forEach(function(layer) { var layerIsSameTrack = layer.isSameTrack; if (!layerIsSameTrack && (!activePlayTrackIds || activePlayTrackIds.indexOf(layer.track_id) === -1)) return; var trk = (activeTracks || []).find(function(t) { return t.id === layer.track_id; }); layers.push({ trackId: layer.track_id, notes: layer.notes.map(function(n) { return { pitch: n.pitch, start_beat: n.relative_start_beat, duration_beats: n.duration_beats, velocity: n.velocity || 0.8 }; }), instrumentProgram: trk ? trk.instrumentProgram : undefined, instrumentName: trk ? trk.instrumentName : undefined, synthEngine: trk ? trk.synth_engine : undefined }); }); setSubTabs(function(prev) { return prev.map(function(s) { if (s.id !== st.id) return s; return Object.assign({}, s, { ghostPlayLayers: layers }); }); }); }, [ghostLayers, activePlayTrackIds, sessionSyncMode, showGhostNotes, st.id, activeTracks]); // Reset item focus when the opened MIDI item changes React.useEffect(function() { setFocusItemId(st.target_id); }, [st.id, st.target_id]); 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 - renderBeatOffset; const pitch = 127 - Math.floor(y / NoteHeight); if (scaleMenuPos) setScaleMenuPos(null); // Right click -> delete note (if on note) or prepare for sweep-drag if (e.button === 2) { e.preventDefault(); const clickedNote = notes.find(n => { return pitch === n.pitch && beat >= n.start_beat && beat < n.start_beat + n.duration_beats; }); if (clickedNote) { pushToUndo(notes); setNotes(prev => prev.filter(n => n.id !== clickedNote.id)); setSelectedNoteIds(prev => prev.filter(id => id !== clickedNote.id)); swallowContextMenuRef.current = true; showToast('Đã xóa nốt!', 'info'); } else { rightClickDragRef.current = { active: true, startX: e.clientX, startY: e.clientY }; } return; } if (e.button !== 0) return; // Only handle left click // Check if clicking on an existing note const clickedNoteIdx = notes.findIndex(n => { return pitch === n.pitch && beat >= n.start_beat && beat < n.start_beat + n.duration_beats; }); // Click on note → play note with SoundFont if (clickedNoteIdx !== -1 && !e.ctrlKey && !e.shiftKey && !e.altKey) { if (window.SonicSF) { const ctx = getAudioContext(); var clTrk = activeTracks.find(function(t) { return t.id === st.trackId; }); var clCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(clTrk, activeTracks) : (clTrk ? clTrk.midiChannel : 0); window.SonicSF.playNote(notes[clickedNoteIdx].pitch, 100, 300, ctx.currentTime, st.instrumentProgram, null, clCh, clTrk ? clTrk.synth_engine : undefined); } } // Ctrl+click: toggle selection (multi-select) if (e.ctrlKey && !e.altKey && !e.shiftKey) { if (clickedNoteIdx !== -1) { const clickedNote = notes[clickedNoteIdx]; if (selectedNoteIds.includes(clickedNote.id)) { setSelectedNoteIds(prev => prev.filter(id => id !== clickedNote.id)); } else { setSelectedNoteIds(prev => [...prev, clickedNote.id]); } return; } else { // Ctrl+click on a dim (same-track) MIDI note → select it and focus its MIDI item var ctrlGhostHit = null; for (var cgi = 0; cgi < ghostLayers.length; cgi++) { var cgl = ghostLayers[cgi]; if (!cgl.isSameTrack || !cgl.notes) continue; for (var cgn = 0; cgn < cgl.notes.length; cgn++) { var cgnote = cgl.notes[cgn]; if (pitch === cgnote.pitch && beat >= cgnote.relative_start_beat && beat < cgnote.relative_start_beat + (cgnote.duration_beats || 1)) { ctrlGhostHit = cgnote; break; } } if (ctrlGhostHit) break; } if (ctrlGhostHit) { setFocusItemId(ctrlGhostHit.item_id || st.target_id); if (selectedNoteIds.includes(ctrlGhostHit.id)) { setSelectedNoteIds(prev => prev.filter(id => id !== ctrlGhostHit.id)); } else { setSelectedNoteIds(prev => [...prev, ctrlGhostHit.id]); } return; } // Ctrl+click on empty space: start selection marquee setSelectedNoteIds([]); const snapStart = getSnapBeat(beat, snapValue); setSelectionMarquee({ startBeat: snapStart, startPitch: pitch, currentBeat: snapStart, currentPitch: pitch }); return; } } // Ctrl+Shift+click on note → split at click position if (e.ctrlKey && e.shiftKey) { if (clickedNoteIdx !== -1) { const target = notes[clickedNoteIdx]; const splitBeat = getSnapBeat(beat, snapValue); if (splitBeat > target.start_beat + 0.03125 && splitBeat < target.start_beat + target.duration_beats - 0.03125) { pushToUndo(notes); const noteA = { ...JSON.parse(JSON.stringify(target)), id: 'note_' + Date.now() + Math.random().toString(36).substr(2, 8), duration_beats: splitBeat - target.start_beat }; const noteB = { ...JSON.parse(JSON.stringify(target)), id: 'note_' + Date.now() + Math.random().toString(36).substr(2, 8), start_beat: splitBeat, duration_beats: target.start_beat + target.duration_beats - splitBeat }; setNotes(prev => { const idx = prev.findIndex(n => n.id === target.id); if (idx === -1) return prev; const result = [...prev]; result.splice(idx, 1, noteA); result.splice(idx + 1, 0, noteB); return result; }); setSelectedNoteIds([noteA.id, noteB.id]); showToast('Đã tách nốt!', 'info'); } } else { // Ctrl+Shift+click on empty space → duplicate selected + clicked notes pushToUndo(notes); const clones = notes.filter(n => selectedNoteIds.includes(n.id)).map(n => ({ ...JSON.parse(JSON.stringify(n)), id: 'note_' + Date.now() + Math.random().toString(36).substr(2, 8) })); if (clones.length > 0) { setNotes(prev => [...prev, ...clones]); const cloneIds = clones.map(c => c.id); setSelectedNoteIds(cloneIds); notesBeforeDragRef.current = JSON.parse(JSON.stringify(notes)); const cloneOffsets = clones.map(n => ({ id: n.id, originalStartBeat: n.start_beat, originalPitch: n.pitch })); setDraggedNote({ mode: 'move', idx: -1, startOffsetBeat: beat, startOffsetPitch: pitch, selectedNotesOffset: cloneOffsets }); showToast('Đã nhân bản ' + clones.length + ' nốt!', 'info'); } } return; } // Hovered resize edge (Alt+resize for scaling) if (hoveredResizeIdx !== -1 && e.altKey) { pushToUndo(notes); notesBeforeDragRef.current = JSON.parse(JSON.stringify(notes)); const allSelected = [...new Set(selectedNoteIds.length > 0 ? selectedNoteIds : [notes[hoveredResizeIdx].id])]; const selectedNotes = notes.filter(n => allSelected.includes(n.id)); const firstStart = Math.min(...selectedNotes.map(n => n.start_beat)); const draggedNote = notes[hoveredResizeIdx]; setDraggedNote({ mode: 'scale', idx: hoveredResizeIdx, originalEnd: draggedNote.start_beat + draggedNote.duration_beats, firstStart: firstStart, selectedNoteIds: allSelected }); return; } // Hovered resize edge (normal resize) if (hoveredResizeIdx !== -1) { pushToUndo(notes); notesBeforeDragRef.current = JSON.parse(JSON.stringify(notes)); setDraggedNote({ mode: 'resize', idx: hoveredResizeIdx, originalStart: notes[hoveredResizeIdx].start_beat }); return; } if (clickedNoteIdx !== -1) { // Click on existing note: drag-move → focus its MIDI item setFocusItemId(st.target_id); const clickedNote = notes[clickedNoteIdx]; let nextSelectedIds; if (!selectedNoteIds.includes(clickedNote.id)) { nextSelectedIds = [clickedNote.id]; setSelectedNoteIds(nextSelectedIds); } else { nextSelectedIds = selectedNoteIds; } pushToUndo(notes); notesBeforeDragRef.current = JSON.parse(JSON.stringify(notes)); const selectedNotesOffset = notes .filter(n => nextSelectedIds.includes(n.id)) .map(n => ({ id: n.id, originalStartBeat: n.start_beat, originalPitch: n.pitch })); setDraggedNote({ mode: 'move', idx: clickedNoteIdx, startOffsetBeat: beat - clickedNote.start_beat, startOffsetPitch: pitch, selectedNotesOffset: selectedNotesOffset, clickedOriginalStartBeat: clickedNote.start_beat }); } else { // Click on a same-track ghost note → focus that MIDI item (no drawing) if (!e.ctrlKey && !e.shiftKey && !e.altKey) { var ghostHit = null; for (var gi = 0; gi < ghostLayers.length; gi++) { var gl = ghostLayers[gi]; if (!gl.isSameTrack || !gl.notes) continue; for (var gn = 0; gn < gl.notes.length; gn++) { var gnote = gl.notes[gn]; if (pitch === gnote.pitch && beat >= gnote.relative_start_beat && beat < gnote.relative_start_beat + (gnote.duration_beats || 1)) { ghostHit = gnote; break; } } if (ghostHit) break; } if (ghostHit) { setFocusItemId(ghostHit.item_id || st.target_id); if (ghostHit.item_id && ghostHit.item_id !== st.target_id) { handleSwitchMidiItem(ghostHit.item_id); } return; } } // Click on empty space with pen tool: DRAW a new note (brush mode with visitedPitches) pushToUndo(notes); const start = getSnapBeat(beat, snapValue); const initialDur = lastNoteDurationRef.current || getSnapDuration(snapValue); const noteId = 'note_' + Date.now() + Math.random().toString(36).substr(2, 5); const newNote = { id: noteId, pitch: snapToScaleRef.current ? snapPitchToScale(pitch, selectedScaleRef.current) : pitch, start_beat: start, duration_beats: initialDur, velocity: brushVelocityRef.current, pan: 0.0 }; setNotes(prev => [...prev, newNote]); setSelectedNoteIds([noteId]); setDraggedNote({ mode: 'draw', idx: -1, startOffsetBeat: start, startOffsetPitch: snapToScaleRef.current ? snapPitchToScale(pitch, selectedScaleRef.current) : pitch, drawNoteId: noteId, drawDuration: initialDur, initialBeat: start, initialPitch: snapToScaleRef.current ? snapPitchToScale(pitch, selectedScaleRef.current) : pitch, noteStartBeats: [start], lastDrawnPitch: snapToScaleRef.current ? snapPitchToScale(pitch, selectedScaleRef.current) : pitch }); // Play the note with SoundFont - stop previous preview first if (previewNodesRef.current) { try { previewNodesRef.current.osc.stop(); } catch(e) {} try { previewNodesRef.current.osc.disconnect(); } catch(e) {} try { previewNodesRef.current.gain.disconnect(); } catch(e) {} previewNodesRef.current = null; } if (window.SonicSF && window.SonicSF._playNoteFallback) { const ctx = getAudioContext(); var dwTrk = activeTracks.find(function(t) { return t.id === st.trackId; }); var dwCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(dwTrk, activeTracks) : (dwTrk ? dwTrk.midiChannel : 0); var dwPitch = snapToScaleRef.current ? snapPitchToScale(pitch, selectedScaleRef.current) : pitch; var dwDurMs = Math.max(100, Math.round(initialDur * (60 / bpm) * 1000)); var dwNodes = window.SonicSF._playNoteFallback(dwPitch, Math.round(brushVelocityRef.current * 127), dwDurMs, ctx.currentTime, dwTrk ? dwTrk.instrumentProgram : undefined, null, dwCh, dwTrk ? dwTrk.synth_engine : undefined); if (dwNodes) previewNodesRef.current = dwNodes; } } }; 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 - renderBeatOffset; const pitch = 127 - Math.floor(y / NoteHeight); if (selectionMarquee) { const snappedBeat = getSnapBeat(beat, snapValue); const marquee = { ...selectionMarquee, currentBeat: snappedBeat, currentPitch: pitch }; setSelectionMarquee(marquee); const minBeat = Math.min(marquee.startBeat, marquee.currentBeat); const maxBeat = Math.max(marquee.startBeat, marquee.currentBeat); const minPitch = Math.min(marquee.startPitch, marquee.currentPitch); const maxPitch = Math.max(marquee.startPitch, marquee.currentPitch); const insideIds = notes .filter(n => { const withinPitch = n.pitch >= minPitch && n.pitch <= maxPitch; if (!withinPitch) return false; const noteEnd = n.start_beat + n.duration_beats; if (marquee.startBeat <= marquee.currentBeat) { // Left to right: select if any overlap return n.start_beat <= maxBeat && noteEnd >= minBeat; } else { // Right to left: select only if fully covered return n.start_beat >= minBeat && noteEnd <= maxBeat; } }) .map(n => n.id); setSelectedNoteIds(insideIds); return; } // Right-click drag → erase sweep const rc = rightClickDragRef.current; if (rc.active && (Math.abs(e.clientX - rc.startX) > 5 || Math.abs(e.clientY - rc.startY) > 5)) { rc.active = false; swallowContextMenuRef.current = true; notesBeforeDragRef.current = JSON.parse(JSON.stringify(notes)); setDraggedNote({ mode: 'erase_sweep', visitedPitches: [] }); return; } if (!draggedNote) { 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 === 'draw') { const snappedPitch = snapToScaleRef.current ? snapPitchToScale(pitch, selectedScaleRef.current) : pitch; const lastPitch = draggedNote.lastDrawnPitch !== undefined ? draggedNote.lastDrawnPitch : draggedNote.startOffsetPitch; const pitchChanged = snappedPitch !== lastPitch; const noteBeats = draggedNote.noteStartBeats || []; const defaultDur = getSnapDuration(snapValue); function playDrawPreview(p, durMs) { stopPreviewNote(); if (window.SonicSF && window.SonicSF._playNoteFallback) { var pvCtx = getAudioContext(); var pvTrk = activeTracks.find(function(t) { return t.id === st.trackId; }); var pvCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(pvTrk, activeTracks) : (pvTrk ? pvTrk.midiChannel : 0); var pvVel = Math.round(brushVelocityRef.current * 127); var pvNodes = window.SonicSF._playNoteFallback(p, pvVel, durMs, pvCtx.currentTime, pvTrk ? pvTrk.instrumentProgram : undefined, null, pvCh, pvTrk ? pvTrk.synth_engine : undefined); if (pvNodes) previewNodesRef.current = pvNodes; previewPitchRef.current = p; } } if (pitchChanged) { const brushIds = draggedNote.brushIds || []; if (brushIds.length > 0 && noteBeats.length > 0) { const prevNoteId = brushIds[brushIds.length - 1]; const prevNoteBeat = noteBeats[noteBeats.length - 1]; const prevDur = Math.max(0.125, beat - prevNoteBeat); setNotes(prev => prev.map(n => { if (n.id !== prevNoteId) return n; return { ...n, duration_beats: prevDur }; })); } const newNote = { id: 'note_' + Date.now() + Math.random().toString(36).substr(2, 8) + '_' + (noteBeats.length + 1), pitch: snappedPitch, start_beat: beat, duration_beats: defaultDur, velocity: brushVelocityRef.current, pan: 0.0 }; setNotes(prev => [...prev, newNote]); setSelectedNoteIds(prev => [...prev, newNote.id]); draggedNote.brushIds = [...brushIds, newNote.id]; draggedNote.lastDrawnPitch = snappedPitch; draggedNote.noteStartBeats = [...noteBeats, beat]; playDrawPreview(snappedPitch, Math.max(100, Math.round(defaultDur * (60 / bpm) * 1000))); } else { const brushIds = draggedNote.brushIds || []; const lastBrushId = brushIds.length > 0 ? brushIds[brushIds.length - 1] : draggedNote.drawNoteId; const lastNoteBeat = noteBeats.length > 0 ? noteBeats[noteBeats.length - 1] : draggedNote.startOffsetBeat; if (lastBrushId) { const extDur = Math.max(0.125, beat - lastNoteBeat); setNotes(prev => prev.map(n => { if (n.id !== lastBrushId) return n; return { ...n, duration_beats: extDur }; })); playDrawPreview(snappedPitch, Math.max(100, Math.round(extDur * (60 / bpm) * 1000))); } } const container = gridScrollRef.current; if (container) { const cr = container.getBoundingClientRect(); const visTop = container.scrollTop; const visBot = visTop + container.clientHeight; const pitchPixel = (127 - snappedPitch) * NoteHeight; const safeMargin = NoteHeight * 2; if (pitchPixel < visTop + safeMargin) { const target = Math.max(0, pitchPixel - safeMargin); if (container.scrollTop !== target) container.scrollTop = target; if (!brushAutoScrollRef.current || brushAutoScrollRef.current.direction !== 'up') { if (brushAutoScrollRef.current) clearInterval(brushAutoScrollRef.current.id); brushAutoScrollRef.current = { direction: 'up', id: setInterval(() => { if (gridScrollRef.current) gridScrollRef.current.scrollTop = Math.max(0, gridScrollRef.current.scrollTop - Math.max(1, Math.floor(NoteHeight * 0.5))); }, 16) }; } } else if (pitchPixel + NoteHeight > visBot - safeMargin) { const target = Math.min(container.scrollHeight - container.clientHeight, pitchPixel - container.clientHeight + safeMargin + NoteHeight); if (container.scrollTop !== target) container.scrollTop = target; if (!brushAutoScrollRef.current || brushAutoScrollRef.current.direction !== 'down') { if (brushAutoScrollRef.current) clearInterval(brushAutoScrollRef.current.id); brushAutoScrollRef.current = { direction: 'down', id: setInterval(() => { if (gridScrollRef.current) gridScrollRef.current.scrollTop = Math.min(gridScrollRef.current.scrollHeight - gridScrollRef.current.clientHeight, gridScrollRef.current.scrollTop + Math.max(1, Math.floor(NoteHeight * 0.5))); }, 16) }; } } else { if (brushAutoScrollRef.current) { clearInterval(brushAutoScrollRef.current.id); brushAutoScrollRef.current = null; } } } return; } if (draggedNote.mode === 'erase_sweep') { const erased = draggedNote.erasedIds || []; const target = notes.find(n => n.pitch === pitch && beat >= n.start_beat && beat < n.start_beat + n.duration_beats ); if (target && !erased.includes(target.id)) { draggedNote.erasedIds = [...erased, target.id]; setNotes(prev => prev.filter(n => n.id !== target.id)); setSelectedNoteIds(prev => prev.filter(id => id !== target.id)); } return; } if (draggedNote.mode === 'scale') { const newEnd = getSnapBeat(Math.max(draggedNote.firstStart + 0.125, beat), snapValue); const range = draggedNote.originalEnd - draggedNote.firstStart; if (range <= 0) return; const scaleFactor = Math.max(0.01, (newEnd - draggedNote.firstStart) / range); const ids = draggedNote.selectedNoteIds || []; const firstStart = draggedNote.firstStart; const notesBefore = notesBeforeDragRef.current; if (!notesBefore) return; setNotes(prev => prev.map(n => { if (!ids.includes(n.id)) return n; const orig = notesBefore ? notesBefore.find(o => o.id === n.id) : null; const origStart = orig ? orig.start_beat : n.start_beat; const origDur = orig ? orig.duration_beats : n.duration_beats; const relStart = origStart - firstStart; const relEnd = relStart + origDur; return { ...n, start_beat: firstStart + relStart * scaleFactor, duration_beats: Math.max(0.125, relEnd * scaleFactor - relStart * scaleFactor) }; })); return; } if (draggedNote.mode === 'resize') { const newDuration = getSnapBeat(Math.max(0.125, beat - draggedNote.originalStart), snapValue); setNotes(prev => prev.map((n, idx) => { if (idx !== draggedNote.idx) return n; return { ...n, duration_beats: newDuration }; })); } else if (draggedNote.mode === 'move') { const refOrigStart = draggedNote.clickedOriginalStartBeat; if (refOrigStart === undefined) return; const deltaBeat = getSnapBeat(beat - draggedNote.startOffsetBeat, snapValue) - refOrigStart; // Clamp so no note goes past beat 0 const minOrigStart = Math.min(...draggedNote.selectedNotesOffset.map(o => o.originalStartBeat)); const clampedDeltaBeat = minOrigStart + deltaBeat < 0 ? -minOrigStart : deltaBeat; const deltaPitch = Math.round(pitch - draggedNote.startOffsetPitch); setNotes(prev => prev.map(n => { const offset = draggedNote.selectedNotesOffset.find(o => o.id === n.id); if (!offset) return n; return { ...n, start_beat: getSnapBeat(Math.max(0, offset.originalStartBeat + clampedDeltaBeat), snapValue), pitch: Math.max(0, Math.min(127, offset.originalPitch + deltaPitch)) }; })); } }; const handleGridMouseUp = () => { if (draggedNote && draggedNote.mode === 'draw') { const dn = draggedNote; const brushIds = dn.brushIds || []; const lastBrushId = brushIds.length > 0 ? brushIds[brushIds.length - 1] : dn.drawNoteId; if (lastBrushId) { const lastNote = notes.find(n => n.id === lastBrushId); if (lastNote) lastNoteDurationRef.current = lastNote.duration_beats; } } setDraggedNote(null); setSelectionMarquee(null); rightClickDragRef.current = { active: false, startX: 0, startY: 0 }; if (brushAutoScrollRef.current) { clearInterval(brushAutoScrollRef.current.id); brushAutoScrollRef.current = null; } stopPreviewNote(); previewPitchRef.current = null; }; const handleContextMenu = (e) => { e.preventDefault(); if (swallowContextMenuRef.current) { swallowContextMenuRef.current = false; return; } const pos = { x: e.clientX, y: e.clientY, parentKey: null }; scaleMenuOriginRef.current = { x: pos.x, y: pos.y }; setScaleMenuPos(pos); }; const ccDragRef = React.useRef(null); const brushAutoScrollRef = React.useRef(null); const rightClickDragRef = React.useRef({ active: false, startX: 0, startY: 0 }); const swallowContextMenuRef = React.useRef(false); const findCCNoteIndex = (b, mouseY, ccH) => { const snapped = getSnapBeat(b, snapValue); const hits = []; notes.forEach((n, idx) => { if (snapped >= n.start_beat && snapped <= n.start_beat + n.duration_beats) { const nv = ccMode === 'pan' ? ((n.pan || 0) * 0.5 + 0.5) : (n.velocity !== undefined ? n.velocity : 0.8); const stemTop = ccH - (nv * (ccH - 20) + 10); hits.push({ idx, dist: Math.abs(stemTop - mouseY) }); } }); if (hits.length > 0) { hits.sort((a, b) => a.dist - b.dist); return hits[0].idx; } let nearest = -1; let minDist = Infinity; notes.forEach((n, idx) => { const center = n.start_beat + n.duration_beats / 2; const d = Math.abs(center - snapped); if (d < minDist) { minDist = d; nearest = idx; } }); return nearest; }; 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 - renderBeatOffset; const noteIdx = findCCNoteIndex(beat, y, h); const val = Math.max(0, Math.min(1, (h - y) / h)); if (e.ctrlKey) { if (selectedNoteIds.length > 0) { const cursorNoteIdx = findCCNoteIndex(beat, y, h); if (cursorNoteIdx !== -1 && selectedNoteIds.includes(notes[cursorNoteIdx] ? notes[cursorNoteIdx].id : -1)) { const currentNote = notes[cursorNoteIdx]; const currentVal = ccMode === 'pan' ? ((currentNote.pan || 0) / 2.0 + 0.5) : (currentNote.velocity !== undefined ? currentNote.velocity : 0.8); if (Math.abs(currentVal - val) > 0.001) { const updatedNotes = notes.map((n, i) => { if (i !== cursorNoteIdx) return n; if (ccMode === 'pan') return { ...n, pan: (val - 0.5) * 2.0 }; return { ...n, velocity: val }; }); setNotes(updatedNotes); if (isPlaying && onRescheduleMidi) onRescheduleMidi(updatedNotes); } ccDragRef.current = { active: true, lastBeat: beat, selectedMode: true, lastPainted: [cursorNoteIdx] }; } else { ccDragRef.current = { active: true, lastBeat: beat, selectedMode: true, lastPainted: [] }; } } else { ccDragRef.current = { active: true, lastBeat: beat, lastPainted: noteIdx !== -1 ? [noteIdx] : [] }; } return; } if (noteIdx !== -1) { const currentVal = ccMode === 'pan' ? ((notes[noteIdx].pan || 0) / 2.0 + 0.5) : (notes[noteIdx].velocity !== undefined ? notes[noteIdx].velocity : 0.8); if (Math.abs(currentVal - val) > 0.001) { const updatedNotes = notes.map((n, i) => { if (i !== noteIdx) return n; if (ccMode === 'pan') return { ...n, pan: (val - 0.5) * 2.0 }; return { ...n, velocity: val }; }); setNotes(updatedNotes); if (isPlaying && onRescheduleMidi) onRescheduleMidi(updatedNotes); } } }; const handleCCMouseMove = (e) => { if (!ccDragRef.current || !ccDragRef.current.active) return; 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 - renderBeatOffset; const val = Math.max(0, Math.min(1, (h - y) / h)); const drag = ccDragRef.current; const painted = drag.lastPainted || []; if (drag.selectedMode && selectedNoteIds.length > 0) { const cursorNoteIdx = findCCNoteIndex(beat, y, h); if (cursorNoteIdx !== -1) { const cursorNote = notes[cursorNoteIdx]; if (cursorNote && selectedNoteIds.includes(cursorNote.id) && !painted.includes(cursorNoteIdx)) { const currentVal = ccMode === 'pan' ? ((cursorNote.pan || 0) / 2.0 + 0.5) : (cursorNote.velocity !== undefined ? cursorNote.velocity : 0.8); if (Math.abs(currentVal - val) > 0.001) { const updatedNotes = notes.map((n, i) => { if (i !== cursorNoteIdx) return n; if (ccMode === 'pan') return { ...n, pan: (val - 0.5) * 2.0 }; return { ...n, velocity: val }; }); setNotes(updatedNotes); if (isPlaying && onRescheduleMidi) onRescheduleMidi(updatedNotes); } drag.lastPainted = [...painted, cursorNoteIdx]; } } return; } const candidateIdx = findCCNoteIndex(beat, y, h); if (candidateIdx !== -1 && !painted.includes(candidateIdx)) { const currentVal = ccMode === 'pan' ? ((notes[candidateIdx].pan || 0) / 2.0 + 0.5) : (notes[candidateIdx].velocity !== undefined ? notes[candidateIdx].velocity : 0.8); if (Math.abs(currentVal - val) > 0.001) { const updatedNotes = notes.map((n, i) => { if (i !== candidateIdx) return n; if (ccMode === 'pan') return { ...n, pan: (val - 0.5) * 2.0 }; return { ...n, velocity: val }; }); setNotes(updatedNotes); if (isPlaying && onRescheduleMidi) onRescheduleMidi(updatedNotes); } drag.lastPainted = [...painted, candidateIdx]; } }; const renderKeybed = () => { var kbTrk = activeTracks.find(function(t) { return t.id === st.trackId; }); var kbCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(kbTrk, activeTracks) : (kbTrk ? kbTrk.midiChannel : 0); var kbSynth = kbTrk ? kbTrk.synth_engine : undefined; const keys = []; for (let pitch = 127; pitch >= PITCH_START; 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 cursor-pointer ${activeMidiPitches && activeMidiPitches.has(pitch) ? 'bg-emerald-500 text-white border-emerald-400' : isBlack ? 'bg-zinc-950 text-slate-500 border-zinc-900 hover:bg-zinc-800' : 'bg-white text-zinc-800 border-r border-zinc-400 hover:bg-amber-100'}`, onMouseDown: (e) => { e.stopPropagation(); keybedMouseDownRef.current = true; try { if (window.triggerMidiVuActivity) { window.triggerMidiVuActivity(st.trackId, 100); } if (window.SonicSF) { window.SonicSF.playNote(pitch, 100, 500, undefined, st.instrumentProgram, null, kbCh, kbSynth); } } catch (err) { console.error('playNote error:', err); } }, onMouseEnter: (e) => { if (keybedMouseDownRef.current) { try { if (window.triggerMidiVuActivity) { window.triggerMidiVuActivity(st.trackId, 100); } if (window.SonicSF) { window.SonicSF.playNote(pitch, 100, 200, undefined, st.instrumentProgram, null, kbCh, kbSynth); } } catch (err) { console.error('playNote error:', err); } } }, onMouseUp: () => { keybedMouseDownRef.current = false; } }, showLabel && label) ); } return keys; }; const handleScroll = (e) => { if (keybedRef.current) { keybedRef.current.scrollTop = e.currentTarget.scrollTop; } if (rulerScrollRef.current) { rulerScrollRef.current.scrollLeft = e.currentTarget.scrollLeft; } if (ccWrapperRef.current) { ccWrapperRef.current.scrollLeft = e.currentTarget.scrollLeft; } const el = e.currentTarget; const THRESHOLD = 200; if (el.scrollLeft + el.clientWidth >= el.scrollWidth - THRESHOLD) { const newBeats = rollBeatsRef.current + 16; setRollBeats(newBeats); } }; const handleKeybedScroll = (e) => { if (gridScrollRef.current) { gridScrollRef.current.scrollTop = e.currentTarget.scrollTop; } }; const SCALES = { "None": null, "Diatonic": { "Major": [0, 2, 4, 5, 7, 9, 11], "Minor": [0, 2, 3, 5, 7, 8, 10], "Dorian": [0, 2, 3, 5, 7, 9, 10], "Phrygian": [0, 1, 3, 5, 7, 8, 10], "Lydian": [0, 2, 4, 6, 7, 9, 11], "Mixolydian": [0, 2, 4, 5, 7, 9, 10], "Locrian": [0, 1, 3, 5, 6, 8, 10] }, "Pentatonic": { "Major": [0, 2, 4, 7, 9], "Minor": [0, 3, 5, 7, 10], "Blues": [0, 3, 5, 6, 7, 10], "Chinese": [0, 2, 4, 7, 9], "Vietnam": [0, 2, 6, 7, 10], "India": [0, 2, 5, 7, 9], "Japan": [0, 2, 5, 7, 9], "Africa": [0, 3, 5, 7, 10] }, "Church": { "Dorian": [0, 2, 3, 5, 7, 9, 10], "Phrygian": [0, 1, 3, 5, 7, 8, 10], "Lydian": [0, 2, 4, 6, 7, 9, 11], "Mixolydian": [0, 2, 4, 5, 7, 9, 10] }, "Jazz": { "Blues": [0, 3, 5, 6, 7, 10], "Bebop": [0, 2, 4, 5, 7, 9, 10, 11], "Diminished": [0, 2, 3, 5, 6, 8, 9, 11] }, "Asian Traditional": { "Chinese": [0, 2, 4, 7, 9], "Vietnam": [0, 2, 6, 7, 10], "Japan": [0, 2, 5, 7, 9], "India": [0, 2, 5, 7, 9], "Gamelan": [0, 2, 4, 7, 9], "Korea": [0, 2, 4, 5, 7, 9] }, "Middle Eastern": { "Hijaz": [0, 1, 4, 5, 7, 8, 11], "Nikriz": [0, 1, 4, 5, 7, 8, 10], "Rast": [0, 2, 4, 5, 7, 8, 10], "Saba": [0, 1, 3, 4, 7, 8, 10], "Bayati": [0, 2, 3, 4, 7, 8, 10] } }; const [selectedScale, setSelectedScale] = React.useState(null); const selectedScaleRef = React.useRef(null); selectedScaleRef.current = selectedScale; const snapToScaleRef = React.useRef(true); snapToScaleRef.current = st.snapToScale !== undefined ? st.snapToScale : true; const [scaleMenuPos, setScaleMenuPos] = React.useState(null); const scaleMenuOriginRef = React.useRef(null); const [showCC, setShowCC] = React.useState(true); const [ccHeight, setCcHeight] = React.useState(80); const snapPitchToScale = (pitch, scale) => { if (!scale) return pitch; const octave = Math.floor(pitch / 12); const noteInOctave = pitch % 12; if (scale.includes(noteInOctave)) return pitch; let best = noteInOctave; let minDist = 12; scale.forEach(s => { const dist = Math.abs(s - noteInOctave); if (dist < minDist) { minDist = dist; best = s; } }); return octave * 12 + best; }; const renderScaleContextMenu = () => { const closeMenu = () => setScaleMenuPos(null); const origin = scaleMenuOriginRef.current || scaleMenuPos; const items = []; let subMenu = null; const isSameScale = (a, b) => { if (!a || !b) return a === b; if (a.length !== b.length) return false; return a.every((v,i)=>v===b[i]); }; const pushItem = (label, onClick, onHover) => { const isActive = onClick._scale && isSameScale(onClick._scale, selectedScale); items.push(React.createElement("div", { key: label, onClick: () => { onClick(); closeMenu(); }, onMouseEnter: onHover || undefined, className: "px-3 py-1.5 text-xs cursor-pointer hover:bg-amber-600 hover:text-white whitespace-nowrap " + (isActive ? "bg-amber-800/40 text-amber-300" : "text-zinc-300") }, label)); }; Object.keys(SCALES).forEach(key => { const val = SCALES[key]; if (val === null) { pushItem("None", () => setSelectedScale(null)); return; } if (Array.isArray(val)) { pushItem(key, () => setSelectedScale(val)); } else { const parentKey = key; const isOpen = scaleMenuPos && scaleMenuPos.parentKey === parentKey; pushItem(key + " ▸", () => setSelectedScale(null), () => { setScaleMenuPos({ x: origin.x, y: origin.y, parentKey }); }); if (isOpen) { const subs = []; Object.keys(val).forEach(subKey => { subs.push(React.createElement("div", { key: subKey, onClick: () => { setSelectedScale(val[subKey]); closeMenu(); }, className: "px-3 py-1.5 text-xs cursor-pointer hover:bg-amber-600 hover:text-white whitespace-nowrap " + (isSameScale(val[subKey], selectedScale) ? "bg-amber-800/40 text-amber-300" : "text-zinc-300") }, subKey)); }); var subH = subs.length * 30 + 16; var subTop = (origin.y + subH + 20 > window.innerHeight) ? (origin.y - subH) : origin.y; subMenu = React.createElement("div", { style: { position: "fixed", left: origin.x + 150, top: subTop, zIndex: 10000 }, className: "bg-[#2a2a2a] border border-zinc-600 rounded shadow-xl py-1 min-w-[120px]" }, ...subs); } } }); var menuH = items.length * 30 + 16; var menuTop = (origin.y + menuH + 20 > window.innerHeight) ? Math.max(10, origin.y - menuH) : origin.y; return React.createElement(React.Fragment, null, React.createElement("div", { style: { position: "fixed", left: origin.x, top: menuTop, zIndex: 9999 }, className: "bg-[#2a2a2a] border border-zinc-600 rounded shadow-xl py-1 min-w-[140px]" }, ...items), subMenu ); }; const renderBarLabels = () => { const labels = []; const barsCount = Math.ceil(viewBeats / 4); const barOffset = Math.floor(sessionStartBar); for (let bar = 0; bar < barsCount; bar++) { const x = bar * 4 * pixelsPerBeat; const displayBar = bar + barOffset; labels.push( /*#__PURE__*/React.createElement("div", { key: bar, style: { position: 'absolute', left: `${x}px`, top: '4px' }, className: "pl-1 border-l border-zinc-700 h-full select-none cursor-pointer hover:bg-zinc-800/30", onClick: (e) => { e.stopPropagation(); const barTime = bar * 4 * beatSec; setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: barTime } : s)); } }, `Bar ${displayBar}`) ); } return labels; }; const beatSec = 60.0 / (parseInt(bpm) || 120); const playHeadX = (st.currentTime || 0) / beatSec * pixelsPerBeat; return React.createElement("div", { className: "flex-1 flex overflow-hidden min-h-0 bg-[#1e1e1e] flex-col h-full" }, /* 1. TOOLBAR HEADER */ 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" }, React.createElement("div", { className: "flex items-center gap-4" }, React.createElement("select", { value: st.trackId || '', onChange: function(e) { var trkId = e.target.value; var trkSel = (activeTracks || []).find(function(t) { return t.id === trkId; }); if (trkSel && trkSel.midiItems && trkSel.midiItems.length) handleSwitchMidiItem(trkSel.midiItems[0].id); }, className: "bg-zinc-800 border border-zinc-700 text-yellow-500 font-bold text-xs rounded px-1.5 py-0.5 outline-none focus:border-yellow-500 max-w-[180px] uppercase" }, (function() { var seenTrackOpts = {}; var trackOpts = []; (activeTracks || []).forEach(function(t) { if (!t.midiItems || !t.midiItems.length) return; if (seenTrackOpts[t.id]) return; seenTrackOpts[t.id] = true; trackOpts.push(React.createElement("option", { key: t.id, value: t.id }, t.name || t.id)); }); return trackOpts; }())), activeParentTrackName ? React.createElement("span", { className: "text-[9px] text-zinc-500 ml-1" }, "(Belongs to: ", React.createElement("span", { className: "text-zinc-400 font-semibold" }, activeParentTrackName), ")") : null, React.createElement("div", { className: "flex items-center gap-1 text-xs" }, React.createElement("span", { className: "text-zinc-500 font-semibold" }, "Snap to Scale"), React.createElement("button", { onClick: () => setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, snapToScale: !(s.snapToScale !== undefined ? s.snapToScale : true) } : s)), className: `w-7 h-4 rounded-full transition-colors relative ${(st.snapToScale !== undefined ? st.snapToScale : true) ? 'bg-yellow-600' : 'bg-zinc-700'}`, style: { padding: 0 } }, React.createElement("div", { className: `w-3 h-3 rounded-full bg-white absolute top-0.5 transition-transform ${(st.snapToScale !== undefined ? st.snapToScale : true) ? 'translate-x-3.5' : 'translate-x-0.5'}` }))), React.createElement("div", { className: "flex items-center gap-1 text-xs" }, React.createElement("span", { className: "text-zinc-500 font-semibold" }, "Snap:"), React.createElement("select", { value: snapValue, onChange: e => { onSnapChange(e.target.value); setRenderTick(t => t + 1); }, 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', '4', '1', '1/2', '1/4', '1/8', '1/16', '1/32'].map(v => React.createElement("option", { key: v, value: v }, v)))), React.createElement("button", { onClick: () => setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, isArmed: !s.isArmed } : s)), className: `px-2 py-1 rounded text-xs font-bold ${st.isArmed ? 'bg-red-600 text-white' : 'bg-zinc-800 text-zinc-400'}` }, "ARM"), React.createElement("select", { value: selectedMidiInputId || '', onChange: e => onMidiInputSelect(e.target.value), className: "bg-zinc-800 border border-zinc-700 text-zinc-300 rounded px-1 py-0.5 text-xs outline-none max-w-[100px]" }, React.createElement("option", { value: "" }, "Input"), React.createElement("option", { value: "ALL" }, "Omni"), (midiDevices || []).map(d => React.createElement("option", { key: d.id, value: d.id }, d.name || d.id))), React.createElement("button", { onClick: () => onInstrumentSelect && onInstrumentSelect(st.trackId), title: st.instrumentName || "Synth", className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 max-w-[70px] ${st.instrumentName ? 'bg-violet-900 text-violet-300 border-violet-700' : 'bg-zinc-800 text-zinc-500 border-transparent hover:text-zinc-300'}` }, React.createElement("i", { "data-lucide": "music", className: "w-3 h-3 shrink-0" }), React.createElement("span", { className: "truncate text-[9px]" }, activeParentTrackName ? ('(' + activeParentTrackName + ') ' + (st.instrumentName || 'Synth')) : (st.instrumentName || 'Synth'))), React.createElement("div", { className: "flex items-center gap-1 ml-1 text-xs" }, React.createElement("span", { className: "text-zinc-500" }, "AI:"), React.createElement("input", { type: "number", value: aiBarStart, onChange: e => setAiBarStart(parseInt(e.target.value) || 0), className: "w-8 bg-zinc-900 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 text-xs text-center" }), React.createElement("span", { className: "text-zinc-500" }, "-"), React.createElement("input", { type: "number", value: aiBarEnd, onChange: e => setAiBarEnd(parseInt(e.target.value) || 1), className: "w-8 bg-zinc-900 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 text-xs text-center" }), React.createElement("span", { className: "text-zinc-500" }, "bar")), React.createElement("select", { value: ccMode, onChange: e => setCcMode(e.target.value), className: "bg-zinc-800 text-zinc-200 border border-zinc-700 rounded px-1.5 py-1 text-xs capitalize cursor-pointer" }, React.createElement("option", { value: "velocity" }, "Velocity"), React.createElement("option", { value: "sustain" }, "Sustain"), React.createElement("option", { value: "modulation" }, "Modulation"), React.createElement("option", { value: "pitch_bend" }, "Pitch Bend"), React.createElement("option", { value: "pan" }, "Pan"))), React.createElement("button", { onClick: () => setShowCC(!showCC), className: `px-2 py-1 rounded text-xs ${showCC ? 'bg-purple-900/60 text-purple-300 border border-purple-700' : 'text-zinc-500 hover:text-zinc-300'}` }, ccMode === 'velocity' ? 'Vel' : ccMode === 'sustain' ? 'Sus' : ccMode === 'modulation' ? 'Mod' : ccMode === 'pitch_bend' ? 'Bend' : ccMode === 'pan' ? 'Pan' : 'CC'), React.createElement("button", { onClick: function() { setSessionSyncMode(function(p) { return !p; }); }, className: function() { var base = 'px-2 py-1 rounded text-xs '; return sessionSyncMode ? base + 'bg-cyan-900/60 text-cyan-300 border border-cyan-700' : base + 'text-zinc-500 hover:text-zinc-300'; }(), title: sessionSyncMode ? "Session-synced mode (ghost visible)" : "Isolated mode (bar 0, no ghost)" }, sessionSyncMode ? "\uD83C\uDF10 Session" : "\uD83D\uDCCB Isolated"), React.createElement("button", { onClick: function() { setShowGhostNotes(function(p) { return !p; }); }, disabled: !sessionSyncMode, className: function() { if (!sessionSyncMode) return 'px-2 py-1 rounded text-xs opacity-30 cursor-not-allowed'; var base = 'px-2 py-1 rounded text-xs '; return showGhostNotes ? base + 'bg-purple-900/60 text-purple-300 border border-purple-700' : base + 'text-zinc-500 hover:text-zinc-300'; }(), title: "Toggle ghost notes visibility" }, "\uD83D\uDC7B Ghost"), React.createElement("div", { className: "flex items-center gap-1" }, React.createElement("button", { onClick: () => onSaveNotes(st.id, st.trackId, st.target_id, notes), className: "px-2.5 py-1 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-xs flex items-center gap-1 transition font-semibold" }, React.createElement("i", { "data-lucide": "save", className: "w-3 h-3" }), "Lưu"), React.createElement("button", { onClick: () => { const ppq = 480; const bpmNum = parseInt(bpm) || 120; const ticksPerBeat = ppq; const events = []; (notes || []).forEach(n => { const startTick = Math.round((n.start_beat || 0) * ticksPerBeat); const durTick = Math.round((n.duration_beats || 1) * ticksPerBeat); const pitch = n.pitch || 60; const vel = Math.round((n.velocity || 0.8) * 127); events.push({ tick: startTick, type: 'note_on', pitch, velocity: vel }); events.push({ tick: startTick + durTick, type: 'note_off', pitch, velocity: 0 }); }); events.sort((a, b) => a.tick - b.tick || (a.type === 'note_off' ? -1 : 1)); const writeVLQ = (bytes, v) => { let val = Math.max(0, v); const buf = []; buf.push(val & 0x7F); while (val > 0x7F) { val >>= 7; buf.push(0x80 | (val & 0x7F)); } for (let i = buf.length - 1; i >= 0; i--) bytes.push(buf[i]); }; const trackBytes = []; let lastTick = 0; events.forEach(ev => { const delta = Math.max(0, ev.tick - lastTick); writeVLQ(trackBytes, delta); trackBytes.push(ev.type === 'note_on' ? 0x90 : 0x80, ev.pitch, ev.velocity); lastTick = ev.tick; }); writeVLQ(trackBytes, 0); trackBytes.push(0xFF, 0x2F, 0x00); const trackData = [0x4D, 0x54, 0x72, 0x6B]; const len = trackBytes.length; trackData.push((len >> 24) & 0xFF, (len >> 16) & 0xFF, (len >> 8) & 0xFF, len & 0xFF); trackData.push(...trackBytes); const header = [0x4D, 0x54, 0x68, 0x64, 0x00, 0x00, 0x00, 0x06, 0x00, 0x01, 0x00, 0x01, (ppq >> 8) & 0xFF, ppq & 0xFF]; const all = header.concat(trackData); const blob = new Blob([new Uint8Array(all)], { type: 'audio/midi' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = (st.label || 'midi') + '.mid'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); showToast('Đã xuất file MIDI!', 'success'); }, className: "px-2.5 py-1 bg-amber-700 hover:bg-amber-600 text-white rounded text-xs flex items-center gap-1 transition font-semibold" }, React.createElement("i", { "data-lucide": "file-down", className: "w-3 h-3" }), "Export"), 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" }, React.createElement("i", { "data-lucide": "x", className: "w-3 h-3" }), "Đóng"))), /* 2. BAR RULER */ React.createElement("div", { className: "h-6 bg-[#1a1a1f] border-b border-zinc-900 flex shrink-0" }, React.createElement("div", { className: "w-[120px] bg-[#1e1e22] border-r border-zinc-800 shrink-0 flex items-end" }, React.createElement("span", { className: "text-[8px] text-zinc-600 font-mono px-1.5 pb-0.5 uppercase tracking-wider" }, "Tracks")), React.createElement("div", { className: "w-[60px] bg-[#222222] border-r border-zinc-900 shrink-0" }), React.createElement("div", { ref: rulerScrollRef, className: "flex-1 overflow-hidden", onMouseDown: (e) => { const rect = e.currentTarget.getBoundingClientRect(); const x = e.clientX - rect.left + e.currentTarget.scrollLeft; const clickBeat = x / pixelsPerBeat; const clickTime = clickBeat * beatSec; const clickInRange = loopStartBeat !== null && loopEndBeat !== null && clickBeat >= loopStartBeat && clickBeat <= loopEndBeat; if (e.ctrlKey || e.metaKey) { setLoopStartBeat(null); setLoopEndBeat(null); setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, selectionStart: null, selectionEnd: null } : s)); return; } if (e.shiftKey) { const beatSnap = getSnapBeat(clickBeat, snapValue); if (loopStartBeat === null) { setLoopStartBeat(Math.max(0, beatSnap - 4)); setLoopEndBeat(Math.max(4, beatSnap)); } else { setLoopEndBeat(Math.max(loopStartBeat + 4, beatSnap)); } return; } if (clickTime >= 0) { if (onSeekPlayhead) { onSeekPlayhead(clickTime); } else { setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, currentTime: clickTime } : s)); } } const snappedStartBeat = getSnapBeat(clickBeat, snapValue); rulerDragRef.current = { startX: e.clientX, startBeat: snappedStartBeat, scrollLeft: e.currentTarget.scrollLeft }; const onMove = (ev) => { const r = rulerScrollRef.current; if (!r || !rulerDragRef.current) return; const rRect = r.getBoundingClientRect(); const bx = ev.clientX - rRect.left + rulerDragRef.current.scrollLeft; const rawBeat = Math.max(0, bx / pixelsPerBeat); const beat = getSnapBeat(rawBeat, snapValue); if (Math.abs(ev.clientX - rulerDragRef.current.startX) > 5) { if (clickInRange) { const rangeWidth = loopEndBeat - loopStartBeat; const offset = rulerDragRef.current.startBeat - loopStartBeat; const centerBeat = beat - offset; const halfRange = rangeWidth / 2; const newStart = Math.max(0, centerBeat - halfRange); setLoopStartBeat(newStart); setLoopEndBeat(newStart + rangeWidth); setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, selectionStart: newStart * beatSec, selectionEnd: (newStart + rangeWidth) * beatSec } : s)); } else { const sBeat = Math.max(0, Math.min(rulerDragRef.current.startBeat, beat)); const eBeat = Math.max(sBeat + 1, Math.max(rulerDragRef.current.startBeat, beat)); setLoopStartBeat(sBeat); setLoopEndBeat(eBeat); setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, selectionStart: sBeat * beatSec, selectionEnd: eBeat * beatSec } : s)); } } }; const onUp = () => { rulerDragRef.current = null; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); } }, React.createElement("div", { style: { width: `${viewWidth}px`, height: '100%' }, className: "relative h-full font-mono text-[9px] text-zinc-500 font-bold" }, renderBarLabels(), loopStartBeat !== null && loopEndBeat !== null && loopEndBeat > loopStartBeat && React.createElement("div", { style: { left: `${loopStartBeat * pixelsPerBeat}px`, width: `${(loopEndBeat - loopStartBeat) * pixelsPerBeat}px`, top: 0, bottom: 0 }, className: "absolute bg-emerald-500/15 border-l border-r border-emerald-400" }, React.createElement("div", { style: { position: 'absolute', left: 0, top: 0, bottom: 0, width: '4px', cursor: 'ew-resize' }, onMouseDown: (e) => { e.stopPropagation(); const startBeat = loopStartBeat; const onMove = (ev) => { const r = rulerScrollRef.current; if (!r) return; const rRect = r.getBoundingClientRect(); const bx = ev.clientX - rRect.left + r.scrollLeft; const nBeat = Math.max(0, Math.min(loopEndBeat - 1, getSnapBeat(bx / pixelsPerBeat, snapValue))); setLoopStartBeat(nBeat); setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, selectionStart: nBeat * beatSec } : s)); }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); } }), React.createElement("div", { style: { position: 'absolute', right: 0, top: 0, bottom: 0, width: '4px', cursor: 'ew-resize' }, onMouseDown: (e) => { e.stopPropagation(); const onMove = (ev) => { const r = rulerScrollRef.current; if (!r) return; const rRect = r.getBoundingClientRect(); const bx = ev.clientX - rRect.left + r.scrollLeft; const nBeat = Math.max(loopStartBeat + 1, getSnapBeat(bx / pixelsPerBeat, snapValue)); setLoopEndBeat(nBeat); setSubTabs(prev => prev.map(s => s.id === st.id ? { ...s, selectionEnd: nBeat * beatSec } : s)); }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); } }))))), /* 3. MAIN PIANO ROLL GRID (KEYBOARD + TRACK COL + CANVAS) */ React.createElement("div", { className: "flex-1 flex overflow-hidden min-h-0 relative" }, /* Track column */ React.createElement("div", { className: "w-[120px] bg-[#16161a] border-r border-zinc-800 shrink-0 flex flex-col z-10", style: { height: KeybedPixelHeight + 'px' } }, (allMidiItems.length > 0 ? function() { var seenTracks = {}; var els = []; allMidiItems.forEach(function(m) { if (seenTracks[m._trackId]) return; seenTracks[m._trackId] = true; var track = (activeTracks || []).find(function(t) { return t.id === m._trackId; }); var isActive = m._trackId === st.trackId && m.id === st.target_id; var isPlayOn = activePlayTrackIds && activePlayTrackIds.indexOf(m._trackId) !== -1; els.push(React.createElement("button", { key: m._trackId, onClick: function() { var prevList = activePlayTrackIds || []; var nextList = prevList.indexOf(m._trackId) !== -1 ? prevList.filter(function(id) { return id !== m._trackId; }) : prevList.concat([m._trackId]); setActivePlayTrackIds(nextList); if (onRealtimePlay) onRealtimePlay(nextList); }, className: "flex items-center justify-center h-[20px] border border-zinc-600 rounded-md cursor-pointer outline-none mx-1 my-[2px] " + (isActive ? 'bg-yellow-600 text-black font-bold' : (isPlayOn ? 'bg-red-700 text-white font-semibold' : 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600')) }, React.createElement("span", { className: "text-[14px] font-sans truncate px-1", title: track ? track.name : m._trackName }, track ? track.name : m._trackName))); }); return els; }() : null)), React.createElement("div", { className: "w-[60px] shrink-0 flex flex-col border-r border-zinc-900 overflow-y-auto", ref: keybedRef, onScroll: handleKeybedScroll, style: { scrollbarWidth: 'none', msOverflowStyle: 'none' } }, renderKeybed()), React.createElement("div", { ref: gridScrollRef, onScroll: handleScroll, className: "flex-1 overflow-auto bg-[#141414] min-w-0" }, React.createElement("div", { style: { width: `${viewWidth}px`, height: `${(128 - PITCH_START) * NoteHeight}px` }, className: "relative" }, React.createElement("canvas", { ref: canvasRef, onMouseDown: handleGridMouseDown, onMouseMove: handleGridMouseMove, onMouseUp: handleGridMouseUp, onMouseLeave: handleGridMouseUp, onContextMenu: handleContextMenu, className: "absolute inset-0 cursor-crosshair" }), loopStartBeat !== null && loopEndBeat !== null && loopEndBeat > loopStartBeat && React.createElement("div", { style: { left: `${loopStartBeat * pixelsPerBeat}px`, width: `${(loopEndBeat - loopStartBeat) * pixelsPerBeat}px`, top: 0, bottom: 0 }, className: "absolute bg-emerald-500/10 border-l border-r border-emerald-400/50 pointer-events-none" })))), /* 4. CC / VELOCITY LANE (ĐẶT Ở ĐÁY) */ showCC && React.createElement("div", { style: { height: `${ccHeight}px` }, className: "bg-[#161616] border-t border-zinc-900 flex shrink-0 relative" }, React.createElement("div", { onMouseDown: e => { e.preventDefault(); const startY = e.clientY; const startH = ccHeight; const onMove = ev => { setCcHeight(Math.max(40, startH + startY - ev.clientY)); }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }, className: "absolute top-0 left-0 right-0 h-1.5 cursor-n-resize z-10 hover:bg-purple-600/30" }), React.createElement("div", { className: "w-[120px] shrink-0" }), 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()), React.createElement("div", { ref: ccWrapperRef, className: "flex-1 overflow-x-hidden min-w-0" }, React.createElement("div", { style: { width: `${viewWidth}px`, height: '100%' }, className: "relative" }, React.createElement("canvas", { ref: ccCanvasRef, onMouseDown: handleCCMouseDown, onMouseMove: handleCCMouseMove, onMouseUp: () => { if (ccDragRef.current) ccDragRef.current.selectedMode = false; ccDragRef.current = null; }, onMouseLeave: () => { if (ccDragRef.current) ccDragRef.current.selectedMode = false; ccDragRef.current = null; }, className: "absolute inset-0" }), loopStartBeat !== null && loopEndBeat !== null && loopEndBeat > loopStartBeat && React.createElement("div", { style: { left: `${loopStartBeat * pixelsPerBeat}px`, width: `${(loopEndBeat - loopStartBeat) * pixelsPerBeat}px`, top: 0, bottom: 0 }, className: "absolute bg-emerald-500/10 border-l border-r border-emerald-400/50 pointer-events-none" })))), /* 5. OVERLAY / CONTEXT MENU */ scaleMenuPos && renderScaleContextMenu() ); }; const serializeTracksList = (tracksList, secondsPerBar) => { return (tracksList || []).map(t => { let trackType = "AUDIO"; if (t.type === 'MIDI' || t.type === 'soundfont' || t.type === 'vst3') trackType = "MIDI"; else if (t.type === 'SECTION') trackType = "SECTION"; else 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; const clipFileId = c.serverFileId || t.serverFileId; 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: clipFileId ? `/static/audio/uploads/${clipFileId}` : "", sample_rate: c.buffer ? c.buffer.sampleRate : 44100, channels: c.buffer ? c.buffer.numberOfChannels : 2, gain: 1.0, server_file_id: clipFileId } }); }); } 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 ? (m.duration / secondsPerBar) : 4.0, clip_start_offset_bars: 0.0, source_data: { total_buffer_bars: m.duration ? (m.duration / secondsPerBar) : 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, instrument_id: t.instrumentId || null, instrument_program: t.instrumentProgram !== undefined ? t.instrumentProgram : null, instrument_name: t.instrumentName || null, instrument_source: t.instrument_source || (t.synth_engine ? t.synth_engine.type : null), soundfont_id: t.soundfont_id || (t.synth_engine ? t.synth_engine.soundfont_id : null), soundfont_bank: t.soundfont_bank !== undefined ? t.soundfont_bank : (t.synth_engine ? t.synth_engine.soundfont_bank : null), soundfont_program: t.soundfont_program !== undefined ? t.soundfont_program : (t.synth_engine ? t.synth_engine.soundfont_program : null), synth_engine: t.synth_engine || undefined, server_file_id: t.serverFileId || null, items: items }; }); }; const deserializeTracksList = (schemaTracks, secondsPerBar, sectionStore) => { return (schemaTracks || []).map(t => { const clips = []; const sections = []; const midiItems = []; (t.items || []).forEach(item => { if (item.type === "AUDIO_ITEM") { const src = item.source_data || {}; const clipFileId = src.server_file_id || (src.audio_file_url ? src.audio_file_url.split('/').pop() : null) || null; clips.push({ id: item.id, name: item.name, startTime: item.start_bar * secondsPerBar, speed: 1.0, duration: item.duration_bars * secondsPerBar, serverFileId: clipFileId }); } else if (item.type === "MIDI_ITEM") { const src = item.source_data || {}; midiItems.push({ id: item.id, name: item.name, parent_track_id: t.id, startTime: item.start_bar * secondsPerBar, duration: item.duration_bars * secondsPerBar, length_bars: item.duration_bars || 4, notes: (src.notes || []).map(n => ({ id: n.id, 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 (item.type === "SECTION_ITEM") { const src = item.source_data || {}; const secId = src.referenced_section_id; const secContainer = sectionStore ? sectionStore[secId] : null; sections.push({ id: item.id, name: item.name, start: item.start_bar * secondsPerBar, duration: item.duration_bars * secondsPerBar, length_bars: item.duration_bars || 4, sectionId: secId, tracks: secContainer ? deserializeTracksList(secContainer.tracks, secondsPerBar, sectionStore) : null }); } }); return { id: t.id, name: t.name, type: t.type === 'MIDI' ? 'MIDI' : (t.type === 'SECTION' ? 'SECTION' : 'audio'), volumeDb: t.volume_db || 0.0, pan: t.pan || 0.0, muted: t.mute || false, solo: t.solo || false, color: t.color || (t.id === '1' ? '#0f766e' : '#1d4ed8'), startTime: t.start_time || 0, height: t.height || 140, markers: t.markers || [], serverFileId: t.server_file_id || (t.items && t.items.find(function(i) { return i.type === 'AUDIO_ITEM'; })?.source_data?.server_file_id) || (t.items && t.items.find(function(i) { return i.type === 'AUDIO_ITEM'; })?.source_data?.audio_file_url?.split('/').pop()) || null, channelInfo: t.channel_info || null, isArmed: t.is_armed || false, monitoringEnabled: t.monitoring_enabled !== false, inputSource: t.input_source ? { deviceType: t.input_source.device_type || 'NONE', deviceId: t.input_source.device_id || '' } : { deviceType: 'NONE', deviceId: '' }, midiChannel: t.midi_channel != null ? t.midi_channel : undefined, is_percussion: t.is_percussion || false, clips: clips, sections: sections, midiItems: midiItems, instrumentId: t.instrumentId != null ? t.instrumentId : (t.instrument_id || null), instrumentProgram: t.instrumentProgram !== undefined && t.instrumentProgram !== null ? t.instrumentProgram : (t.instrument_program !== null ? t.instrument_program : undefined), instrumentName: t.instrument_name || null, instrument_source: t.instrument_source || null, soundfont_id: t.soundfont_id || null, soundfont_bank: t.soundfont_bank !== null ? t.soundfont_bank : undefined, soundfont_program: t.soundfont_program !== null ? t.soundfont_program : undefined, synth_engine: t.synth_engine || undefined }; }); }; const serializeProjectToSchema = (projectId, name, bpmVal, tracksList, subTabsList, sessionTabsList, masteringSettings) => { const secondsPerBar = (60.0 / parseFloat(bpmVal || 120)) * 4; const mainTracks = serializeTracksList(tracksList, secondsPerBar); const sectionStore = {}; // Helper: compute length_bars from tracks content const computeLengthBars = (tracksArr, spb) => { let maxSec = 0; (tracksArr || []).forEach(tr => { (tr.clips || []).forEach(c => { const end = (c.startTime || 0) + (c.buffer ? c.buffer.duration / (c.speed || 1.0) : 4); if (end > maxSec) maxSec = end; }); (tr.midiItems || []).forEach(m => { const end = (m.startTime || 0) + (m.duration || 4); if (end > maxSec) maxSec = end; }); }); return Math.ceil((maxSec || 4) / spb); }; // 1. Populate from sessionTabsList (open tabs) (sessionTabsList || []).forEach(st => { const serializedTracks = serializeTracksList(st.tracks, secondsPerBar); sectionStore[st.sectionId] = { id: st.sectionId, name: st.name, is_root: false, length_bars: computeLengthBars(st.tracks, secondsPerBar), auto_compute_length: true, tracks: serializedTracks, color: st.color || null }; }); // 2. Also populate from tracksList (closed tabs saved inside Section items) const scanForSections = (tracks) => { (tracks || []).forEach(t => { if (t.sections) { t.sections.forEach(s => { const secId = s.sectionId || s.id; if (s.tracks && !sectionStore[secId]) { sectionStore[secId] = { id: secId, name: s.name, is_root: false, length_bars: computeLengthBars(s.tracks, secondsPerBar), auto_compute_length: true, tracks: serializeTracksList(s.tracks, secondsPerBar), color: s.color || null }; } if (s.tracks) { scanForSections(s.tracks); } }); } }); }; scanForSections(tracksList); const subTabs = (subTabsList || []).map(st => { return { id: st.id, label: st.label, type: st.type, track_id: st.trackId, target_id: st.target_id, parent_tab_id: st.parent_tab_id, notes: st.notes || [], duration: st.duration || 4, instrument_program: st.instrumentProgram, instrument_name: st.instrumentName, current_time: st.currentTime || 0, color: st.color || null }; }); 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: (() => { let maxBar = 16.0; (mainTracks || []).forEach(t => { (t.items || []).forEach(item => { const end = (item.start_bar || 0) + (item.duration_bars || 4); if (end > maxBar) maxBar = end; }); }); return maxBar; })(), auto_compute_length: true, tracks: mainTracks }, sub_tabs: subTabs, section_store: sectionStore, mastering_settings: masteringSettings || null }; }; const deserializeProjectFromSchema = (schemaObj) => { const bpmVal = schemaObj.metadata ? schemaObj.metadata.bpm : 120; const secondsPerBar = (60.0 / bpmVal) * 4; const sectionStore = schemaObj.section_store || {}; const restoredTracks = deserializeTracksList(schemaObj.main_session.tracks, secondsPerBar, sectionStore); const restoredSessionTabs = []; Object.keys(sectionStore).forEach(secId => { const secContainer = sectionStore[secId]; const secTracks = deserializeTracksList(secContainer.tracks, secondsPerBar, sectionStore); 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 !== undefined ? secContainer.auto_compute_length : true, color: secContainer.color || null }); }); const restoredSubTabs = (schemaObj.sub_tabs || []).map(st => { return { id: st.id, label: st.label, type: st.type, trackId: st.track_id, target_id: st.target_id, parent_tab_id: st.parent_tab_id, notes: st.notes || [], duration: st.duration || 4, instrumentProgram: st.instrument_program, instrumentName: st.instrument_name, currentTime: st.current_time || 0, color: st.color || null }; }); return { bpm: bpmVal, tracks: restoredTracks, sessionTabs: restoredSessionTabs, subTabs: restoredSubTabs, masteringSettings: schemaObj.mastering_settings || null }; }; // ────────────────────────────────────────────── // MASTERING KNOB COMPONENT (Dynamic pointer events version) // ────────────────────────────────────────────── const MasteringKnob = ({ param, min, max, value, unit, label, color, onChange, size = 'small' }) => { const [isDragging, setIsDragging] = React.useState(false); const startYRef = React.useRef(0); const startValRef = React.useRef(0); const handlePointerDown = (e) => { e.preventDefault(); setIsDragging(true); startYRef.current = e.clientY; startValRef.current = value; e.currentTarget.setPointerCapture(e.pointerId); }; const handlePointerMove = (e) => { if (!isDragging) return; const deltaY = startYRef.current - e.clientY; let newVal = startValRef.current + (deltaY / 150) * (max - min); newVal = Math.min(max, Math.max(min, newVal)); onChange(param, newVal); }; const handlePointerUp = (e) => { setIsDragging(false); try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (err) {} }; const pct = (value - min) / (max - min); const angle = -135 + pct * 270; const isLarge = size === 'large'; const dialClass = isLarge ? 'w-20 h-20 border-4 bg-slate-900' : 'w-10 h-10 border-2 bg-slate-800'; const pointerHeight = isLarge ? 'h-6' : 'h-3'; const valClass = isLarge ? 'text-xs text-cyan-300 font-bold mt-2 z-10' : 'text-[9px] text-slate-300 font-mono mt-1 font-bold'; return (
{label && {label}}
{isLarge && ( {value > 0 && unit === 'dB' ? '+' : ''}{value.toFixed(1)} {unit} )}
{!isLarge && ( {value > 0 && unit === 'dB' ? '+' : ''}{value.toFixed(1)}{unit} )}
); }; // ────────────────────────────────────────────── // MASTERING MODAL COMPONENT (from md/45_MASTERING_MODULE.md) // ────────────────────────────────────────────── const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettings }) => { const ozState = masteringSettings; const setOzState = setMasteringSettings; const [isPlaying, setIsPlaying] = React.useState(false); const masterConnected = ozState.masterConnected; const setMasterConnected = (val) => { setOzState(prev => ({ ...prev, masterConnected: typeof val === 'function' ? val(prev.masterConnected) : val })); }; const audioRef = React.useRef({ source: null }); const eqCanvasRef = React.useRef(null); const imagerCanvasRef = React.useRef(null); const inMeterCanvasRef = React.useRef(null); const outMeterCanvasRef = React.useRef(null); const animFrameRef = React.useRef(null); const knobsInitializedRef = React.useRef(false); // Wave Observer Refs & States const woCanvasRef = React.useRef(null); const woLeftHistoryRef = React.useRef(new Float32Array(400).fill(0)); const woRightHistoryRef = React.useRef(new Float32Array(400).fill(0)); const woLeftMeterRef = React.useRef(null); const woRightMeterRef = React.useRef(null); const [woPaused, setWoPaused] = React.useState(false); const [woChannel, setWoChannel] = React.useState('stereo'); const [woMode, setWoMode] = React.useState('waveform'); const [woDuration, setWoDuration] = React.useState(2.0); const [woZoom, setWoZoom] = React.useState(0.0); const woPausedRef = React.useRef(woPaused); woPausedRef.current = woPaused; const woChannelRef = React.useRef(woChannel); woChannelRef.current = woChannel; const woModeRef = React.useRef(woMode); woModeRef.current = woMode; const woDurationRef = React.useRef(woDuration); woDurationRef.current = woDuration; const woZoomRef = React.useRef(woZoom); woZoomRef.current = woZoom; const ozStateRef = React.useRef(ozState); ozStateRef.current = ozState; function startAudioDemo() { getAudioContext(); const ctx = audioCtx; if (ctx.state === 'suspended') ctx.resume(); stopAudioDemo(); const sampleRate = ctx.sampleRate; const bufferSize = sampleRate * 4; const buffer = ctx.createBuffer(2, bufferSize, sampleRate); const left = buffer.getChannelData(0); const right = buffer.getChannelData(1); for (let i = 0; i < bufferSize; i++) { const t = i / sampleRate; const kickEnv = Math.max(0, 1 - (t % 0.5) * 8); const kick = Math.sin(2 * Math.PI * (55 * Math.exp(-(t % 0.5) * 18))) * kickEnv; const snareEnv = Math.max(0, 1 - ((t + 0.25) % 0.5) * 6); const snare = (Math.random() * 2 - 1) * snareEnv * 0.3; const synth = (Math.sin(2 * Math.PI * 261.63 * t) + Math.sin(2 * Math.PI * 311.13 * t) + Math.sin(2 * Math.PI * 392.0 * t)) * 0.12; left[i] = kick * 0.6 + snare + synth; right[i] = kick * 0.6 + snare * 0.9 + synth * 0.95; } const source = ctx.createBufferSource(); source.buffer = buffer; source.loop = true; source.connect(masterBus.inputAnalyser); source.start(); audioRef.current.source = source; setIsPlaying(true); } function stopAudioDemo() { const src = audioRef.current.source; if (src) { try { src.stop(); } catch(e) {} try { src.disconnect(); } catch(e) {} audioRef.current.source = null; } setIsPlaying(false); } React.useEffect(() => { if (!isOpen) return; getAudioContext(); function resizeAll() { const resizeCanvas = (ref) => { const el = ref.current; if (el) { el.width = el.clientWidth; el.height = el.clientHeight; } }; resizeCanvas(eqCanvasRef); resizeCanvas(imagerCanvasRef); resizeCanvas(inMeterCanvasRef); resizeCanvas(outMeterCanvasRef); resizeCanvas(woCanvasRef); } resizeAll(); window.addEventListener('resize', resizeAll); const fftData = new Uint8Array(1024); function getPeakLevel(analyser) { if (!analyser) return 0; const bufferLength = analyser.fftSize; const dataArray = new Float32Array(bufferLength); analyser.getFloatTimeDomainData(dataArray); let maxVal = 0; for (let i = 0; i < bufferLength; i++) { const val = Math.abs(dataArray[i]); if (val > maxVal) { maxVal = val; } } return maxVal; } function renderFrame() { animFrameRef.current = requestAnimationFrame(renderFrame); const s = ozStateRef.current; // EQ Spectrum const eqCanvas = eqCanvasRef.current; if (eqCanvas) { const w = eqCanvas.width, h = eqCanvas.height; const eqCtx = eqCanvas.getContext('2d'); eqCtx.clearRect(0, 0, w, h); eqCtx.strokeStyle = 'rgba(51, 65, 85, 0.3)'; eqCtx.lineWidth = 1; eqCtx.font = '9px JetBrains Mono'; eqCtx.fillStyle = '#475569'; const freqs = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]; freqs.forEach(f => { const x = (Math.log10(f / 20) / Math.log10(20000 / 20)) * w; eqCtx.beginPath(); eqCtx.moveTo(x, 0); eqCtx.lineTo(x, h); eqCtx.stroke(); if (f >= 1000) eqCtx.fillText(`${f/1000}k`, x + 3, h - 6); else eqCtx.fillText(`${f}`, x + 3, h - 6); }); if (masterBus && masterBus.outputAnalyser) { masterBus.outputAnalyser.getByteFrequencyData(fftData); eqCtx.fillStyle = 'rgba(56, 189, 248, 0.15)'; const barWidth = w / 128; for (let i = 0; i < 128; i++) { const val = fftData[i * 4] / 255; eqCtx.fillRect(i * barWidth, h - val * h, barWidth - 1, val * h); } } eqCtx.strokeStyle = '#38bdf8'; eqCtx.lineWidth = 2.5; eqCtx.beginPath(); for (let x = 0; x < w; x++) { const freq = 20 * Math.pow(20000 / 20, x / w); let gainDb = 0; if (s.eqActive) { gainDb += s.eqLowGain / (1 + Math.pow(freq / 100, 2)); gainDb += s.eqMid1Gain * Math.exp(-Math.pow(Math.log(freq / 822), 2) * 2); gainDb += s.eqMid2Gain * Math.exp(-Math.pow(Math.log(freq / 3200), 2) * 2); gainDb += s.eqHighGain / (1 + Math.pow(10000 / freq, 2)); } const y = h / 2 - (gainDb / 18) * (h / 2); if (x === 0) eqCtx.moveTo(x, y); else eqCtx.lineTo(x, y); } eqCtx.stroke(); } // Imager Vectorscope const imagerCanvas = imagerCanvasRef.current; if (imagerCanvas) { const iw = imagerCanvas.width, ih = imagerCanvas.height; const ic = imagerCanvas.getContext('2d'); ic.clearRect(0, 0, iw, ih); ic.strokeStyle = 'rgba(51, 65, 85, 0.4)'; ic.lineWidth = 1; ic.beginPath(); ic.arc(iw / 2, ih / 2, ih / 3, 0, Math.PI * 2); ic.stroke(); const outPeak = getPeakLevel(masterBus && masterBus.outputAnalyser); if (outPeak > 0.001) { ic.fillStyle = '#38bdf8'; const maxRadius = (ih / 3.2) * Math.min(1.0, outPeak * 1.5); for (let i = 0; i < 40; i++) { const angle = (Math.random() - 0.5) * (Math.PI / 2) + (-Math.PI / 2); const radius = Math.random() * maxRadius; const x = iw / 2 + Math.cos(angle) * radius * (1 + s.w3 / 100); const y = ih / 2 + Math.sin(angle) * radius; ic.fillRect(x, y, 2, 2); } } } // I/O Meters const renderMeter = (analyser, ctxRef, textId) => { const canvas = ctxRef.current; if (!canvas) return; const w = canvas.width, h = canvas.height; const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, w, h); const peak = getPeakLevel(analyser); const barH = Math.min(1.0, peak) * h; const grad = ctx.createLinearGradient(0, h, 0, 0); grad.addColorStop(0, '#38bdf8'); grad.addColorStop(0.7, '#f59e0b'); grad.addColorStop(1, '#ef4444'); ctx.fillStyle = grad; ctx.fillRect(2, h - barH, w - 4, barH); const el = document.getElementById(textId); if (el) { if (peak > 0) { const dbVal = 20 * Math.log10(peak); el.innerText = dbVal < -90 ? '-inf dB' : `${dbVal.toFixed(1)} dB`; } else { el.innerText = '-inf dB'; } } }; renderMeter(masterBus && masterBus.inputAnalyser, inMeterCanvasRef, 'inPeakText'); renderMeter(masterBus && masterBus.outputAnalyser, outMeterCanvasRef, 'outPeakText'); // Wave Observer Oscilloscope Rendering const woCanvas = woCanvasRef.current; if (woCanvas) { const w = woCanvas.width, h = woCanvas.height; const woCtx = woCanvas.getContext('2d'); woCtx.clearRect(0, 0, w, h); // Draw grid woCtx.strokeStyle = 'rgba(51, 65, 85, 0.2)'; woCtx.lineWidth = 1; woCtx.font = '8px JetBrains Mono, monospace'; woCtx.fillStyle = '#475569'; const centerY = h / 2; const gridLines = [-0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75]; gridLines.forEach(g => { const y = centerY + g * centerY; woCtx.beginPath(); woCtx.moveTo(0, y); woCtx.lineTo(w, y); woCtx.stroke(); }); // Vertical lines const ticksCount = 10; for (let i = 1; i <= ticksCount; i++) { const x = (i / (ticksCount + 1)) * w; woCtx.beginPath(); woCtx.moveTo(x, 0); woCtx.lineTo(x, h); woCtx.stroke(); } // dB labels on left side woCtx.fillText('-6.0 dB', 5, centerY - 0.5 * centerY + 3); woCtx.fillText('-9.0 dB', 5, centerY - 0.35 * centerY + 3); woCtx.fillText('-15.0 dB', 5, centerY - 0.18 * centerY + 3); woCtx.fillText('-27.0 dB', 5, centerY - 0.05 * centerY + 3); woCtx.fillText('-27.0 dB', 5, centerY + 0.05 * centerY + 3); woCtx.fillText('-15.0 dB', 5, centerY + 0.18 * centerY + 3); woCtx.fillText('-9.0 dB', 5, centerY + 0.35 * centerY + 3); woCtx.fillText('-6.0 dB', 5, centerY + 0.5 * centerY + 3); // Time indicators at the bottom const durationSec = woDurationRef.current; for (let i = 1; i <= 5; i++) { const timeVal = (i / 6) * durationSec; const x = (i / 6) * w; woCtx.fillText(timeVal.toFixed(2) + 's', x - 10, h - 4); } let leftPeak = 0; let rightPeak = 0; if (!woPausedRef.current && masterBus && masterBus.leftAnalyser && masterBus.rightAnalyser) { const leftData = new Float32Array(512); const rightData = new Float32Array(512); masterBus.leftAnalyser.getFloatTimeDomainData(leftData); masterBus.rightAnalyser.getFloatTimeDomainData(rightData); for (let i = 0; i < 512; i++) { const l = Math.abs(leftData[i]); const r = Math.abs(rightData[i]); if (l > leftPeak) leftPeak = l; if (r > rightPeak) rightPeak = r; } const lHistory = woLeftHistoryRef.current; const rHistory = woRightHistoryRef.current; // Shift history buffer Left for (let i = 0; i < lHistory.length - 1; i++) { lHistory[i] = lHistory[i + 1]; rHistory[i] = rHistory[i + 1]; } if (woModeRef.current === 'envelope') { lHistory[lHistory.length - 1] = leftPeak; rHistory[rHistory.length - 1] = rightPeak; } else { lHistory[lHistory.length - 1] = leftData[0]; rHistory[rHistory.length - 1] = rightData[0]; } } const lHistory = woLeftHistoryRef.current; const rHistory = woRightHistoryRef.current; const zoomGain = Math.pow(10, woZoomRef.current / 20); // Update input meter bars if (woLeftMeterRef.current && woRightMeterRef.current) { const lPct = Math.min(100, leftPeak * 100); const rPct = Math.min(100, rightPeak * 100); woLeftMeterRef.current.style.width = `${lPct}%`; woRightMeterRef.current.style.width = `${rPct}%`; } // Left channel line (cyan) if (woChannelRef.current === 'stereo' || woChannelRef.current === 'left') { woCtx.strokeStyle = '#22d3ee'; woCtx.lineWidth = 1.2; woCtx.beginPath(); for (let i = 0; i < lHistory.length; i++) { const x = (i / (lHistory.length - 1)) * w; const val = lHistory[i] * zoomGain; const y = centerY - val * centerY; if (i === 0) woCtx.moveTo(x, y); else woCtx.lineTo(x, y); } woCtx.stroke(); if (woModeRef.current === 'envelope') { woCtx.beginPath(); for (let i = 0; i < lHistory.length; i++) { const x = (i / (lHistory.length - 1)) * w; const val = lHistory[i] * zoomGain; const y = centerY + val * centerY; if (i === 0) woCtx.moveTo(x, y); else woCtx.lineTo(x, y); } woCtx.stroke(); } } // Right channel line (teal) if (woChannelRef.current === 'stereo' || woChannelRef.current === 'right') { woCtx.strokeStyle = '#0d9488'; woCtx.lineWidth = 1.2; woCtx.beginPath(); for (let i = 0; i < rHistory.length; i++) { const x = (i / (rHistory.length - 1)) * w; const val = rHistory[i] * zoomGain; const y = centerY - val * centerY; if (i === 0) woCtx.moveTo(x, y); else woCtx.lineTo(x, y); } woCtx.stroke(); if (woModeRef.current === 'envelope') { woCtx.beginPath(); for (let i = 0; i < rHistory.length; i++) { const x = (i / (rHistory.length - 1)) * w; const val = rHistory[i] * zoomGain; const y = centerY + val * centerY; if (i === 0) woCtx.moveTo(x, y); else woCtx.lineTo(x, y); } woCtx.stroke(); } } } } renderFrame(); return () => { window.removeEventListener('resize', resizeAll); if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); }; }, [isOpen]); React.useEffect(() => { if (!isOpen) { stopAudioDemo(); knobsInitializedRef.current = false; } else { setTimeout(() => { try { window.lucide.createIcons(); } catch(e) {} }, 100); } }, [isOpen]); if (!isOpen) return null; const switchModule = (name) => setOzState(prev => ({ ...prev, activeModule: name })); const bandKnob = (param, min, max, val, unit, label, freq, color, filterType) => (
{label} {freq}
setOzState(prev => ({ ...prev, [p]: v }))} />
{filterType}
); return (
e.stopPropagation()} style={{fontFamily: "'Inter', sans-serif"}}> {/* TOP TRANSPORT & SESSION BAR */}

MASTERING SUITE WEB MASTERING V10.5

Preset:
Target LUFS: -11.0 LUFS
{/* MODULE CHAIN STRIP */}
CHAIN:
switchModule('eq')} className={`w-36 h-12 rounded-lg p-2 flex items-center justify-between cursor-pointer transition-all ${ozState.activeModule === 'eq' ? 'oz-card-active' : 'oz-card'}`}>
Dynamic EQ
4-Band Peak
switchModule('imager')} className={`w-36 h-12 rounded-lg p-2 flex items-center justify-between cursor-pointer transition-all ${ozState.activeModule === 'imager' ? 'oz-card-active' : 'oz-card'}`}>
Imager
4-Band Width
switchModule('maximizer')} className={`w-36 h-12 rounded-lg p-2 flex items-center justify-between cursor-pointer transition-all ${ozState.activeModule === 'maximizer' ? 'oz-card-active' : 'oz-card'}`}>
Maximizer
IRC IV True Peak
{/* MAIN WORKSPACE */}
{/* LEFT: MODULE VIEWS */}
{/* SUB HEADER TOOLBAR */}
Delta Listen
Learn Input Gain:
{/* VIEW: DYNAMIC EQ */}
{bandKnob('eqLowGain', -12, 12, 1.5, 'dB', 'BAND 1 (LOW)', '100 Hz', '#22d3ee', 'Shelf Filter')} {bandKnob('eqMid1Gain', -12, 12, -1.0, 'dB', 'BAND 2 (MID LOW)', '822 Hz', '#fbbf24', 'Dynamic Bell (Q: 0.7)')} {bandKnob('eqMid2Gain', -12, 12, 2.0, 'dB', 'BAND 3 (MID HIGH)', '3.2 kHz', '#a855f7', 'Dynamic Bell (Q: 1.2)')} {bandKnob('eqHighGain', -12, 12, 1.8, 'dB', 'BAND 4 (HIGH)', '10 kHz', '#34d399', 'High Shelf')}
{/* VIEW: STEREO IMAGER */}
Polar Vectorscope & Correlation Meter
4-Band Stereo Width
{[{id:'w1',label:'Band 1 (0-100Hz)',color:'#22d3ee',val:ozState.w1}, {id:'w2',label:'Band 2 (100-1kHz)',color:'#fbbf24',val:ozState.w2}, {id:'w3',label:'Band 3 (1k-6kHz)',color:'#a855f7',val:ozState.w3}, {id:'w4',label:'Band 4 (6k-20kHz)',color:'#34d399',val:ozState.w4}].map(b => (
{b.label} {b.val}%
setOzState(prev => ({...prev, [b.id]: parseInt(e.target.value)}))} className="w-full h-1 cursor-pointer" style={{accentColor: b.color}} />
))}
{/* VIEW: MAXIMIZER */}
Maximizer Gain Boost
setOzState(prev => ({ ...prev, [p]: v }))} />
Ceiling Level: {ozState.ceiling.toFixed(2)} dB
setOzState(prev => ({ ...prev, ceiling: parseFloat(e.target.value) }))} className="w-full h-1 cursor-pointer accent-cyan-400" />
setOzState(prev => ({ ...prev, [p]: v }))} />
setOzState(prev => ({ ...prev, [p]: v }))} />
setOzState(prev => ({ ...prev, [p]: v }))} />
{/* WAVE OBSERVER INTEGRATION */}
{/* Header */}
Wave Observer Real-time Oscilloscope
{['Scope', 'Settings', 'Help', 'About'].map(tab => ( ))}
{/* Scope Canvas */}
{/* Controls bar */}
{/* Input level meters */}
Input
L
R
{/* Scope Controls */}
Scope
Channel
Mode
Duration: {woDuration.toFixed(3)}s setWoDuration(parseFloat(e.target.value))} className="w-16 h-1 cursor-pointer accent-cyan-400" />
V.Zoom: {woZoom.toFixed(1)} dB setWoZoom(parseFloat(e.target.value))} className="w-16 h-1 cursor-pointer accent-cyan-400" />
{/* RIGHT SIDEBAR: I/O METERS */}
I/O METERS TRUE PEAK
IN PEAK
-inf dB
OUT PEAK
-inf dB
IN
OUT
); }; 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 prevBpmRef = useRef(bpm); const [draggedClip, setDraggedClip] = useState(null); const [hoveredTrackId, setHoveredTrackId] = useState(null); // Recalculate item/section/selection durations when BPM changes useEffect(() => { const oldSpb = prevBpmRef.current ? (60.0 / parseFloat(prevBpmRef.current)) * 4 : null; const bpmVal = parseFloat(bpm) || 120; const secondsPerBar = (60.0 / bpmVal) * 4; // Recalculate range loop selection to maintain bar count (tempo mode only) if (oldSpb && selectionFollowsTempo && selectionStart !== null && selectionEnd !== null && selectionEnd > selectionStart) { const startBar = selectionStart / oldSpb; const endBar = selectionEnd / oldSpb; if (endBar - startBar > 0.01) { setSelectionStart(startBar * secondsPerBar); setSelectionEnd(endBar * secondsPerBar); } } prevBpmRef.current = bpm; // Force canvas redraw setCanvasRedrawCount(n => n + 1); // Recalculate item/section durations updateActiveTracks(prev => prev.map(t => ({ ...t, midiItems: (t.midiItems || []).map(m => { if (m.length_bars) return { ...m, duration: m.length_bars * secondsPerBar }; if (m.duration) { // Legacy item without length_bars: compute bars from current duration/BPM const bars = Math.max(0.25, Math.round(m.duration / secondsPerBar * 4) / 4); return { ...m, length_bars: bars, duration: bars * secondsPerBar }; } return m; }), sections: (t.sections || []).map(s => { if (s.length_bars) return { ...s, duration: s.length_bars * secondsPerBar }; if (s.duration) { const bars = Math.max(0.25, Math.round(s.duration / secondsPerBar * 4) / 4); return { ...s, length_bars: bars, duration: bars * secondsPerBar }; } return s; }) }))); }, [bpm]); 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 [instrumentSelectorTrackId, setInstrumentSelectorTrackId] = useState(null); const [synthTrackDropdownId, setSynthTrackDropdownId] = useState(null); const [fxSelectorTrackId, setFxSelectorTrackId] = useState(null); const handleSetTrackFx = (trackId, fxType) => { updateActiveTracks(prev => prev.map(t => t.id === trackId ? { ...t, fxType } : t)); setFxSelectorTrackId(null); setTimeout(() => lucide.createIcons(), 50); }; const [instrumentSelectorData, setInstrumentSelectorData] = useState(null); const [instrumentRefreshKey, setInstrumentRefreshKey] = useState(0); const openInstrumentSelector = trackId => { setInstrumentSelectorTrackId(trackId); setSfPresetSearchQuery(''); setInstrumentRefreshKey(k => k + 1); setSfPresets(null); // Fetch instruments fresh from API for all soundfonts window.SonicAPI.listPlugins().then(async data => { var sfonts = data.soundfonts || []; if (sfonts.length === 0) { setSfPresets([]); return; } try { var results = await Promise.all(sfonts.map(function(sf) { var sfId = sf.id.replace('sf_', ''); return window.SonicAPI.listSoundfontInstruments(sfId) .then(function(r) { return { sf: sf, presets: r.presets || [] }; }) .catch(function() { return { sf: sf, presets: [] }; }); })); var all = []; results.forEach(function(r) { var sf = r.sf; var sfId = sf.id.startsWith('sf_') ? sf.id : 'sf_' + sf.id; var sfName = sf.display || sf.name || sf.id; (r.presets || []).forEach(function(p) { all.push({ ...p, _sfId: sfId, _sfName: sfName, _sfDisplay: sfName.substring(0, 30) }); }); }); setSfPresets(all.length > 0 ? all : []); } catch(e) { setSfPresets([]); } }).catch(function() { setSfPresets([]); }); }; const closeInstrumentSelector = () => { setInstrumentSelectorTrackId(null); setSynthCategory(null); setSelectedSoundFontId(null); setSfPresetSearchQuery(''); }; const [synthCategory, setSynthCategory] = useState(null); // 'vst' | 'soundfont' const [selectedSoundFontId, setSelectedSoundFontId] = useState(null); const [sfPresets, setSfPresets] = useState(null); // presets from SoundFont const [instrumentDropdownTrackId, setInstrumentDropdownTrackId] = useState(null); const [instrumentDropdownBtnRect, setInstrumentDropdownBtnRect] = useState(null); const [instrumentSearchQuery, setInstrumentSearchQuery] = useState(''); const [sfPresetSearchQuery, setSfPresetSearchQuery] = useState(''); const filteredInstruments = useMemo(() => { if (!instrumentSelectorData || !instrumentSearchQuery) return { soundfonts: instrumentSelectorData?.soundfonts || [], vst: instrumentSelectorData?.vst_instruments || [] }; const q = instrumentSearchQuery.toLowerCase(); return { soundfonts: (instrumentSelectorData.soundfonts || []).filter(sf => (sf.display || sf.name || sf.id).toLowerCase().includes(q)), vst: (instrumentSelectorData.vst_instruments || []).filter(v => (v.name || v.id).toLowerCase().includes(q)) }; }, [instrumentSearchQuery, instrumentSelectorData]); useEffect(() => { window.SonicAPI.listPlugins().then(async data => { try { const catResp = await window.SonicAPI.getSoundfontCatalog?.() ?? await fetch('/api/v1/plugins/soundfonts/catalog').then(r => r.json()); const catalog = catResp.full_catalog || {}; data.soundfonts = (data.soundfonts || []).map(sf => { const sfId = sf.id.replace('sf_', ''); const catEntry = catalog[sfId.toLowerCase()] || catalog[sfId]; if (catEntry && catEntry.instruments) { return { ...sf, presets: catEntry.instruments }; } return sf; }); } catch (e) { console.warn('Catalog fetch error:', e); } setInstrumentSelectorData(data); }).catch(() => {}); }, [instrumentRefreshKey]); useEffect(() => { if (!instrumentDropdownTrackId) return; const handler = e => { setInstrumentDropdownTrackId(null); setInstrumentDropdownBtnRect(null); setInstrumentSearchQuery(''); }; document.addEventListener('click', handler); return () => document.removeEventListener('click', handler); }, [instrumentDropdownTrackId]); const GM_INSTRUMENTS = [ "Acoustic Grand Piano","Bright Acoustic Piano","Electric Grand Piano","Honky-tonk Piano","Electric Piano 1","Electric Piano 2","Harpsichord","Clavi", "Celesta","Glockenspiel","Music Box","Vibraphone","Marimba","Xylophone","Tubular Bells","Dulcimer", "Drawbar Organ","Percussive Organ","Rock Organ","Church Organ","Reed Organ","Accordion","Harmonica","Tango Accordion", "Acoustic Guitar (nylon)","Acoustic Guitar (steel)","Electric Guitar (jazz)","Electric Guitar (clean)","Electric Guitar (muted)","Overdriven Guitar","Distortion Guitar","Guitar harmonics", "Acoustic Bass","Electric Bass (finger)","Electric Bass (pick)","Fretless Bass","Slap Bass 1","Slap Bass 2","Synth Bass 1","Synth Bass 2", "Violin","Viola","Cello","Contrabass","Tremolo Strings","Pizzicato Strings","Orchestral Harp","Timpani", "String Ensemble 1","String Ensemble 2","Synth Strings 1","Synth Strings 2","Choir Aahs","Voice Oohs","Synth Voice","Orchestra Hit", "Trumpet","Trombone","Tuba","Muted Trumpet","French Horn","Brass Section","Synth Brass 1","Synth Brass 2", "Soprano Sax","Alto Sax","Tenor Sax","Baritone Sax","Oboe","English Horn","Bassoon","Clarinet", "Piccolo","Flute","Recorder","Pan Flute","Blown Bottle","Shakuhachi","Whistle","Ocarina", "Lead 1 (square)","Lead 2 (sawtooth)","Lead 3 (calliope)","Lead 4 (chiff)","Lead 5 (charang)","Lead 6 (voice)","Lead 7 (fifths)","Lead 8 (bass+lead)", "Pad 1 (new age)","Pad 2 (warm)","Pad 3 (polysynth)","Pad 4 (choir)","Pad 5 (bowed)","Pad 6 (metallic)","Pad 7 (halo)","Pad 8 (sweep)", "FX 1 (rain)","FX 2 (soundtrack)","FX 3 (crystal)","FX 4 (atmosphere)","FX 5 (brightness)","FX 6 (goblins)","FX 7 (echoes)","FX 8 (sci-fi)", "Sitar","Banjo","Shamisen","Koto","Kalimba","Bag pipe","Fiddle","Shanai", "Tinkle Bell","Agogo","Steel Drums","Woodblock","Taiko Drum","Melodic Tom","Synth Drum","Reverse Cymbal", "Guitar Fret Noise","Breath Noise","Seashore","Bird Tweet","Telephone Ring","Helicopter","Applause","Gunshot" ]; const setTrackInstrumentWithProgram = (trackId, instrumentId, programNumber, displayName, bankNumber) => { const isSfInstrument = instrumentId && typeof instrumentId === 'string' && instrumentId.startsWith('sf_'); const sfBank = bankNumber !== undefined ? bankNumber : (isSfInstrument ? 0 : undefined); const sfProg = programNumber !== undefined ? programNumber : undefined; updateActiveTracks(prev => prev.map(t => { if (t.id !== trackId) return t; const hasInstrument = !!instrumentId; const instrType = isSfInstrument ? 'soundfont' : (hasInstrument ? 'vst3' : 'default'); const synthEngine = hasInstrument ? { type: instrType, plugin_id: instrumentId, soundfont_bank: sfBank !== undefined ? sfBank : 0, soundfont_program: sfProg !== undefined ? sfProg : 0, soundfont_id: isSfInstrument ? instrumentId.replace('sf_', '') : '' } : undefined; var mt = activeTracksRef.current || tracks; var midx = 0; for (var mi = 0; mi < mt.length; mi++) { if (mt[mi].id === trackId) { midx = mi; break; } } var mch = sfBank === 128 ? 9 : (midx % 16); return { ...t, midiChannel: mch, instrumentId, instrumentProgram: sfProg, instrumentName: displayName, soundfont_bank: sfBank, soundfont_program: sfProg, synth_engine: synthEngine, type: hasInstrument ? 'MIDI' : (t.type === 'MIDI' ? 'audio' : t.type) }; })); setInstrumentDropdownTrackId(null); setInstrumentDropdownBtnRect(null); setSynthCategory(null); // Trigger SpessaSynth load + program change when soundfont instrument selected if (window.SonicSF && window.SonicSF.selectInstrument && instrumentId && isSfInstrument) { const sfId = instrumentId.replace('sf_', ''); var allTracks = activeTracksRef.current || tracks; var tidx = 0; for (var i = 0; i < allTracks.length; i++) { if (allTracks[i].id === trackId) { tidx = i; break; } } var ch = sfBank === 128 ? 9 : (tidx % 16); // Store channel for consistent per-track instrument playback if (!allTracks[tidx] || allTracks[tidx].midiChannel === undefined) { updateActiveTracks(prev => prev.map(t => t.id === trackId ? { ...t, midiChannel: ch } : t)); } window.SonicSF.selectInstrument(ch, sfBank || 0, sfProg || 0, sfId); } setSubTabs(prev => prev.map(s => { if (s.trackId !== trackId) return s; return { ...s, instrumentProgram: programNumber !== undefined ? programNumber : undefined, instrumentName: displayName, instrumentId }; })); const playingSub = subTabs.find(s => s.trackId === trackId && s.type === 'PIANO_ROLL' && s.isPlaying); if (playingSub) { const pid = playingSub.id; const ctx = getAudioContext(); const tNode = activeTrackNodesRef.current[trackId]; if (tNode && tNode.gainNode) { tNode.gainNode.gain.setValueAtTime(tNode.gainNode.gain.value || 1, ctx.currentTime); tNode.gainNode.gain.linearRampToValueAtTime(0.001, ctx.currentTime + 0.04); } setTimeout(() => { stopAllPlayback(); if (tNode && tNode.gainNode) { const trackData = activeTracksRef.current ? activeTracksRef.current.find(t => t.id === trackId) : null; const volDb = trackData ? (trackData.volumeDb ?? 0) : 0; const volLinear = volDb <= -50 ? 0 : Math.pow(10, volDb / 20); tNode.gainNode.gain.setValueAtTime(0.001, ctx.currentTime); tNode.gainNode.gain.linearRampToValueAtTime(volLinear || 0.8, ctx.currentTime + 0.015); } const context = getAudioContext(); const offset = playingSub.currentTime || 0; startOffsetTimeRef.current = offset; startAudioTimeRef.current = context.currentTime; startBufferOffsetRef.current = offset * (playingSub.speed || 1.0); schedulePianoRollMidi(playingSub, offset); startSubTabPlayback(playingSub, offset); setSubTabs(prev => prev.map(s => s.id === pid ? { ...s, isPlaying: true } : s)); if (subTabsRef.current) { subTabsRef.current = subTabsRef.current.map(s => s.id === pid ? { ...s, isPlaying: true } : s); } animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); }, 60); } setTimeout(() => lucide.createIcons(), 50); }; const setTrackInstrument = (trackId, instrumentId, displayName) => { setInstrumentDropdownTrackId(null); setInstrumentDropdownBtnRect(null); if (instrumentId && instrumentId.startsWith('sf_')) { // Set instrument on track immediately so Synth button shows the name updateActiveTracks(prev => prev.map(t => { if (t.id !== trackId) return t; const sfClean = instrumentId.replace('sf_', ''); const synthEngine = { type: 'soundfont', plugin_id: instrumentId, soundfont_bank: 0, soundfont_program: 0, soundfont_id: sfClean }; return { ...t, instrumentId, instrumentProgram: undefined, instrumentName: displayName, synth_engine: synthEngine }; })); setSelectedSoundFontId(instrumentId); setSynthCategory('soundfont'); setInstrumentSelectorTrackId(trackId); setSfPresets(null); setSfPresetSearchQuery(''); const sfIdParam = instrumentId.replace('sf_', ''); // Use cached presets from instrumentSelectorData const cachedSf = (instrumentSelectorData?.soundfonts || []).find(s => s.id === instrumentId || s.id === sfIdParam); if (cachedSf && cachedSf.presets) { setSfPresets(cachedSf.presets); } else { window.SonicAPI.listSoundfontInstruments(sfIdParam) .then(data => setSfPresets(data.presets || [])) .catch(e => { console.error('listSoundfontInstruments failed:', e); setSfPresets([]); }); } } else { setTrackInstrumentWithUndo(trackId, instrumentId, displayName); } }; const [activeTool, setActiveTool] = useState('select'); // 'select' | 'grab' | 'razor' const [snapValue, onSnapChangeue] = useState('1'); // 'free', '1', '1/2', '1/4', '1/8', '1/16', '1/32' const snapTime = (time, snapValue, bpmVal) => { if (snapValue === 'free') return time; const beatDuration = 60 / parseFloat(bpmVal || 120); let divisor = 1; if (snapValue === '4') divisor = 4; else 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 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 [selectedItemIds, setSelectedItemIds] = useState(new Set()); const selectedItemIdsRef = useRef(new Set()); selectedItemIdsRef.current = selectedItemIds; const [currentTime, setCurrentTime] = useState(0); const [isPlaying, setIsPlaying] = useState(false); const [selectionStart, setSelectionStart] = useState(null); const [selectionEnd, setSelectionEnd] = useState(null); const [selectionFollowsTempo, setSelectionFollowsTempo] = useState(true); 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 [sweepSelect, setSweepSelect] = useState(null); const isSweepingRef = useRef(false); const sweepStartRef = useRef(0); const sweepTrackIdRef = useRef(null); const sweepStartYRef = useRef(0); const sweepEndYRef = useRef(0); const sweepSelectRef = useRef(null); const pendingDragRef = useRef(null); // { trackId, itemType, itemId, clickOffset, startX, startY } const handleSectionItemDragStartRef = useRef(null); 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(0); const [endBar, setEndBar] = useState(0); 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 [openProjectModalOpen, setOpenProjectModalOpen] = useState(false); const hasAnySolo = tracks.some(t => t.solo); const [toastMessage, setToastMessage] = useState(null); const [audioDevices, setAudioDevices] = useState([]); const [midiDevices, setMidiDevices] = useState([]); const [selectedMidiInputId, setSelectedMidiInputId] = useState(''); const selectedMidiInputIdRef = useRef(''); const handleMidiInputSelect = (id) => { setSelectedMidiInputId(id); selectedMidiInputIdRef.current = id; if (window.SonicRecorderManager) { window.SonicRecorderManager.setSelectedMidiInputId(id); } }; 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 = []; function attachMidiHandler(input) { input.onmidimessage = msg => { // Filter by selected MIDI input device const selId = selectedMidiInputIdRef.current; if (selId && selId !== 'ALL' && input.id !== selId) return; console.log(`[DevLog] [Raw MIDI] Received from "${input.name}" (ID: ${input.id}) - Data:`, Array.from(msg.data)); if (msg.data.length < 3) return; const cmd = msg.data[0] >> 4; const pitch = msg.data[1]; const rawVel = msg.data[2]; // Scale low MIDI velocity naturally (min 1, max 127) const scaledVel = Math.min(127, Math.max(1, Math.round(rawVel))); if (cmd === 0x9 && rawVel > 0) { lastMidiNoteRef.current = { pitch, velocity: scaledVel, startTime: performance.now(), length: 0 }; setLastMidiNote({ pitch, velocity: scaledVel, length: 0, time: Date.now() }); activeMidiPitchesRef.current.add(pitch); setActiveMidiPitches(new Set(activeMidiPitchesRef.current)); // Route MIDI input to ALL armed tracks on their dedicated channels // Do NOT use raw MIDI hardware channel (msg.data[0] & 0x0F) if (window.SonicSF) { var allTracks = activeTracksRef.current || []; var arSubs = subTabsRef && subTabsRef.current ? subTabsRef.current.filter(function(s) { return s.type === 'PIANO_ROLL' && s.isArmed; }) : []; var armedTracks = allTracks.filter(function(t) { return t.isArmed; }); // Piano Roll arming has priority: route to each armed sub-tab's parent track if (arSubs.length > 0) { arSubs.forEach(function(as) { var asTrk = allTracks.find(function(t) { return t.id === as.trackId; }); var asCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(asTrk, allTracks) : (asTrk ? asTrk.midiChannel : 0); var asProg = as.instrumentProgram; var asSe = as.synth_engine; if (window.triggerMidiVuActivity) { window.triggerMidiVuActivity(as.trackId, scaledVel); } window.SonicSF.playNote(pitch, scaledVel, 60000, undefined, asProg, null, asCh, asSe); }); } // Route to ALL armed tracks (not just the first one) armedTracks.forEach(function(at) { var atCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(at, allTracks) : (at.midiChannel !== undefined ? at.midiChannel : 0); var atProg = at.instrumentProgram; var atSe = at.synth_engine; var atDest = activeTrackNodesRef.current[at.id]?.gainNode || null; if (window.triggerMidiVuActivity) { window.triggerMidiVuActivity(at.id, scaledVel); } window.SonicSF.playNote(pitch, scaledVel, 60000, undefined, atProg, atDest, atCh, atSe); }); } } else if (cmd === 0x8 || (cmd === 0x9 && rawVel === 0)) { activeMidiPitchesRef.current.delete(pitch); setActiveMidiPitches(new Set(activeMidiPitchesRef.current)); 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); } // Stop the note on ALL tracks (not just armed) to prevent stuck notes // when ARM is toggled off while a key is held if (window.SonicSF && window.SonicSF.stopNote) { var stopTracks = activeTracksRef.current || []; stopTracks.forEach(function(st) { var stCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(st, stopTracks) : (st.midiChannel !== undefined ? st.midiChannel : 0); window.SonicSF.stopNote(stCh, pitch); }); } } // ── Sustain (CC64), Modulation (CC1), Pitch Bend ── const midiCh = msg.data[0] & 0x0F; if (cmd === 0xB) { // Controller Change: forward to ALL armed tracks' dedicated channels const cc = msg.data[1]; const val = msg.data[2]; if (window.SonicSF && window.SonicSF.controllerChange) { var ccTracks = activeTracksRef.current || []; var hasArmed = ccTracks.some(function(t) { return t.isArmed; }); if (hasArmed) { ccTracks.forEach(function(ct) { if (!ct.isArmed) return; var ctCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(ct, ccTracks) : (ct.midiChannel !== undefined ? ct.midiChannel : 0); window.SonicSF.controllerChange(ctCh, cc, val); }); } else { window.SonicSF.controllerChange(midiCh, cc, val); } } } else if (cmd === 0xE) { // Pitch Bend: forward to ALL armed tracks' dedicated channels const lsb = msg.data[1]; const msb = msg.data[2]; const bendVal = (msb << 7) | lsb; if (window.SonicSF && window.SonicSF.pitchBend) { var pbTracks = activeTracksRef.current || []; var hasArmedPB = pbTracks.some(function(t) { return t.isArmed; }); if (hasArmedPB) { pbTracks.forEach(function(pt) { if (!pt.isArmed) return; var ptCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(pt, pbTracks) : (pt.midiChannel !== undefined ? pt.midiChannel : 0); window.SonicSF.pitchBend(ptCh, bendVal); }); } else { window.SonicSF.pitchBend(midiCh, bendVal); } } } // Forward to active MIDI recorders if (activeMIDIRecordersRef.current) { for (let trackId in activeMIDIRecordersRef.current) { const rec = activeMIDIRecordersRef.current[trackId]; if (rec) { rec.handleMIDIMessage(msg, input.id); } } } }; } for (let input of access.inputs.values()) { inputs.push(input); attachMidiHandler(input); } setMidiDevices(inputs); access.onstatechange = () => { const inputs = []; for (let input of access.inputs.values()) { inputs.push(input); // Re-attach handler to ensure new devices get it if (!input.onmidimessage) { attachMidiHandler(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 pianoRollRecorderRef = useRef(null); const activeMidiPitchesRef = useRef(new Set()); const [activeMidiPitches, setActiveMidiPitches] = useState(new Set()); 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(false); const [showAIPanel, setShowAIPanel] = useState(true); const [showSelectionPanel, setShowSelectionPanel] = useState(false); const [showPythonToolsPanel, setShowPythonToolsPanel] = useState(false); const [showMediaExplorer, setShowMediaExplorer] = useState(false); const [scrollBufferExtra, setScrollBufferExtra] = useState(0); const scrollBufferExtraRef = useRef(0); scrollBufferExtraRef.current = scrollBufferExtra; const [showFxRack, setShowFxRack] = useState(false); const [showMidiEvents, setShowMidiEvents] = useState(false); const [showMixer, setShowMixer] = useState(false); const setShowMixerRef = useRef(setShowMixer); setShowMixerRef.current = setShowMixer; window.__toggleMixerRef = function() { setShowMixer(function(p) { return !p; }); }; const [mixerHeight, setMixerHeight] = useState(function() { var saved = localStorage.getItem('studio_mixer_height'); return saved ? parseInt(saved) : 200; }()); const [masterVolume, setMasterVolume] = useState(0); // dB const [masterVU, setMasterVU] = useState(0); // 0-1 const [masterMeterPeak, setMasterMeterPeak] = useState(0); const [rightSidebarWidth, setRightSidebarWidth] = useState(320); const [tcpWidth, setTcpWidth] = 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 aiPromptUndoRef = useRef({ stack: [], idx: -1, max: 30 }); const aiPromptUndoPush = (text) => { const u = aiPromptUndoRef.current; u.stack.push(text); if (u.stack.length > u.max) u.stack.shift(); u.idx = u.stack.length - 1; }; 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 [aiSuggestions, setAiSuggestions] = useState([]); const [showAiTypeahead, setShowAiTypeahead] = useState(false); const [showAISuggestions, setShowAISuggestions] = useState(true); const [showAIActionLog, setShowAIActionLog] = useState(false); const aiPromptMgrRef = useRef(null); const aiTypeaheadRef = useRef(null); 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 + Global Extension) ── 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 (window.UndoRedoEngine && window.UndoRedoEngine.canUndo()) { const entry = window.UndoRedoEngine.undo(); if (entry) { if (entry.undo && typeof entry.undo === 'function') entry.undo(entry); showToast(`Undo: ${entry.label || entry.type}`, 'info'); return; } } 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 (window.UndoRedoEngine && window.UndoRedoEngine.canRedo()) { const entry = window.UndoRedoEngine.redo(); if (entry) { if (entry.redo && typeof entry.redo === 'function') entry.redo(entry); showToast(`Redo: ${entry.label || entry.type}`, 'info'); return; } } 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) => { if (trackId === 'ALL_TRACKS') { state.forEach(entry => { applyTrackState(entry.trackId, entry.state); }); return; } updateActiveTracks(prev => prev.map(t => { if (t.id !== trackId) return t; var updated = { ...t, ...state }; if (state.sections !== undefined) { updated.sections = state.sections; } if (state.midiItems !== undefined) { updated.midiItems = state.midiItems; } if (state.clips !== undefined) { updated.clips = state.clips; } return updated; })); }; const getSelectedMidiItemInfo = () => { if (!selectedItemIds || selectedItemIds.size !== 1) return null; const selId = selectedItemIds.values().next().value; const tlist = activeTracks || tracks || []; for (const t of tlist) { const found = (t.midiItems || []).find(m => m.id === selId); if (found) return { itemName: found.name, trackName: t.name, trackId: t.id, itemId: selId }; } return null; }; const captureTrackSnapshot = trackId => { const track = activeTracks.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: 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, sections: track.sections ? track.sections.map(function(s) { return { id: s.id, start: s.start, duration: s.duration, name: s.name, color: s.color, notes: s.notes ? s.notes.map(function(n) { return { id: n.id, pitch: n.pitch, start_beat: n.start_beat, duration_beats: n.duration_beats, velocity: n.velocity }; }) : null, tracks: s.tracks ? s.tracks.map(function(st) { return { id: st.id, name: st.name, color: st.color, clips: st.clips ? st.clips.map(function(c) { return { id: c.id, startTime: c.startTime, name: c.name, speed: c.speed, buffer: c.buffer }; }) : null, midiItems: st.midiItems ? st.midiItems.map(function(m) { return { id: m.id, startTime: m.startTime, duration: m.duration, name: m.name, notes: m.notes ? m.notes.map(function(n) { return { id: n.id, pitch: n.pitch, start_beat: n.start_beat, duration_beats: n.duration_beats, velocity: n.velocity }; }) : null }; }) : null }; }) : null }; }) : null, midiItems: track.midiItems ? track.midiItems.map(function(m) { return { id: m.id, startTime: m.startTime, duration: m.duration, name: m.name, notes: m.notes ? m.notes.map(function(n) { return { id: n.id, pitch: n.pitch, start_beat: n.start_beat, duration_beats: n.duration_beats, velocity: n.velocity }; }) : null }; }) : null }; }; const captureAllTracksSnapshot = () => { const curTracks = activeTracksRef.current || activeTracks || []; return curTracks.map(t => ({ trackId: t.id, state: captureTrackSnapshot(t.id) })); }; const setBpmWithUndo = (newBpm) => { const oldBpm = bpmRef.current; if (String(oldBpm) === String(newBpm)) return; const entry = { type: 'SET_BPM', scope: 'global', label: `BPM ${oldBpm} → ${newBpm}`, before: oldBpm, after: newBpm, undo: (e) => { setBpm(e.before); showToast(`Undo: BPM → ${e.before}`, 'info'); }, redo: (e) => { setBpm(e.after); showToast(`Redo: BPM → ${e.after}`, 'info'); } }; if (window.UndoRedoEngine) window.UndoRedoEngine.execute(entry); setBpm(String(newBpm)); }; const setPlayheadWithUndo = (newTime) => { const oldTime = currentTime; if (Math.abs(oldTime - newTime) < 0.001) return; const entry = { type: 'SET_PLAYHEAD', scope: 'global', label: `Playhead ${formatTimeSimple(oldTime)} → ${formatTimeSimple(newTime)}`, before: oldTime, after: newTime, undo: (e) => { applyPlayheadDirect(e.before); showToast(`Undo: Playhead`, 'info'); }, redo: (e) => { applyPlayheadDirect(e.after); showToast(`Redo: Playhead`, 'info'); } }; if (window.UndoRedoEngine) window.UndoRedoEngine.execute(entry); applyPlayheadDirect(newTime); }; const applyPlayheadDirect = (time) => { localSelectionAnchorRef.current = time; if (isPlaying) { setCurrentTime(time); stopAllPlayback(); setTimeout(() => { startOffsetTimeRef.current = time; startAudioTimeRef.current = getAudioContext().currentTime; startTrackPlayback(time); setIsPlaying(true); }, 50); } else { setCurrentTime(time); } }; const setSelectionWithUndo = (newStart, newEnd, mode) => { const oldStart = selectionRef.current.start; const oldEnd = selectionRef.current.end; const oldMode = selectionMode; if (oldStart === newStart && oldEnd === newEnd && oldMode === mode) return; const entry = { type: 'SET_SELECTION', scope: 'global', label: `Selection`, before: { start: oldStart, end: oldEnd, mode: oldMode }, after: { start: newStart, end: newEnd, mode: mode }, undo: (e) => { setSelectionStart(e.before.start); setSelectionEnd(e.before.end); setSelectionMode(e.before.mode); showToast(`Undo: Selection`, 'info'); }, redo: (e) => { setSelectionStart(e.after.start); setSelectionEnd(e.after.end); setSelectionMode(e.after.mode); showToast(`Redo: Selection`, 'info'); } }; if (window.UndoRedoEngine) window.UndoRedoEngine.execute(entry); setSelectionStart(newStart); setSelectionEnd(newEnd); if (mode !== undefined) setSelectionMode(mode); }; const setSelectedItemsWithUndo = (newSet) => { const oldSet = selectedItemIdsRef.current; if (oldSet && newSet && oldSet.size === newSet.size && [...oldSet].every(x => newSet.has(x))) return; const entry = { type: 'SELECT_ITEMS', scope: 'global', label: `Selection`, before: [...oldSet], after: [...newSet], undo: (e) => { setSelectedItemIds(new Set(e.before)); showToast(`Undo: Selection`, 'info'); }, redo: (e) => { setSelectedItemIds(new Set(e.after)); showToast(`Redo: Selection`, 'info'); } }; if (window.UndoRedoEngine) window.UndoRedoEngine.execute(entry); setSelectedItemIds(newSet); }; const setAiPromptWithUndo = (newText) => { const oldText = aiPrompt; if (oldText === newText) return; const entry = { type: 'SET_AI_PROMPT', scope: 'global', label: `AI Prompt`, before: oldText, after: newText, undo: (e) => { setAiPrompt(e.before); showToast(`Undo: AI Prompt`, 'info'); }, redo: (e) => { setAiPrompt(e.after); showToast(`Redo: AI Prompt`, 'info'); } }; if (window.UndoRedoEngine) window.UndoRedoEngine.execute(entry); setAiPrompt(newText); }; const setTrackInstrumentWithUndo = (trackId, instrumentId, displayName, bankNumber, programNumber) => { const track = activeTracks.find(t => t.id === trackId); if (!track) return; const oldInstrumentId = track.instrumentId; const oldInstrumentName = track.instrumentName; if (oldInstrumentId === instrumentId && oldInstrumentName === displayName) return; const entry = { type: 'SET_INSTRUMENT', scope: 'track:' + trackId, label: `Instrument ${track.name}`, before: { instrumentId: oldInstrumentId, instrumentName: oldInstrumentName, bankNumber: track.soundfont_bank, programNumber: track.instrumentProgram }, after: { instrumentId, instrumentName: displayName, bankNumber, programNumber }, undo: (e) => { setTrackInstrumentWithProgram(trackId, e.before.instrumentId, e.before.programNumber, e.before.instrumentName, e.before.bankNumber); showToast(`Undo: Instrument`, 'info'); }, redo: (e) => { setTrackInstrumentWithProgram(trackId, e.after.instrumentId, e.after.programNumber, e.after.instrumentName, e.after.bankNumber); showToast(`Redo: Instrument`, 'info'); } }; if (window.UndoRedoEngine) window.UndoRedoEngine.execute(entry); setTrackInstrumentWithProgram(trackId, instrumentId, programNumber, displayName, bankNumber); }; const createSectionWithUndo = (trackId, section) => { const entry = { type: 'CREATE_SECTION', scope: 'track:' + trackId, label: `Create Section`, before: null, after: section, undo: (e) => { updateActiveTracks(prev => prev.map(t => t.id === trackId ? { ...t, sections: (t.sections || []).filter(s => s.id !== e.after.id) } : t)); showToast(`Undo: Create Section`, 'info'); }, redo: (e) => { updateActiveTracks(prev => prev.map(t => t.id === trackId ? { ...t, sections: [...(t.sections || []), e.after] } : t)); showToast(`Redo: Create Section`, 'info'); } }; if (window.UndoRedoEngine) window.UndoRedoEngine.execute(entry); }; const createMidiWithUndo = (trackId, midiItem) => { const entry = { type: 'CREATE_MIDI', scope: 'track:' + trackId, label: `Create MIDI Item`, before: null, after: midiItem, undo: (e) => { updateActiveTracks(prev => prev.map(t => t.id === trackId ? { ...t, midiItems: (t.midiItems || []).filter(m => m.id !== e.after.id) } : t)); showToast(`Undo: Create MIDI`, 'info'); }, redo: (e) => { updateActiveTracks(prev => prev.map(t => t.id === trackId ? { ...t, midiItems: [...(t.midiItems || []), e.after] } : t)); showToast(`Redo: Create MIDI`, 'info'); } }; if (window.UndoRedoEngine) window.UndoRedoEngine.execute(entry); }; const createClipWithUndo = (trackId, clip) => { const entry = { type: 'CREATE_CLIP', scope: 'track:' + trackId, label: `Create Audio Clip`, before: null, after: clip, undo: (e) => { updateActiveTracks(prev => prev.map(t => t.id === trackId ? { ...t, clips: (t.clips || []).filter(c => c.id !== e.after.id), buffer: (t.clips || []).filter(c => c.id !== e.after.id)[0]?.buffer || null, startTime: (t.clips || []).filter(c => c.id !== e.after.id)[0]?.startTime || 0, name: (t.clips || []).filter(c => c.id !== e.after.id)[0]?.name || t.name } : t)); showToast(`Undo: Create Clip`, 'info'); }, redo: (e) => { updateActiveTracks(prev => prev.map(t => t.id === trackId ? { ...t, clips: [...(t.clips || []), e.after] } : t)); showToast(`Redo: Create Clip`, 'info'); } }; if (window.UndoRedoEngine) window.UndoRedoEngine.execute(entry); }; const deleteTrackWithUndo = (trackId, trackData) => { const entry = { type: 'DELETE_TRACK', scope: 'global', label: `Delete Track`, before: trackData, after: null, undo: (e) => { if (e.before) { setTracks(prev => [...prev, e.before]); showToast(`Undo: Delete Track`, 'info'); } }, redo: (e) => { setTracks(prev => prev.filter(t => t.id !== trackId)); showToast(`Redo: Delete Track`, 'info'); } }; if (window.UndoRedoEngine) window.UndoRedoEngine.execute(entry); }; // ── 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 [tabContextMenu, setTabContextMenu] = useState(null); // { x, y, tabId, tabType } const handleSetTabColor = (tabId, tabType, color) => { if (tabType === 'session') { setSessionTabs(prev => prev.map(s => s.id === tabId ? { ...s, color: color } : s)); const tab = sessionTabs.find(s => s.id === tabId); if (tab) { setTracks(prev => prev.map(t => { if (!t.sections || t.sections.length === 0) return t; return { ...t, sections: t.sections.map(s => { if (s.sectionId === tab.sectionId) { return { ...s, color: color }; } return s; }) }; })); } } else { setSubTabs(prev => prev.map(s => s.id === tabId ? { ...s, color: color } : s)); } }; const activeTracks = useMemo(() => { var st = sessionTabs.find(s => s.id === activeTab); if (st) return st.tracks; var pr = subTabs.find(s => s.id === activeTab && s.type === 'PIANO_ROLL'); if (pr && pr.parent_tab_id && pr.parent_tab_id.startsWith('session_')) { var parentSt = sessionTabs.find(s => s.id === pr.parent_tab_id); if (parentSt) return parentSt.tracks; } return tracks; }, [activeTab, sessionTabs, subTabs, 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 const midiVuActivityRef = useRef({}); const triggerMidiVuActivity = (trackId, velocity) => { if (!trackId) return; const velFactor = typeof velocity === 'number' ? (velocity > 1 ? velocity / 127 : velocity) : 0.8; const peak = Math.min(1.0, Math.max(0.15, velFactor)); midiVuActivityRef.current[trackId] = peak; }; window.triggerMidiVuActivity = triggerMidiVuActivity; // ── 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); const [aiPresetModalOpen, setAiPresetModalOpen] = useState(false); const [aiPresetVersion, setAiPresetVersion] = useState(0); const [showMasteringModal, setShowMasteringModal] = useState(false); const [masteringSettings, setMasteringSettings] = useState({ masterConnected: false, activeModule: 'eq', eqActive: true, imagerActive: true, maximizerActive: true, eqLowGain: 1.5, eqMid1Gain: -1.0, eqMid2Gain: 2.0, eqHighGain: 1.8, w1: 0, w2: 15, w3: 35, w4: 50, maxGain: 5.4, maxUpward: 2.0, maxSoftClip: 15, maxTransient: 25, ceiling: -0.1, isBypassed: false }); useEffect(() => { window.currentMasteringSettings = masteringSettings; if (audioCtx && masterBus) { toggleMasteringOnMaster(masteringSettings.masterConnected, masteringSettings.isBypassed); applyMasteringSettings(masteringSettings); } }, [masteringSettings]); const [pluginManagerModalOpen, setPluginManagerModalOpen] = useState(false); const [pluginsData, setPluginsData] = useState(null); const loadAudioBuffersForTracks = async (tracksList) => { let hasLoadedAny = false; const loadBuffer = async (url) => { const res = await fetch(url); if (!res.ok) return null; const blob = await res.blob(); return await window.SonicAudio.decodeAudioFile(blob); }; const tryLoad = async (fileId) => { if (!fileId) return null; try { const result = await loadBuffer('/static/audio/uploads/' + fileId); if (result) return result; } catch (_) {} try { const result = await loadBuffer(`${API_AUDIO}/download/${fileId}`); if (result) return result; } catch (_) {} return null; }; const updatedTracks = await Promise.all(tracksList.map(async t => { let trackBuffer = t.buffer; let trackChannelInfo = t.channelInfo; if (t.serverFileId && !trackBuffer) { const result = await tryLoad(t.serverFileId); if (result) { trackBuffer = result.audioBuffer; trackChannelInfo = result.channelInfo; hasLoadedAny = true; } } const updatedClips = await Promise.all((t.clips || []).map(async c => { let clipBuffer = c.buffer; const targetFileId = c.serverFileId || t.serverFileId; if (targetFileId && !clipBuffer) { const result = await tryLoad(targetFileId); if (result) { clipBuffer = result.audioBuffer; hasLoadedAny = true; } } return { ...c, buffer: clipBuffer }; })); if (trackBuffer && updatedClips.length === 0) { const clipId = `default_${t.id}`; return { ...t, buffer: trackBuffer, channelInfo: trackChannelInfo, clips: [{ id: clipId, buffer: trackBuffer, startTime: t.startTime || 0, name: t.name, speed: 1.0 }] }; } return { ...t, buffer: trackBuffer, channelInfo: trackChannelInfo, clips: updatedClips }; })); if (hasLoadedAny) { setTracks(prev => { const merged = [...updatedTracks]; (prev || []).forEach((pt, i) => { if (!merged[i]) merged[i] = pt; else { merged[i] = { ...merged[i] }; merged[i].clips = (pt.clips || []).map((pc, j) => { if (merged[i].clips && merged[i].clips[j] && (merged[i].clips[j].buffer || pc.buffer)) { return { ...pc, buffer: pc.buffer || merged[i].clips[j].buffer }; } if ((pc.buffer || (merged[i].clips && merged[i].clips[j] && merged[i].clips[j].buffer))) { return pc; } return merged[i].clips && merged[i].clips[j] ? merged[i].clips[j] : pc; }); } }); return merged; }); setSessionTabs(prev => prev.map(st => ({ ...st, tracks: (st.tracks || []).map(t => { const found = updatedTracks.find(u => u.id === t.id); return found || t; }) }))); } }; const restoreLastSessionProject = async () => { var pendingWasNull = !window.__pendingSfsProject; loadPendingSfsProject(); if (!pendingWasNull) return; var lastId = localStorage.getItem('sonic_project_id'); if (!lastId) return; var lastName = localStorage.getItem('sonic_project_name') || 'Dự án'; try { var parsed = null; if (lastId.startsWith('local_')) { var localData = localStorage.getItem('sonic_local_project_data'); if (localData) parsed = JSON.parse(localData); } else { var proj = await window.SonicAPI.getCloudProject(lastId); if (proj) parsed = JSON.parse(proj.data_json); } if (!parsed) return; var restoredBpm = bpm; var restoredTracks = []; var restoredSessionTabs = []; var restoredSubTabs = []; if (parsed.main_session) { var result = deserializeProjectFromSchema(parsed); restoredTracks = result.tracks; restoredBpm = result.bpm; restoredSessionTabs = result.sessionTabs; restoredSubTabs = result.subTabs; if (result.masteringSettings) setMasteringSettings(result.masteringSettings); } else { restoredTracks = (parsed.tracks || []).map(function(t) { var rest = Object.assign({}, t); delete rest.height; return Object.assign({}, rest, { buffer: null, channelInfo: t.channelInfo || null, clips: t.clips || [], serverFileId: t.serverFileId || null }); }); } setTracks(restoredTracks); loadAudioBuffersForTracks(restoredTracks).catch(function(err) { console.warn('loadAudioBuffersForTracks error:', err); }); setBpm(restoredBpm.toString()); setSelectedTrackId(restoredTracks[0]?.id || '1'); setProjectName(lastName); setCurrentProjectId(lastId); localStorage.setItem('sonic_project_id', lastId); setSessionTabs(restoredSessionTabs); setSubTabs(restoredSubTabs); var restoredItemCount = 0; restoredTracks.forEach(function(rt) { if (rt.clips) restoredItemCount += rt.clips.length; if (rt.midiItems) restoredItemCount += rt.midiItems.length; if (rt.sections) restoredItemCount += rt.sections.length; }); showToast('Đã khôi phục dự án "' + lastName + '" (' + restoredItemCount + ' items).', 'info'); } catch(e) { console.warn('restoreLastSessionProject failed:', e); } }; 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); restoreLastSessionProject(); } } catch (err) { const cached = localStorage.getItem('sonic_user'); if (cached) { try { setCurrentUser(JSON.parse(cached)); } catch (_) { } setIsMandatoryLogin(false); setAuthModalOpen(false); restoreLastSessionProject(); } 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 = []; let restoredSubTabs = []; if (proj.main_session) { const result = deserializeProjectFromSchema(proj); restoredTracks = result.tracks; restoredBpm = result.bpm; restoredSessionTabs = result.sessionTabs; restoredSubTabs = result.subTabs; if (result.masteringSettings) { setMasteringSettings(result.masteringSettings); } else { setMasteringSettings({ masterConnected: false, activeModule: 'eq', eqActive: true, imagerActive: true, maximizerActive: true, eqLowGain: 1.5, eqMid1Gain: -1.0, eqMid2Gain: 2.0, eqHighGain: 1.8, w1: 0, w2: 15, w3: 35, w4: 50, maxGain: 5.4, maxUpward: 2.0, maxSoftClip: 15, maxTransient: 25, ceiling: -0.1, isBypassed: false }); } } 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); } if (restoredSubTabs.length > 0) { setSubTabs(restoredSubTabs); } 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); restoreLastSessionProject(); 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) { } try { window.SonicAPI.getSoundfontCatalog().then(cat => { window.__soundfontCatalog = cat; }).catch(() => {}); } catch (e) { } // Re-fetch instrument data after auth (useEffect on mount runs before token is set) try { window.SonicAPI.listPlugins().then(async data => { try { const catResp = await window.SonicAPI.getSoundfontCatalog?.() ?? await fetch('/api/v1/plugins/soundfonts/catalog').then(r => r.json()); const catalog = catResp.full_catalog || {}; data.soundfonts = (data.soundfonts || []).map(sf => { const sfId = sf.id.replace('sf_', ''); const catEntry = catalog[sfId.toLowerCase()] || catalog[sfId]; if (catEntry && catEntry.instruments) return { ...sf, presets: catEntry.instruments }; return sf; }); } catch (e) { console.warn('Catalog fetch error:', e); } setInstrumentSelectorData(data); }).catch(() => {}); } 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, masteringSettings); }); }, [tracks, subTabs, sessionTabs, masteringSettings]); // ── Timer-based auto-save (5 min) + backup (30 min) ── useEffect(() => { const BACKUP_MAX_KEY = 'sonic_backup_max_count'; if (!localStorage.getItem(BACKUP_MAX_KEY)) { localStorage.setItem(BACKUP_MAX_KEY, '10'); } const interval5 = setInterval(() => { if (!currentProjectId) return; // Auto-save: giống handleSaveProject if (currentProjectId.startsWith('local_')) { const finalName = projectName || 'Dự án mới'; const localId = currentProjectId; const schemaObj = serializeProjectToSchema(localId, finalName, bpm, tracks, subTabs, sessionTabs, masteringSettings); const dataStr = JSON.stringify(schemaObj); localStorage.setItem('sonic_local_project_data', dataStr); localStorage.setItem('sonic_project_id', localId); localStorage.setItem('sonic_project_name', finalName); } else if (currentUser && window.SonicAPI) { try { const schemaObj = serializeProjectToSchema(currentProjectId, projectName, bpm, tracks, subTabs, sessionTabs, masteringSettings); const dataJson = JSON.stringify(schemaObj); window.SonicAPI.updateCloudProject(currentProjectId, projectName || 'Dự án mới', dataJson).catch(() => {}); } catch (e) { console.warn('Auto-save error:', e); } } }, 5 * 60 * 1000); const interval30 = setInterval(() => { if (!currentProjectId || currentProjectId.startsWith('local_') || !currentUser || !window.SonicAPI) return; try { window.SonicAPI.createBackup(currentProjectId).catch(() => {}); } catch (e) { console.warn('Backup error:', e); } }, 30 * 60 * 1000); return () => { clearInterval(interval5); clearInterval(interval30); }; }, [currentProjectId, projectName, currentUser, tracks, subTabs, sessionTabs, masteringSettings, bpm]); // Lucide icons initialization useEffect(() => { setTimeout(() => { if (window.lucide) { window.lucide.createIcons(); } }, 50); }, [activeTool, activeTab, contextMenu, activeTracks]); 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); }; // ── TCP Resizer ── const startTcpResize = e => { e.preventDefault(); const startX = e.clientX; const startWidth = tcpWidth; const onMove = ev => { const deltaX = ev.clientX - startX; const newWidth = Math.max(280, Math.min(600, startWidth + deltaX)); setTcpWidth(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 subTabDragStartRef = useRef(null); const isDraggingSubTabRef = 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 handleDeleteSelectedItemsRef = useRef(() => {}); handleDeleteSelectedItemsRef.current = (idsToDelete) => { const count = idsToDelete.size; setSelectedItemIds(new Set()); updateActiveTracks(prev => prev.map(t => { let changed = false; const sections = (t.sections || []).filter(s => { if (idsToDelete.has(s.id)) { changed = true; return false; } return true; }); const midiItems = (t.midiItems || []).filter(m => { if (idsToDelete.has(m.id)) { changed = true; return false; } return true; }); let clips = t.clips || []; const hasVirtualClip = !t.clips || t.clips.length === 0; if (hasVirtualClip && t.buffer) { const canonical = 'default_' + t.id; if (idsToDelete.has(canonical)) { changed = true; return { ...t, sections, midiItems, clips: [], buffer: null, startTime: 0 }; } } const updatedClips = clips.filter(c => { const canonical = c.id === 'default' ? 'default_' + t.id : c.id; if (idsToDelete.has(canonical)) { changed = true; return false; } return true; }); if (!changed && sections.length === (t.sections || []).length && midiItems.length === (t.midiItems || []).length) { return t; } return { ...t, sections, midiItems, 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 : t.name }; })); showToast(count === 1 ? 'Đã xóa 1 item.' : `Đã xóa ${count} items.`, 'info'); }; const selectedClipIdRef = useRef(null); selectedClipIdRef.current = selectedClipId; const selectedTrackIdRef = useRef(selectedTrackId); selectedTrackIdRef.current = selectedTrackId; const handleDeleteTrackRef = useRef(() => {}); const handleSplitTrackRef = useRef(() => {}); const handleRecordClickRef = useRef(() => {}); 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 (recordingStateRef.current === 'RECORDING' || recordingStateRef.current === 'COUNT_IN') { handleRecordClickRef.current(); return; } if (handlePlayPauseRef.current) handlePlayPauseRef.current(); return; } if (activeTabRef.current !== 'main' && !activeTabRef.current.startsWith('session_')) { // 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) { const tag = document.activeElement?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA') return; e.preventDefault(); handleUndoRef.current(); return; } if (ctrl && (e.key === 'y' || e.key === 'z' && e.shiftKey)) { const tag = document.activeElement?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA') return; 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.shiftKey && e.key === 'a') { e.preventDefault(); const curTab = activeTabRef.current; if (curTab !== 'main' && !curTab.startsWith('session_')) return; captureSelectionUndo(); const allIds = new Set(); (activeTracksRef.current || []).forEach(t => { (t.sections || []).forEach(s => allIds.add(s.id)); (t.midiItems || []).forEach(m => allIds.add(m.id)); 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 => allIds.add(c.id === 'default' ? 'default_' + t.id : c.id)); }); setSelectedItemIds(allIds); pushSelectionUndo(); return; } if (ctrl && !alt && e.key === 's') { e.preventDefault(); handleSaveProjectRef.current(); 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 selItems = selectedItemIdsRef.current; if (selItems.size > 0) { e.preventDefault(); const idsToDelete = new Set(selItems); handleDeleteSelectedItemsRef.current(idsToDelete); return; } 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(); handleDeleteTrackRef.current(); return; } } if (ctrl && !alt && e.key === 's') { e.preventDefault(); const curTab = activeTabRef.current; if (curTab === 'main') { // handled by main handler } else if (curTab.startsWith('session_')) { // Main session: save project + save all dirty sub-tabs handleSaveProject(); subTabsRef.current.filter(s => s.isDirty).forEach(st => { if (st.type === 'PIANO_ROLL') { handleSaveMidiNotes(st.id, st.trackId, st.target_id, st.notes || []); } else if (st.type === 'SECTION') { handleSaveSectionTab(st.id); } else if (st.buffer) { // Audio clip sub-tab: save buffer to track const subTrack = activeTracksRef.current.find(t => t.id === st.trackId); if (subTrack) { updateActiveTracks(prev => prev.map(t => t.id === st.trackId ? { ...t, buffer: st.buffer } : t)); } } showToast('Đã lưu', 'success'); }); } else if (curTab.startsWith('session_')) { // Section tab: save section handleSaveSectionTabRef.current(curTab); showToast('Đã lưu Section', 'success'); } else { // Sub-tab: save current tab const st = subTabsRef.current.find(s => s.id === curTab); if (st) { if (st.type === 'PIANO_ROLL') { handleSaveMidiNotes(st.id, st.trackId, st.target_id, st.notes || []); showToast('Đã lưu Piano Roll', 'success'); } else if (st.type === 'SECTION') { handleSaveSectionTab(st.id); showToast('Đã lưu Section Tab', 'success'); } else if (st.buffer) { const subTrack = activeTracksRef.current.find(t => t.id === st.trackId); if (subTrack) { updateActiveTracks(prev => prev.map(t => t.id === st.trackId ? { ...t, buffer: st.buffer } : t)); } showToast('Đã lưu Audio Tab', 'success'); } } } return; } if (!ctrl && !alt && e.key === 's') { e.preventDefault(); handleSplitTrackRef.current(selectedTrackIdRef.current); return; } if (e.key === 'F7') { e.preventDefault(); setShowMixerRef.current(p => !p); return; } }; window.addEventListener('keydown', handler, { capture: true }); return () => window.removeEventListener('keydown', handler, { capture: true }); }, []); // ── 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 ctx = getAudioContext(); const silentBuffer = ctx.createBuffer(1, 128, ctx.sampleRate); 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, buffer: silentBuffer, instrumentProgram: track.instrumentProgram !== undefined ? track.instrumentProgram : (track.synth_engine ? track.synth_engine.soundfont_program : undefined), instrumentName: track.instrumentName, instrumentId: track.instrumentId, synth_engine: track.synth_engine, currentTime: 0, isPlaying: false, isLooping: true, selectionStart: null, selectionEnd: null, 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); if (track.synth_engine && window.SonicSF && window.SonicSF.selectInstrument) { var se = track.synth_engine; var sfId = se.soundfont_id; if (sfId) { var allTrks = activeTracksRef.current || activeTracks; var trkIdx = 0; for (var ti = 0; ti < allTrks.length; ti++) { if (allTrks[ti].id === trackId) { trkIdx = ti; break; } } var seCh = (se.soundfont_bank === 128 ? 9 : (trkIdx % 16)); window.SonicSF.selectInstrument(seCh, se.soundfont_bank || 0, se.soundfont_program || 0, sfId); } } }; const handleUpdateMidiNotes = (tabId, notes) => { setSubTabs(prev => prev.map(s => s.id === tabId ? { ...s, notes: notes, isDirty: true } : s)); }; const handleSaveMidiNotes = (tabId, trackId, midiItemId, updatedNotes) => { const pianoRollTab = subTabs.find(s => s.id === tabId); const parentTabId = pianoRollTab ? pianoRollTab.parent_tab_id : null; if (parentTabId && parentTabId.startsWith('session_')) { setSessionTabs(prev => prev.map(s => { if (s.id !== parentTabId) 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 }; }) }; })); } setSubTabs(prev => prev.map(s => s.id === tabId ? { ...s, isDirty: false } : s)); showToast('Đã lưu các chỉnh sửa nốt MIDI vào item cha!', 'success'); }; const handleSaveSectionTab = (tabId) => { const tab = sessionTabs.find(s => s.id === tabId); if (!tab) return; const bpmVal = parseInt(bpm) || 120; const secondsPerBeat = 60.0 / bpmVal; const secondsPerBar = secondsPerBeat * 4; const contentTracks = tab.tracks ? tab.tracks.filter(tr => tr.clips?.length > 0 || tr.midiItems?.length > 0) : []; let maxEndTime = 0; (contentTracks || []).forEach(tr => { (tr.clips || []).forEach(c => { const end = (c.startTime || 0) + (c.buffer ? c.buffer.duration / (c.speed || 1.0) : 4); if (end > maxEndTime) maxEndTime = end; }); (tr.midiItems || []).forEach(m => { const end = (m.startTime || 0) + (m.duration || 4); if (end > maxEndTime) maxEndTime = end; }); }); const durationSec = Math.max(maxEndTime, 4 * secondsPerBar); setTracks(prev => prev.map(t => { if (!t.sections || t.sections.length === 0) return t; return { ...t, sections: t.sections.map(s => { if (s.sectionId !== tab.sectionId && s.id !== tab.sectionId) return s; return { ...s, name: tab.name, duration: durationSec, tracks: contentTracks }; }) }; })); // Clear isDirty flag setSessionTabs(prev => prev.map(s => s.id === tabId ? { ...s, isDirty: false } : s)); showToast(`Đã lưu nội dung Section "${tab.name}" vào Main Session!`, 'success'); }; // ── 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 = section.tracks ? (JSON.parse(JSON.stringify(section.tracks))).map(t => ({ ...t, clips: [], sections: [], markers: [], isArmed: false, monitoringEnabled: true, instrumentId: t.instrumentId || null, instrumentProgram: t.instrumentProgram, instrumentName: t.instrumentName || null, _isSectionClone: true })) : tracks.filter(t => t.id === trackId).map(t => ({ ...t, clips: [], sections: [], midiItems: [], markers: [], isArmed: false, monitoringEnabled: true, instrumentId: null, instrumentProgram: undefined, instrumentName: null, soundfont_id: null, soundfont_bank: undefined, soundfont_program: undefined, instrument_source: null, synth_engine: undefined, _isSectionClone: true })); setSessionTabs(prev => [...prev, { id: tabId, name: tabName, sectionId: sectionId, tracks: clonedTracks, color: section.color || null }]); 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 => { const tab = subTabs.find(s => s.id === tabId); if (!tab) return; const performClose = () => { setSubTabs(prev => prev.filter(s => s.id !== tabId)); if (activeTab === tabId) setActiveTab('main'); }; if (tab.isDirty) { setAppWarningModal({ title: "Đóng Tab?", message: `Bạn chưa lưu các thay đổi của tab "${tab.label || 'MIDI'}". Bạn có chắc chắn muốn đóng tab này và mất hết thay đổi?`, isAlert: false, onConfirm: performClose }); } else { performClose(); } }; 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; } const performClose = () => { setSessionTabs(prev => prev.filter(s => s.id !== tabId)); if (activeTab === tabId) setActiveTab('main'); }; if (targetTab.isDirty) { setAppWarningModal({ title: "Đóng Tab Section?", message: `Bạn chưa lưu các thay đổi của Section "${targetTab.name}". Bạn có chắc chắn muốn đóng tab này và mất hết thay đổi?`, isAlert: false, onConfirm: performClose }); } else { performClose(); } }; 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 = activeTracks.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) { updateActiveTracks(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 rearrangeNewId = 'track_pasted_' + Date.now(); updateActiveTracks(prev => [...prev, { id: rearrangeNewId, 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(rearrangeNewId); setCurrentTime(pasteTime); showToast('Đã dán track mới từ clipboard.', 'success'); return rearrangeNewId; }; 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 rearrangeNewId = 'track_merged_' + Date.now(); const names = activeTracks.map(t => t.name).join('+').slice(0, 30); setTracks(prev => [...prev, { id: rearrangeNewId, 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(rearrangeNewId); 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 = activeTracks.find(x => x.id === selectedTrackId); if (!t || !t.buffer) return; const sr = t.buffer.sampleRate; const data = t.buffer.getChannelData(0); // If selection exists, copy only the selected region if (selLeft !== null && selRight !== null && selRight > selLeft) { const trackStart = t.startTime || 0; const relSelLeft = Math.max(0, selLeft - trackStart); const relSelRight = Math.max(0, selRight - trackStart); const startSample = Math.floor(relSelLeft * sr); const endSample = Math.min(data.length, Math.floor(relSelRight * sr)); const len = endSample - startSample; if (len > 0) { const ctx = getAudioContext(); const clipBuffer = ctx.createBuffer(1, len, sr); clipBuffer.copyToChannel(data.subarray(startSample, endSample), 0); clipboardRef.current = { buffer: clipBuffer, name: t.name, volumeDb: t.volumeDb, color: t.color }; showToast('Copied selection to clipboard.', 'info'); return; } } // No selection: copy entire track clipboardRef.current = { buffer: t.buffer, name: t.name, volumeDb: t.volumeDb, pan: t.pan, color: t.color, sampleRate: t.buffer.sampleRate, channels: t.buffer.numberOfChannels, speed: t.speed || 1.0 }; showToast('Copied track to clipboard.', 'info'); }; const handleCutTrack = () => { const t = activeTracks.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]; updateActiveTracks(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) { const trackData = activeTracks.find(t => t.id === tid); if (trackData) deleteTrackWithUndo(tid, JSON.parse(JSON.stringify(trackData))); 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; } if (track) deleteTrackWithUndo(tid, JSON.parse(JSON.stringify(track))); setTracks(p => p.filter(t => t.id !== tid)); setSelectedTrackId(curTracks.filter(t => t.id !== tid)[0]?.id || '1'); } showToast('Deleted track.', 'info'); }; handleDeleteTrackRef.current = handleDeleteTrack; // ── 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 leadInMargin = 0; const leadInMarginRef = useRef(0); leadInMarginRef.current = 0; const maxDuration = useMemo(() => { const secPerBar = (60.0 / (parseInt(bpm) || 120)) * 4; const cur = activeTracks; let max = 10; cur.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 || 0) + (m.duration || 4)); }); (t.sections || []).forEach(s => { max = Math.max(max, (s.start || 0) + (s.duration || 4)); }); }); if (recordingState === 'RECORDING' || recordingState === 'COUNT_IN') { max = Math.max(max, currentTime + 60); } max += secPerBar * 12 + scrollBufferExtra; return max; }, [activeTracks, recordingState, currentTime, bpm, scrollBufferExtra]); const maxDurationRef = useRef(maxDuration); maxDurationRef.current = maxDuration; const minZoom = useMemo(() => { return viewportWidth / maxDuration; }, [viewportWidth, maxDuration]); const timelineWidth = useMemo(() => { return Math.max(zoom * (maxDuration + leadInMargin), viewportWidth); }, [zoom, maxDuration, viewportWidth, leadInMargin]); useEffect(() => { if (zoom < minZoom) { setZoom(minZoom); } }, [minZoom]); const playheadLeftPos = useMemo(() => (currentTime + leadInMargin) * zoom, [currentTime, zoom, leadInMargin]); const selLeft = useMemo(() => { if (selectionMode === 'local' && localSelectionStart !== null && localSelectionEnd !== null) { return Math.max(0, Math.min(localSelectionStart, localSelectionEnd)); } if (selectionStart === null || selectionEnd === null) return null; return Math.max(0, Math.min(selectionStart, selectionEnd)); }, [selectionStart, selectionEnd, selectionMode, localSelectionStart, localSelectionEnd]); const selRight = useMemo(() => { if (selectionMode === 'local' && localSelectionStart !== null && localSelectionEnd !== null) { return Math.max(0, Math.max(localSelectionStart, localSelectionEnd)); } if (selectionStart === null || selectionEnd === null) return null; return Math.max(0, 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); }; window.showToast = showToast; // ── 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(); if (!st.buffer) { st.buffer = context.createBuffer(1, 128, context.sampleRate); } 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(masterBus ? masterBus.input : 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(masterBus ? masterBus.input : 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 let combinedNotes = []; 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; const notes = [...midiRec.recordedNotes, ...Array.from(midiRec.activeNotes.values()).map(n => ({ ...n, duration_beats: currentBeat - n.start_beat }))]; combinedNotes = combinedNotes.concat(notes); } } if (combinedNotes.length > 0 || armedTracks.some(t => activeMIDIRecordersRef.current[t.id])) { setRecTempMidiNotes(combinedNotes); setCanvasRedrawCount(n => n + 1); } } } const isSubTab = subTabsRef.current.some(sub => sub.id === activeTabRef.current); if (isSubTab) { 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 && st.isLooping) { 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: start, isPlaying: true } : s)); schedulePianoRollMidi(st, start); startSubTabPlayback(st, start); animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); return; } } const effectiveDuration = st.type === 'PIANO_ROLL' ? (() => { const notes = st.notes || []; const bpmVal = parseInt(bpmRef?.current || bpm) || 120; const beatSec = 60.0 / bpmVal; if (recordingStateRef.current === 'RECORDING') return 600.0; // 10 min during recording let maxEnd = 0; notes.forEach(n => { const end = (n.start_beat || 0) + (n.duration_beats || 1); if (end > maxEnd) maxEnd = end; }); return Math.max(maxEnd * beatSec, 16 * beatSec * 4) + 1.0; })() : st.buffer.duration; if (bufferPos >= effectiveDuration) { stopAllPlayback(); if (st.isLooping) { 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 (hasAnySolo || selectionMode === 'local') { stopAllPlayback(); startOffsetTimeRef.current = selLeft; startAudioTimeRef.current = context.currentTime; if (hasAnySolo) { const soloed = tracks.filter(t => t.solo); soloed.forEach(t => startLocalTrackPlayback(t.id, selLeft)); } else { startLocalTrackPlayback(localSelectionTrackId, 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]); // ── FX Nodes ── const createChorusNode = (context, inputNode, outputNode) => { const dryGain = context.createGain(); dryGain.gain.value = 0.6; const wetGain = context.createGain(); wetGain.gain.value = 0.5; const delayNode = context.createDelay(); delayNode.delayTime.value = 0.02; const lfo = context.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 1.5; const lfoGain = context.createGain(); lfoGain.gain.value = 0.002; lfo.connect(lfoGain); lfoGain.connect(delayNode.delayTime); lfo.start(); inputNode.connect(dryGain); inputNode.connect(delayNode); delayNode.connect(wetGain); dryGain.connect(outputNode); wetGain.connect(outputNode); return { stop: () => { try { lfo.stop(); } catch(e) {} } }; }; const createReverbNode = (context, inputNode, outputNode) => { const dryGain = context.createGain(); dryGain.gain.value = 0.6; const wetGain = context.createGain(); wetGain.gain.value = 0.4; const convolver = context.createConvolver(); const rate = context.sampleRate; const len = rate * 2.0; const impulse = context.createBuffer(2, len, rate); const left = impulse.getChannelData(0); const right = impulse.getChannelData(1); for (let i = 0; i < len; i++) { const decay = Math.exp(-i / (rate * 0.5)); left[i] = (Math.random() * 2 - 1) * decay; right[i] = (Math.random() * 2 - 1) * decay; } convolver.buffer = impulse; inputNode.connect(dryGain); inputNode.connect(convolver); convolver.connect(wetGain); dryGain.connect(outputNode); wetGain.connect(outputNode); }; // ── Playback ── const getOrCreateTrackNode = (track, context) => { if (!track) return null; let node = activeTrackNodesRef.current[track.id]; if (!node) { 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); // Ensure master bus is initialized for MAIN OUT routing if (!masterBus) initMasterBus(context); // Route through master bus if available, else direct to destination const dest = masterBus ? masterBus.input : context.destination; const analyserNode = context.createAnalyser(); analyserNode.fftSize = 256; pannerNode.connect(analyserNode); analyserNode.connect(dest); let fxStopFn; if (track.fxType === 'chorus') { const fxInput = context.createGain(); gainNode.connect(fxInput); const chorus = createChorusNode(context, fxInput, pannerNode); fxStopFn = chorus.stop; } else if (track.fxType === 'reverb') { const fxInput = context.createGain(); gainNode.connect(fxInput); createReverbNode(context, fxInput, pannerNode); fxStopFn = null; } else { gainNode.connect(pannerNode); } node = { gainNode, pannerNode, fxStopFn, analyserNode }; activeTrackNodesRef.current[track.id] = node; } return node.gainNode; }; const getOrCreateSubTrackNode = (track, subTrack, context) => { if (!track || !subTrack) return null; const subKey = track.id + '_sub_' + subTrack.id; let node = activeTrackNodesRef.current[subKey]; if (!node) { const gainNode = context.createGain(); const volDb = subTrack.volumeDb ?? 0; const volLinear = volDb <= -50 ? 0 : Math.pow(10, volDb / 20); gainNode.gain.setValueAtTime(volLinear, context.currentTime); const parentNode = getOrCreateTrackNode(track, context); gainNode.connect(parentNode); node = { gainNode, pannerNode: null }; activeTrackNodesRef.current[subKey] = node; } return node.gainNode; }; const playMidiPreviewNote = (pitch, velocity = 0.8, durationMs = 500) => { if (!window.SonicSF) return; const context = getAudioContext(); const tab = subTabs.find(s => s.id === activeTabRef.current); const trackId = tab ? tab.trackId : selectedTrackId; const track = activeTracks.find(t => t.id === trackId); const destNode = getOrCreateTrackNode(track, context); const program = track ? track.instrumentProgram : undefined; var prevCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(track, activeTracks) : (track ? track.midiChannel : 0); window.SonicSF.playNote( pitch, velocity, durationMs, context.currentTime, program, destNode, prevCh, track ? track.synth_engine : undefined ); }; const startTrackPlayback = offsetTime => { const context = getAudioContext(); const allPlayTracks = activeTracksRef.current && activeTracksRef.current.length ? activeTracksRef.current : activeTracks; const hasSolo = allPlayTracks.some(t => t.solo); allPlayTracks.forEach(track => { const isPlayable = hasSolo ? track.solo : !track.muted; if (!isPlayable) return; const gainNode = getOrCreateTrackNode(track, context); const pannerNode = activeTrackNodesRef.current[track.id].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); 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); } }); // MIDI items playback const midiItems = track.midiItems || []; if ((track.type === 'MIDI' || midiItems.length > 0) && window.SonicSF) { var trkCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(track, allPlayTracks) : (track.midiChannel !== undefined ? track.midiChannel : 0); // Ensure instrument is loaded in FluidSynth if (track.synth_engine && track.synth_engine.type === 'soundfont' && track.synth_engine.soundfont_id) { window.SonicSF.selectInstrument(trkCh, track.synth_engine.soundfont_bank || 0, track.synth_engine.soundfont_program || 0, track.synth_engine.soundfont_id); } const bpmVal = parseInt(bpm) || 120; const secondsPerBeat = 60.0 / bpmVal; midiItems.forEach(item => { const itemEndSec = item.startTime + (item.duration || 4); const notes = item.notes || []; notes.forEach(note => { // note start/duration is in beats (for MIDI items) const noteStartSec = item.startTime + (note.start_beat || 0) * secondsPerBeat; if (noteStartSec >= itemEndSec) return; // skip notes outside item's visual duration const noteDurSec = (note.duration_beats || 1) * secondsPerBeat; const noteEndSec = noteStartSec + noteDurSec; if (offsetTime < noteEndSec) { const durationMs = noteDurSec * 1000; const program = track._isSectionClone ? track.instrumentProgram : (track.instrumentProgram !== undefined ? track.instrumentProgram : 0); if (offsetTime < noteStartSec) { const delay = noteStartSec - offsetTime; const startTime = context.currentTime + delay; window.SonicSF.playNote( note.pitch || 60, note.velocity || 0.8, durationMs, startTime, program, gainNode, trkCh, track.synth_engine ); // Trigger VU meter flash when the note starts playing setTimeout(() => { if (window.triggerMidiVuActivity) { window.triggerMidiVuActivity(track.id, note.velocity || 0.8); } }, delay * 1000); } else { const playOffset = offsetTime - noteStartSec; const remainingDurMs = (noteEndSec - offsetTime) * 1000; window.SonicSF.playNote( note.pitch || 60, note.velocity || 0.8, remainingDurMs, context.currentTime, program, gainNode, trkCh, track.synth_engine ); // Trigger VU meter flash instantly if (window.triggerMidiVuActivity) { window.triggerMidiVuActivity(track.id, note.velocity || 0.8); } } } }); }); } // If track has instrumentId set but no MIDI items, create scheduled oscillators if (track.instrumentId && midiItems.length === 0 && track.buffer) { // Play audio normally (the buffer has the audio data) // This block is intentionally empty - audio clips already play above } // SECTION items playback const sections = track.sections || []; if (activeTab === 'main' && sections.length > 0) { const bpmVal = parseInt(bpm) || 120; const secondsPerBeat = 60.0 / bpmVal; const secondsPerBar = secondsPerBeat * 4; sections.forEach(sec => { const secStart = sec.start || 0; const secEnd = secStart + (sec.duration || 0); const openTab = sessionTabsRef.current ? sessionTabsRef.current.find(st => st.sectionId === sec.sectionId || st.sectionId === sec.id) : null; const secTracks = openTab ? openTab.tracks : (sec.tracks || []); const hasSubSolo = secTracks.some(st => st.solo); secTracks.forEach((subTrack, subIdx) => { const isSubPlayable = hasSubSolo ? subTrack.solo : !subTrack.muted; if (!isSubPlayable) return; const subNode = getOrCreateSubTrackNode(track, subTrack, context); if (!subNode) return; // 1. Play clips in subTrack const subClips = subTrack.clips || []; subClips.forEach(clip => { if (!clip.buffer) return; const clipStartLocal = clip.startTime || 0; const clipDurLocal = clip.buffer.duration / (clip.speed || 1.0); const clipStartMain = secStart + clipStartLocal; const clipEndMain = clipStartMain + clipDurLocal; // Only play within the bounds of [secStart, secEnd] const activeStart = Math.max(clipStartMain, secStart); const activeEnd = Math.min(clipEndMain, secEnd); if (activeStart < activeEnd && offsetTime < activeEnd) { const source = context.createBufferSource(); source.buffer = clip.buffer; source.playbackRate.value = clip.speed || 1.0; source.connect(subNode); if (offsetTime < activeStart) { const delay = activeStart - offsetTime; const playOffset = activeStart - clipStartMain; const playDuration = activeEnd - activeStart; source.start(context.currentTime + delay, playOffset * (clip.speed || 1.0), playDuration); activeSourcesRef.current.push(source); } else { const playOffset = offsetTime - clipStartMain; const playDuration = activeEnd - offsetTime; source.start(context.currentTime, playOffset * (clip.speed || 1.0), playDuration); activeSourcesRef.current.push(source); } } }); // 2. Play MIDI items in subTrack const subMidiItems = subTrack.midiItems || []; if (window.SonicSF && subMidiItems.length > 0) { subMidiItems.forEach(item => { const notes = item.notes || []; notes.forEach(note => { const noteStartSecLocal = item.startTime + (note.start_beat || 0) * secondsPerBeat; const noteDurSec = (note.duration_beats || 1) * secondsPerBeat; const noteStartMain = secStart + noteStartSecLocal; const noteEndMain = noteStartMain + noteDurSec; // Only play if the note starts inside the Section item bounds and hasn't finished yet if (noteStartMain >= secStart && noteStartMain < secEnd && offsetTime < noteEndMain) { const notePlayEndMain = Math.min(noteEndMain, secEnd); const program = subTrack.instrumentProgram; var subCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(subTrack, secTracks) : (subTrack.midiChannel !== undefined ? subTrack.midiChannel : (subIdx % 16)); if (offsetTime < noteStartMain) { const delay = noteStartMain - offsetTime; const startTime = context.currentTime + delay; const playDurMs = (notePlayEndMain - noteStartMain) * 1000; window.SonicSF.playNote( note.pitch || 60, note.velocity || 0.8, playDurMs, startTime, program, subNode, subCh, subTrack.synth_engine ); } else { const remainingDurMs = (notePlayEndMain - offsetTime) * 1000; window.SonicSF.playNote( note.pitch || 60, note.velocity || 0.8, remainingDurMs, context.currentTime, program, subNode, subCh, subTrack.synth_engine ); } } }); }); } }); }); } }); }; // 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 }] : []; // Get or create persistent gain & panner for real-time control const gainNode = getOrCreateTrackNode(track, context); const pannerNode = activeTrackNodesRef.current[track.id].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); } }); // MIDI items playback const midiItems = track.midiItems || []; if ((track.type === 'MIDI' || midiItems.length > 0) && window.SonicSF) { const bpmVal = parseInt(bpm) || 120; const secondsPerBeat = 60.0 / bpmVal; midiItems.forEach(item => { const notes = item.notes || []; notes.forEach(note => { const noteStartSec = item.startTime + (note.start_beat || 0) * secondsPerBeat; const noteDurSec = (note.duration_beats || 1) * secondsPerBeat; const noteEndSec = noteStartSec + noteDurSec; if (offsetTime < noteEndSec) { const durationMs = noteDurSec * 1000; const program = track.instrumentProgram !== undefined ? track.instrumentProgram : 0; if (offsetTime < noteStartSec) { const delay = noteStartSec - offsetTime; const startTime = context.currentTime + delay; window.SonicSF.playNote( note.pitch || 60, note.velocity || 0.8, durationMs, startTime, program, gainNode ); } else { const remainingDurMs = (noteEndSec - offsetTime) * 1000; window.SonicSF.playNote( note.pitch || 60, note.velocity || 0.8, remainingDurMs, context.currentTime, program, gainNode ); } } }); }); } }; const schedulePianoRollMidi = (st, offsetSeconds, notesOverride) => { if (st.type !== 'PIANO_ROLL') return; const context = getAudioContext(); const midiNotes = notesOverride || st.notes || []; const bpmVal = parseInt(bpm) || 120; const secondsPerBeat = 60.0 / bpmVal; const startWallTime = context.currentTime; const track = activeTracksRef.current ? activeTracksRef.current.find(t => t.id === st.trackId) : null; const destNode = getOrCreateTrackNode(track, context); const instrumentProgram = track ? track.instrumentProgram : undefined; const synthEngine = track ? track.synth_engine : undefined; var allTracks = activeTracksRef.current || []; var mainCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(track, allTracks) : (track ? track.midiChannel : 0); // Compute session beat offset from target item var sessionBeatOffset = 0; var targetTrk = allTracks.find(function(t) { return t.id === st.trackId; }); if (targetTrk) { var targetIt = (targetTrk.midiItems || []).find(function(m) { return m.id === st.target_id; }); if (targetIt) sessionBeatOffset = (targetIt.startTime / ((60.0 / bpmVal) * 4)) * 4; } midiNotes.forEach(note => { const noteOnBeat = (note.start_beat || 0) + sessionBeatOffset; const noteDurBeat = note.duration_beats || 1; const noteStartSec = noteOnBeat * secondsPerBeat; const noteDurSec = noteDurBeat * secondsPerBeat; if (noteStartSec + noteDurSec > offsetSeconds) { const effectiveStart = Math.max(0, noteStartSec - offsetSeconds); const effectiveDur = noteDurSec - Math.max(0, offsetSeconds - noteStartSec); const scheduledTime = startWallTime + effectiveStart; const durMs = effectiveDur * 1000; if (window.SonicSF) { window.SonicSF.playNote(note.pitch || 60, note.velocity || 0.8, durMs, scheduledTime, instrumentProgram, destNode, mainCh, synthEngine); } } }); // Play ghost notes from active tracks var ghostLayers = st.ghostPlayLayers || []; ghostLayers.forEach(function(layer) { var ghostTrack = allTracks.find(function(t) { return t.id === layer.trackId; }); var ghostProg = layer.instrumentProgram !== undefined ? layer.instrumentProgram : (ghostTrack ? ghostTrack.instrumentProgram : undefined); var ghostSynth = layer.synthEngine || (ghostTrack ? ghostTrack.synth_engine : undefined); if (ghostProg === undefined && !ghostSynth) return; var ghostDest = getOrCreateTrackNode(ghostTrack, context); var ghostCh = 0; if (ghostTrack) { if (ghostTrack.midiChannel !== undefined) { ghostCh = ghostTrack.midiChannel; } else { ghostCh = window.SonicPianoRoll ? window.SonicPianoRoll.getTrackMidiChannel(ghostTrack, allTracks) : (ghostTrack ? ghostTrack.midiChannel : 0); } } layer.notes.forEach(function(note) { var beat = (note.start_beat || 0) + sessionBeatOffset; var dur = note.duration_beats || 1; var startSec = beat * secondsPerBeat; var durSec = dur * secondsPerBeat; if (startSec + durSec > offsetSeconds) { var effStart = Math.max(0, startSec - offsetSeconds); var effDur = durSec - Math.max(0, offsetSeconds - startSec); var schedTime = startWallTime + effStart; var durMs = effDur * 1000; if (window.SonicSF) { window.SonicSF.playNote(note.pitch || 60, note.velocity || 0.8, durMs, schedTime, ghostProg, ghostDest, ghostCh, ghostSynth); } } }); }); }; const handlePlayPause = () => { if (activeTab !== 'main' && !activeTab.startsWith('session_')) { // Sub-tab playback transport const st = subTabs.find(s => s.id === activeTab); if (!st || (!st.buffer && st.type !== 'PIANO_ROLL')) return; if (st.isPlaying) { stopAllPlayback(); setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, isPlaying: false } : s)); } else { stopAllPlayback(); const startOffset = st.currentTime || 0; if (st.type === 'PIANO_ROLL') { schedulePianoRollMidi(st, startOffset); startSubTabPlayback(st, startOffset); } else { 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 handlePianoRollRealtimePlay = (trackIds) => { const prSt = subTabs.find(s => s.id === activeTab && s.type === 'PIANO_ROLL'); if (!prSt) return; var prLayers = []; var ghostData = (window.SonicGhost && window.SonicGhost.extractGhostLayers) ? window.SonicGhost.extractGhostLayers(activeTracks, prSt.trackId, prSt.target_id, parseInt(bpm) || 120) : []; (ghostData || []).forEach(function(layer) { if (!trackIds || trackIds.indexOf(layer.track_id) === -1) return; var ltrk = (activeTracks || []).find(function(t) { return t.id === layer.track_id; }); prLayers.push({ trackId: layer.track_id, notes: layer.notes.map(function(n) { return { pitch: n.pitch, start_beat: n.relative_start_beat, duration_beats: n.duration_beats, velocity: n.velocity || 0.8 }; }), instrumentProgram: ltrk ? ltrk.instrumentProgram : undefined, instrumentName: ltrk ? ltrk.instrumentName : undefined, synthEngine: ltrk ? ltrk.synth_engine : undefined }); }); stopAllPlayback(); const prCtx = getAudioContext(); const prTNode = activeTrackNodesRef.current[prSt.trackId]; if (prTNode && prTNode.gainNode) { prTNode.gainNode.gain.setValueAtTime(prTNode.gainNode.gain.value || 1, prCtx.currentTime); prTNode.gainNode.gain.linearRampToValueAtTime(0.001, prCtx.currentTime + 0.04); } setTimeout(() => { window.SonicSF.stopAll(); if (prTNode && prTNode.gainNode) { const prTrackData = activeTracksRef.current ? activeTracksRef.current.find(t => t.id === prSt.trackId) : null; const prVolDb = prTrackData ? (prTrackData.volumeDb ?? 0) : 0; const prVolLinear = prVolDb <= -50 ? 0 : Math.pow(10, prVolDb / 20); prTNode.gainNode.gain.setValueAtTime(0.001, prCtx.currentTime); prTNode.gainNode.gain.linearRampToValueAtTime(prVolLinear || 0.8, prCtx.currentTime + 0.015); } const prOffset = prSt.currentTime || 0; startOffsetTimeRef.current = prOffset; startAudioTimeRef.current = prCtx.currentTime; startBufferOffsetRef.current = prOffset * (prSt.speed || 1.0); const playTab = Object.assign({}, prSt, { ghostPlayLayers: prLayers }); schedulePianoRollMidi(playTab, prOffset); setSubTabs(prev => prev.map(s => s.id === prSt.id ? Object.assign({}, s, { ghostPlayLayers: prLayers, isPlaying: true, currentTime: prOffset }) : s)); }, 60); }; const handlePause = () => { if (isPlaying || subTabs.some(s => s.isPlaying)) stopAllPlayback(); }; const stopAllPlayback = () => { activeSourcesRef.current.forEach(src => { try { src.stop(); } catch (e) { } }); activeSourcesRef.current = []; Object.values(activeTrackNodesRef.current).forEach(n => { if (n.fxStopFn) n.fxStopFn(); }); activeTrackNodesRef.current = {}; if (window.SonicSF) { window.SonicSF.stopAll(); } setIsPlaying(false); setSubTabs(prev => prev.map(s => ({ ...s, isPlaying: false }))); }; const seekPlaybackTo = (time) => { const isSubTab = subTabs.some(sub => sub.id === activeTab); if (isSubTab) { const st = subTabs.find(s => s.id === activeTab); if (!st) return; if (st.isPlaying) { stopAllPlayback(); window.SonicSF.stopAll(); setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, currentTime: time, isPlaying: true } : s)); const ctx = getAudioContext(); startOffsetTimeRef.current = time; startAudioTimeRef.current = ctx.currentTime; startBufferOffsetRef.current = time * (st.speed || 1.0); if (st.type === 'PIANO_ROLL') { schedulePianoRollMidi(st, time); } startSubTabPlayback(st, time); } else { setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, currentTime: time } : s)); } } else { if (isPlaying) { stopAllPlayback(); setCurrentTime(time); startOffsetTimeRef.current = time; startAudioTimeRef.current = getAudioContext().currentTime; startTrackPlayback(time); setIsPlaying(true); } else { setCurrentTime(time); } } }; const handleStop = () => { if (recordingStateRef.current === 'RECORDING' || recordingStateRef.current === 'COUNT_IN') { stopRecordingTake(); return; } stopAllPlayback(); if (activeTab !== 'main' && !activeTab.startsWith('session_')) { 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; } // Check if piano roll tab is active and armed const activePianoRoll = subTabs.find(s => s.id === activeTab && s.type === 'PIANO_ROLL' && s.isArmed); if (activePianoRoll && selectedMidiInputId) { 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... (MIDI Piano Roll)', 'info'); for (let i = 0; i < 4; i++) { playMetronomeClick(now + i * secondsPerBeat, i === 0); } setTimeout(() => { startPianoRollRecording(activePianoRoll); }, countInDuration * 1000); 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 startPianoRollRecording = (tab) => { try { const context = getAudioContext(); const secondsPerBeat = 60.0 / (parseInt(bpm) || 120); const startTime = currentTime; const startBeat = startTime / secondsPerBeat; nextMetronomeBeatRef.current = Math.ceil(startBeat); const midiRec = new ClientMIDIRecorder(context, parseInt(bpm) || 120, 4); midiRec.tempTabId = tab.id; midiRec.selectedMidiInputId = selectedMidiInputId || 'ALL'; midiRec.start(startTime / secondsPerBeat, midiRec.selectedMidiInputId); pianoRollRecorderRef.current = midiRec; activeMIDIRecordersRef.current['piano_roll'] = midiRec; setRecStartTimelineTime(startTime); recordingStartTimeRef.current = startTime; setRecordingState('RECORDING'); setRecTempMidiNotes([]); const ctx = getAudioContext(); const silentBuf = ctx.createBuffer(1, 128, ctx.sampleRate); setSubTabs(prev => prev.map(s => s.id === tab.id ? { ...s, buffer: silentBuf, isPlaying: true } : s)); startSubTabPlayback({ ...tab, buffer: silentBuf }, startTime); startOffsetTimeRef.current = startTime; startAudioTimeRef.current = context.currentTime; setIsPlaying(true); midiRec.onNoteOn = (pitch, currentBeat) => { const elapsedBeats = Math.max(0, currentBeat); const sec = elapsedBeats * (60.0 / (parseInt(bpm) || 120)); const activeNotes = Array.from(midiRec.activeNotes.values()).map(n => ({ id: 'rec_' + n.pitch + '_' + currentBeat, pitch: n.pitch, start_beat: n.start_beat, duration_beats: Math.max(0.125, currentBeat - n.start_beat), velocity: Math.min(1, (n.velocity || 0.8)), pan: 0.0 })); const rec = midiRec.recordedNotes.map((n, i) => ({ id: 'rec_' + Date.now() + '_' + i, pitch: n.pitch, start_beat: n.start_beat, duration_beats: n.duration_beats, velocity: Math.min(1, (n.velocity || 0.8)), pan: 0.0 })); const allNotes = [...rec, ...activeNotes]; setRecTempMidiNotes(allNotes); setCanvasRedrawCount(n => n + 1); setSubTabs(prev => prev.map(s => s.id === tab.id ? { ...s, currentTime: startTime + sec, isDirty: true } : s )); }; if (!tab._recordingStarted) { tab._recordingStarted = true; } showToast('Recording MIDI to Piano Roll...', 'info'); } catch (err) { console.error('startPianoRollRecording error:', err); showToast('Lỗi khi bắt đầu ghi âm Piano Roll: ' + err.message, 'warning'); setRecordingState('IDLE'); } }; handleRecordClickRef.current = handleRecordClick; 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 * (secondsPerBeat * 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); const elapsedSec = elapsedBeats * secondsPerBeat; const durationSec = Math.max(4 * (secondsPerBeat * 4), elapsedSec); 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: durationSec } : m) }; })); } }; midiRec.onNoteOff = () => { const currentBeat = (context.currentTime - midiRec.recStartAudioTime - midiRec.latencyCompSec) / (60.0 / midiRec.bpm); const elapsedBeats = Math.max(0, currentBeat); const elapsedSec = elapsedBeats * secondsPerBeat; const durationSec = Math.max(4 * (secondsPerBeat * 4), elapsedSec); 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: durationSec } : 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 ? (masterBus ? masterBus.input : 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; const allNotes = [...midiRec.recordedNotes, ...Array.from(midiRec.activeNotes.values()).map(n => ({ ...n, duration_beats: currentBeat - n.start_beat }))]; const durationSec = Math.max(4 * (secondsPerBeatInt * 4), currentTimeSec); if (Math.random() < 0.2) { // Throttle log to prevent flooding (approx 2 logs/sec) console.log(`[DevLog] [MIDI Rec Sync] Temp Item ID: ${midiRec.tempMidiItemId}, Duration: ${durationSec.toFixed(2)}s, ActiveNotes: ${midiRec.activeNotes.size}, RecordedNotes: ${midiRec.recordedNotes.length}`); } setRecTempMidiNotes(allNotes); setCanvasRedrawCount(n => n + 1); 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: durationSec } : m) }; })); } }, 100); showToast('Đang ghi âm...', 'info'); }; const stopRecordingTake = async () => { setRecordingState('IDLE'); stopAllPlayback(); const context = getAudioContext(); const secondsPerBeat = 60.0 / (parseInt(bpm) || 120); const secondsPerBar = secondsPerBeat * 4; 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.tempTabId) { if (recordedNotes.length > 0) { const newNotes = recordedNotes.map((n, i) => ({ id: 'rec_' + Date.now() + '_' + i, pitch: n.pitch, start_beat: Math.max(0, n.start_beat || 0), duration_beats: Math.max(0.125, n.duration_beats || 0.25), velocity: Math.min(1, (n.velocity || 0.8)), pan: 0.0 })); setSubTabs(prev => prev.map(s => s.id === midiRec.tempTabId ? { ...s, notes: [...(s.notes || []), ...newNotes], isDirty: true } : s)); setCanvasRedrawCount(n => n + 1); showToast(`Đã ghi ${recordedNotes.length} notes vào Piano Roll.`, 'success'); } hasRecordedAnything = true; } else 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) * secondsPerBar }; return { ...t, midiItems: updatedItems }; } const newMidiItem = { id: 'midi_rec_' + Date.now(), name: 'Recorded MIDI', parent_track_id: t.id, startTime: recordingStartTimeRef.current, duration: Math.ceil(totalDurationBeats / 4) * secondsPerBar, length_bars: 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', parent_track_id: trackId, startTime: recordingStartTimeRef.current, duration: Math.ceil(totalDurationBeats / 4) * secondsPerBar, length_bars: 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) => { setPlayheadWithUndo(time); }; const clearLocalSelection = () => { setSelectionMode(null); setLocalSelectionTrackId(null); setLocalSelectionStart(null); setLocalSelectionEnd(null); }; const handleRulerMouseDown = e => { if (e.ctrlKey) { e.preventDefault(); e.stopPropagation(); clearLocalSelection(); const wrapper = timelineWrapperRef.current; if (wrapper) { const rect = wrapper.getBoundingClientRect(); const scrollLeft = wrapper.scrollLeft; const rawTime = Math.max(0, (e.clientX - rect.left + scrollLeft) / zoom - leadInMargin); const time = snapValue !== 'free' ? snapTime(rawTime, snapValue, bpm) : rawTime; rulerDragStartRef.current = time; rulerAnchorRef.current = time; isDraggingRulerRef.current = true; captureSelectionUndo(); setSelectionMode('global'); setSelectionStart(time); setSelectionEnd(time); } 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, mouseX / zoom - leadInMargin); const time = snapValue !== 'free' ? snapTime(rawTime, snapValue, bpm) : rawTime; clearLocalSelection(); captureSelectionUndo(); setSelectionMode('global'); rulerDragStartRef.current = time; isDraggingRulerRef.current = true; if (e.shiftKey) { e.preventDefault(); e.stopPropagation(); const anchor = rulerAnchorRef.current !== null && rulerAnchorRef.current !== undefined ? rulerAnchorRef.current : selectionStart !== null && selectionStart !== undefined ? selectionStart : currentTime; const selS = Math.max(0, Math.min(anchor, time)); const selE = Math.max(0, Math.max(anchor, time)); setSelectionStart(selS); setSelectionEnd(selE); } else { rulerAnchorRef.current = Math.max(0, 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 - leadInMarginRef.current)); 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; pushSelectionUndo(); } }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); // Sub-tab ruler drag (section/editor tabs) const handleSubTabMove = e => { if (!isDraggingSubTabRef.current) return; const wrapper = timelineWrapperRef.current; if (!wrapper) return; const rect = wrapper.getBoundingClientRect(); const sl = wrapper.scrollLeft; const raw = Math.max(0, (e.clientX - rect.left + sl) / zoom); const time = snapValueRef.current !== 'free' ? snapTime(raw, snapValueRef.current, bpmRef.current) : raw; const anchor = subTabDragStartRef.current ?? 0; setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? { ...s, selectionStart: Math.min(anchor, time), selectionEnd: Math.max(anchor, time) } : s)); }; const handleSubTabUp = () => { if (isDraggingSubTabRef.current) { isDraggingSubTabRef.current = false; subTabDragStartRef.current = null; } }; document.addEventListener('mousemove', handleSubTabMove); document.addEventListener('mouseup', handleSubTabUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); document.removeEventListener('mousemove', handleSubTabMove); document.removeEventListener('mouseup', handleSubTabUp); }; }, [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); captureSelectionUndo(); 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; pushSelectionUndo(); } }; 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; const selectionUndoRef = useRef(null); const pushSelectionUndo = () => { var cur = selectionRef.current; var before = selectionUndoRef.current; if (!before || (before.start === cur.start && before.end === cur.end && before.mode === cur.mode)) return; var entry = { type: 'SELECTION', scope: 'global', label: 'Selection', before: before, after: { start: cur.start, end: cur.end, mode: cur.mode }, undo: (e) => { setSelectionStart(e.before.start); setSelectionEnd(e.before.end); setSelectionMode(e.before.mode); showToast('Undo: Selection', 'info'); }, redo: (e) => { setSelectionStart(e.after.start); setSelectionEnd(e.after.end); setSelectionMode(e.after.mode); showToast('Redo: Selection', 'info'); } }; if (window.UndoRedoEngine) window.UndoRedoEngine.execute(entry); selectionUndoRef.current = null; }; const captureSelectionUndo = () => { if (selectionUndoRef.current !== null) return; selectionUndoRef.current = { start: selectionRef.current.start, end: selectionRef.current.end, mode: selectionMode }; }; let clipSeqCounter = 0; const nextClipId = () => `clip_${Date.now()}_${++clipSeqCounter}`; const handleClipDragStart = (trackId, clipId, clickOffset, isDuplicate = false) => { const curTracks = activeTracksRef.current || activeTracks; const track = curTracks.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)' }; updateActiveTracks(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) { updateActiveTracks(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 curTracks = activeTracksRef.current || activeTracks; const track = curTracks.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) { updateActiveTracks(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'); }; // Shared auto-scroll: when mouse near right edge, scroll container right const autoScrollTimeline = (clientX) => { const wrapper = timelineWrapperRef.current; if (!wrapper) return; const wr = wrapper.getBoundingClientRect(); const margin = 200; if (clientX > wr.right - margin) { const depth = (clientX - (wr.right - margin)) / margin; const speed = Math.round(5 + depth * depth * 40); wrapper.scrollLeft += speed; if (wrapper.scrollLeft > wrapper.scrollWidth - wrapper.clientWidth - 100) { const secPerBar = (60.0 / (parseInt(bpm) || 120)) * 4; setScrollBufferExtra(prev => prev + secPerBar * 4); } } else if (clientX < wr.left + margin) { const depth = ((wr.left + margin) - clientX) / margin; const speed = Math.round(5 + depth * depth * 40); wrapper.scrollLeft = Math.max(0, wrapper.scrollLeft - speed); } }; useEffect(() => { const handleMouseMove = e => { const drag = draggedClipRef.current; if (!drag) return; const wrapper = timelineWrapperRef.current; if (!wrapper) return; autoScrollTimeline(e.clientX); const rect = wrapper.getBoundingClientRect(); const scrollLeft = wrapper.scrollLeft; const mouseX = e.clientX - rect.left + scrollLeft; const time = Math.max(0, mouseX / zoom - leadInMarginRef.current); const beatSec = (60.0 / (parseInt(bpmRef.current) || 120)); const rawStart = Math.max(0, time - drag.clickOffset); const secPerBar = beatSec * 4; const marginBar = maxDurationRef.current - secPerBar; const clampedStart = Math.min(rawStart, marginBar); const newStart = snapTime(Math.max(0, clampedStart), snapValueRef.current, bpmRef.current); const itemPx = newStart * zoom; const keepMargin = 80; if (itemPx > scrollLeft + rect.width - keepMargin) { wrapper.scrollLeft = itemPx - rect.width + keepMargin; setCanvasRedrawCount(n => n + 1); } else if (itemPx < scrollLeft + keepMargin) { wrapper.scrollLeft = Math.max(0, itemPx - keepMargin); setCanvasRedrawCount(n => n + 1); } const targetTrackId = hoveredTrackIdRef.current || drag.trackId; updateActiveTracks(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; autoScrollTimeline(e.clientX); 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 secPerBar = (60.0 / (parseInt(bpmRef.current) || 120)) * 4; const marginBar = maxDurationRef.current - secPerBar; const maxEnd = Math.min(time, marginBar); const newDuration = Math.max(0.1, maxEnd - stretch.startTime); const speedRatio = stretch.originalDuration / newDuration; updateActiveTracks(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]); // ── Deselect single item ── const handleDeselectItem = itemId => { captureSelectionUndo(); setSelectedItemIds(prev => { var next = new Set(prev); next.delete(itemId); return next; }); }; // ── Add item to selectedItemIds ── const handleSaveSectionTabRef = useRef(handleSaveSectionTab); handleSaveSectionTabRef.current = handleSaveSectionTab; const handleAddToSelection = itemId => { captureSelectionUndo(); setSelectedItemIds(prev => { var next = new Set(prev); next.add(itemId); return next; }); }; const handleSetPendingDrag = (trackId, itemType, itemId, clickOffset, e, preToggleSnapshot) => { // Compute selectedIds AFTER the toggle that just happened: // - If itemId was in snapshot (was selected) → toggle removed it → delete // - If itemId was not in snapshot (was not selected) → toggle added it → add var ids = preToggleSnapshot ? new Set(preToggleSnapshot) : new Set(selectedItemIds); if (preToggleSnapshot && preToggleSnapshot.has(itemId)) { ids.delete(itemId); } else { ids.add(itemId); } pendingDragRef.current = { trackId, itemType, itemId, clickOffset, startX: e.clientX, startY: e.clientY, selectedIds: ids }; }; // ── Sweep Select ── const handleSweepSelectStart = (trackId, startTime, startY) => { isSweepingRef.current = true; sweepStartRef.current = startTime; sweepTrackIdRef.current = trackId; sweepStartYRef.current = startY || 0; sweepEndYRef.current = startY || 0; var init = { startTime, endTime: startTime }; sweepSelectRef.current = init; setSweepSelect(init); // Do NOT clear selection here – wait until mouseup. // Small movement → deselect all; large movement → marquee toggle. }; // ── Section / MIDI Item Drag Start ── const handleSectionItemDragStart = (trackId, itemType, itemId, clickOffset, isDuplicate, pendingSelectedIds) => { var curTracks = activeTracksRef.current || activeTracks; var multiIds = null; var selIds = pendingSelectedIds || selectedItemIds; if (selIds && selIds.size > 0 && selIds.has(itemId)) { var selArr = Array.from(selIds); var originals = {}; curTracks.forEach(function(t) { (t.sections || []).forEach(function(s) { if (selArr.indexOf(s.id) >= 0) originals[s.id] = { type: 'section', start: s.start, trackId: t.id }; }); (t.midiItems || []).forEach(function(m) { if (selArr.indexOf(m.id) >= 0) originals[m.id] = { type: 'midiItem', start: m.startTime, trackId: t.id }; }); (t.clips || []).forEach(function(c) { var cid = c.id === 'default' ? 'default_' + t.id : c.id; if (selArr.indexOf(cid) >= 0) originals[cid] = { type: 'clip', start: c.startTime, trackId: t.id }; }); }); if (Object.keys(originals).length > 0) multiIds = originals; } if (isDuplicate) { var track = curTracks.find(function(t) { return t.id === trackId; }); if (!track) return; if (multiIds) { var newOriginals = {}; var dupBeforeSnap = captureAllTracksSnapshot(); updateActiveTracks(function(prev) { return prev.map(function(t) { var updatedSections = t.sections ? t.sections.slice() : []; var updatedMidi = t.midiItems ? t.midiItems.slice() : []; var updatedClips = t.clips ? t.clips.slice() : []; Object.keys(multiIds).forEach(function(oid) { var info = multiIds[oid]; if (info.type === 'section') { var sec = (t.sections || []).find(function(s) { return s.id === oid; }); if (sec) { var rearrangeNewId = 'sec_dup_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5); updatedSections.push({ ...sec, id: rearrangeNewId, name: sec.name + ' (Copy)' }); newOriginals[rearrangeNewId] = { type: 'section', start: sec.start }; } } else if (info.type === 'midiItem') { var mid = (t.midiItems || []).find(function(m) { return m.id === oid; }); if (mid) { var rearrangeNewId = 'midi_dup_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5); updatedMidi.push({ ...mid, id: rearrangeNewId, name: mid.name + ' (Copy)' }); newOriginals[rearrangeNewId] = { type: 'midiItem', start: mid.startTime }; } } else if (info.type === 'clip') { var clip = (t.clips || []).find(function(c) { return c.id === oid || 'default_' + t.id === oid; }); if (clip) { var rearrangeNewId = 'clip_dup_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5); updatedClips.push({ ...clip, id: rearrangeNewId, startTime: clip.startTime, name: clip.name + ' (Copy)' }); newOriginals[rearrangeNewId] = { type: 'clip', start: clip.startTime }; } } }); return { ...t, sections: updatedSections, midiItems: updatedMidi, clips: updatedClips }; }); }); var newItemId = Object.keys(newOriginals)[0] || itemId; var origPos = newOriginals[newItemId] || { start: 0 }; setDraggedSectionItem({ trackId, itemType, itemId: newItemId, clickOffset, multiIds: newOriginals, originalPositions: { [newItemId]: origPos.start }, beforeSnap: dupBeforeSnap }); } else { var items = itemType === 'section' ? (track.sections || []) : (track.midiItems || []); var item = items.find(function(it) { return it.id === itemId; }); if (!item) return; var rearrangeNewId = itemType + '_dup_' + Date.now(); var newItem = { ...item, id: rearrangeNewId, name: item.name + ' (Copy)' }; var singleDupBeforeSnap = captureAllTracksSnapshot(); updateActiveTracks(function(prev) { return prev.map(function(t) { if (t.id !== trackId) return t; var updated = itemType === 'section' ? [...(t.sections || []), newItem] : [...(t.midiItems || []), newItem]; return itemType === 'section' ? { ...t, sections: updated } : { ...t, midiItems: updated }; }); }); setDraggedSectionItem({ trackId, itemType, itemId: rearrangeNewId, clickOffset, isDuplicate: false, originalPositions: { [rearrangeNewId]: itemType === 'section' ? item.start : item.startTime }, beforeSnap: singleDupBeforeSnap }); } } else { var curTrk = activeTracksRef.current ? activeTracksRef.current.find(t => t.id === trackId) : null; var its = itemType === 'section' ? (curTrk?.sections || []) : (curTrk?.midiItems || []); var it = its.find(function(x) { return x.id === itemId; }); var origPos = it ? (itemType === 'section' ? it.start : it.startTime) : 0; var beforeSnap = captureAllTracksSnapshot(); setDraggedSectionItem({ trackId, itemType, itemId, clickOffset, multiIds, originalPositions: { [itemId]: origPos }, beforeSnap }); } }; handleSectionItemDragStartRef.current = handleSectionItemDragStart; // ── 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; autoScrollTimeline(e.clientX); const rect = wrapper.getBoundingClientRect(); const scrollLeft = wrapper.scrollLeft; const mouseX = e.clientX - rect.left + scrollLeft; const time = Math.max(0, mouseX / zoom - leadInMarginRef.current); const beatSec = (60.0 / (parseInt(bpm) || 120)); const secondsPerBar = beatSec * 4; const marginBar = maxDurationRef.current - secondsPerBar; const rawStart = Math.max(0, Math.min(time - drag.clickOffset, marginBar)); const newStart = snapTime(rawStart, snapValueRef.current, bpmRef.current); const itemPx = newStart * zoom; const keepMargin = 80; if (itemPx > scrollLeft + rect.width - keepMargin) { wrapper.scrollLeft = itemPx - rect.width + keepMargin; setCanvasRedrawCount(n => n + 1); } else if (itemPx < scrollLeft + keepMargin) { wrapper.scrollLeft = Math.max(0, itemPx - keepMargin); setCanvasRedrawCount(n => n + 1); } // Compute target track from mouse Y position var allTrks = activeTracksRef.current || []; var mouseY = e.clientY - rect.top; var trackTop = 0; var targetIdx = -1; for (var ti = 0; ti < allTrks.length; ti++) { var tH = allTrks[ti].height || (allTrks[ti].isArmed ? 164 : 140); if (mouseY >= trackTop && mouseY < trackTop + tH) { targetIdx = ti; break; } trackTop += tH; } if (targetIdx < 0) targetIdx = mouseY < trackTop ? 0 : Math.max(0, allTrks.length - 1); var targetTrackId = allTrks[targetIdx] ? allTrks[targetIdx].id : drag.trackId; // Use ORIGINAL trackId from drag start for crossOffset math (never changes) var dragBaseTrackId = drag.multiIds && drag.multiIds[drag.itemId] ? drag.multiIds[drag.itemId].trackId : drag.trackId; // Pre-compute crossOffset from current allTrks (stable per mousemove) var baseIdx = allTrks.findIndex(function(tr) { return tr.id === dragBaseTrackId; }); if (baseIdx < 0) baseIdx = 0; var trgIdx = allTrks.findIndex(function(tr) { return tr.id === targetTrackId; }); if (trgIdx < 0) trgIdx = allTrks.length - 1; if (trgIdx < 0) trgIdx = 0; var crossOffset = trgIdx - baseIdx; // Special path for multi-select drag: handle items from multiple tracks if (drag.multiIds) { var dragOrigStart = drag.multiIds[drag.itemId] ? drag.multiIds[drag.itemId].start : 0; var delta = newStart - dragOrigStart; // Extend activeTracks if target track index exceeds track count var outTracks = []; Object.keys(drag.multiIds).forEach(function(mid) { var inf = drag.multiIds[mid]; var oIdx = allTrks.findIndex(function(tr) { return tr.id === inf.trackId; }); if (oIdx < 0) oIdx = baseIdx; var needIdx = oIdx + crossOffset; if (needIdx >= allTrks.length) { for (var ai = allTrks.length; ai <= needIdx; ai++) { var exists = outTracks.some(function(ot) { return ot.id === (ai + 1).toString(); }); if (!exists) { var colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; outTracks.push({ id: (ai + 1).toString(), name: 'Track ' + (ai + 1), buffer: null, startTime: 0, volumeDb: 0, pan: 0, muted: false, solo: false, color: colors[ai % colors.length], clips: [], midiItems: [], sections: [], markers: [], isArmed: false, height: 140 }); } } } }); updateActiveTracks(function(prev) { var trkIds = prev.map(function(tr) { return tr.id; }); var merged = outTracks.length > 0 ? prev.concat(outTracks.filter(function(ot) { return !trkIds.includes(ot.id); })) : prev; var allIds = merged.map(function(tr) { return tr.id; }); return merged.map(function(t) { var resS = (t.sections || []).slice(); var resM = (t.midiItems || []).slice(); var resC = (t.clips || []).slice(); Object.keys(drag.multiIds).forEach(function(mid) { var inf = drag.multiIds[mid]; var newVal = inf.start + delta; var oIdx = allIds.indexOf(inf.trackId); if (oIdx < 0) oIdx = baseIdx; var needIdx = Math.max(0, oIdx + crossOffset); var targetTid = needIdx < merged.length ? merged[needIdx].id : merged[merged.length - 1].id; if (inf.type === 'section') { resS = resS.filter(function(s) { return s.id !== mid; }); if (t.id === targetTid) { var src = null; for (var p = 0; p < prev.length; p++) { src = (prev[p].sections || []).find(function(s) { return s.id === mid; }); if (src) break; } if (src) resS.push({ ...src, start: Math.max(0, newVal) }); } } else if (inf.type === 'midiItem') { resM = resM.filter(function(mx) { return mx.id !== mid; }); if (t.id === targetTid) { var src = null; for (var p = 0; p < prev.length; p++) { src = (prev[p].midiItems || []).find(function(mx) { return mx.id === mid; }); if (src) break; } if (src) resM.push({ ...src, startTime: Math.max(0, newVal) }); } } else if (inf.type === 'clip') { var cid3 = 'default_' + t.id; resC = resC.filter(function(cx) { return cx.id !== mid && cx.id !== cid3; }); if (t.id === targetTid) { var src = null; for (var p = 0; p < prev.length; p++) { var c2 = (prev[p].clips || []).find(function(cx) { return cx.id === mid || 'default_' + prev[p].id === mid; }); if (c2) { src = c2; break; } } if (src) resC.push({ ...src, startTime: Math.max(0, newVal) }); } } }); return { ...t, sections: resS, midiItems: resM, clips: resC }; }); }); } else { // Single item drag path updateActiveTracks(function(prev) { let movedItem = null; for (let track of prev) { const sec = (track.sections || []).find(function(it) { return it.id === drag.itemId; }); const mid = (track.midiItems || []).find(function(it) { return it.id === drag.itemId; }); if (sec || mid) { movedItem = sec || mid; break; } } return prev.map(function(tr) { var its = drag.itemType === 'section' ? (tr.sections || []).filter(function(it) { return it.id !== drag.itemId; }) : (tr.midiItems || []).filter(function(it) { return it.id !== drag.itemId; }); if (tr.id === targetTrackId && movedItem) { its.push(drag.itemType === 'section' ? { ...movedItem, start: newStart } : { ...movedItem, startTime: newStart }); } return drag.itemType === 'section' ? { ...tr, sections: its } : { ...tr, midiItems: its }; }); }); } }; const handleMouseUp = () => { const drag = draggedSectionItemRef.current; if (!drag) return; var changed = false; var trackId = drag.trackId; var beforeSnap = drag.beforeSnap; var origs = drag.originalPositions || {}; updateActiveTracks(function(prev) { return prev.map(function(t) { var allItemIds = Object.keys(origs); var updatedSections = (t.sections || []).slice(); var updatedMidi = (t.midiItems || []).slice(); var hasChange = false; for (var i = 0; i < allItemIds.length; i++) { var oid = allItemIds[i]; var origStart = origs[oid]; if (t.id === trackId || drag.multiIds && Object.values(drag.multiIds).some(function(v) { return v.trackId === t.id && v.type === (oid.startsWith('sec_') ? 'section' : oid.startsWith('midi_') ? 'midiItem' : 'clip'); })) { var sec = updatedSections.find(function(s) { return s.id === oid; }); var mid = updatedMidi.find(function(m) { return m.id === oid; }); var item = sec || mid; if (item) { var curStart = sec ? item.start : item.startTime; if (Math.abs(curStart - origStart) > 0.001) { hasChange = true; changed = true; } } } } if (!hasChange) return t; return { ...t, sections: updatedSections, midiItems: updatedMidi }; }); }); if (changed || drag.beforeSnap) { var afterSnap = captureAllTracksSnapshot(); pushAction('MOVE_ITEM', 'ALL_TRACKS', drag.beforeSnap || beforeSnap, afterSnap); } if (drag.itemType === 'midiItem') { let finalTrackId = null; const curTrks = activeTracksRef.current || activeTracks; for (const t of curTrks) { if ((t.midiItems || []).some(m => m.id === drag.itemId)) { finalTrackId = t.id; break; } } if (finalTrackId) { const targetTrack = curTrks.find(t => t.id === finalTrackId); if (targetTrack) { setSubTabs(prev => prev.map(st => { if (st.target_id === drag.itemId) { return { ...st, trackId: finalTrackId, instrumentProgram: targetTrack.instrumentProgram !== undefined ? targetTrack.instrumentProgram : st.instrumentProgram, instrumentName: targetTrack.instrumentName || st.instrumentName }; } return st; })); } } } 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]); // ── Pending drag: Ctrl+click toggles selection; mousemove > threshold starts copy-drag ── useEffect(() => { const handleMouseMove = e => { var pd = pendingDragRef.current; if (!pd) return; var dx = e.clientX - pd.startX; if (Math.abs(dx) > 5) { var pdSnap = pendingDragRef.current; pendingDragRef.current = null; if (handleSectionItemDragStartRef.current) handleSectionItemDragStartRef.current(pdSnap.trackId, pdSnap.itemType, pdSnap.itemId, pdSnap.clickOffset, true, pdSnap.selectedIds); } }; document.addEventListener('mousemove', handleMouseMove); var handleMouseUp = function() { pendingDragRef.current = null; }; document.addEventListener('mouseup', handleMouseUp); return function() { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); pendingDragRef.current = null; }; }, []); // ── 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; autoScrollTimeline(e.clientX); const rect = wrapper.getBoundingClientRect(); const scrollLeft = wrapper.scrollLeft; const mouseX = e.clientX - rect.left + scrollLeft; const time = Math.max(0, mouseX / zoom - leadInMarginRef.current); 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 beatSec = (60.0 / (parseInt(bpm) || 120)); const newStart = Math.max(0, 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 secondsPerBar = (60.0 / (parseInt(bpm) || 120)) * 4; const marginBar = maxDurationRef.current - secondsPerBar; const clampedTime = Math.min(time, marginBar); const snappedDuration = snapValueRef.current !== 'free' ? snapTime(clampedTime - resize.originalStart, snapValueRef.current, bpm) : clampedTime - resize.originalStart; const newDuration = Math.max(0.1, snappedDuration); items[idx] = { ...item, duration: newDuration }; } return resize.itemType === 'section' ? { ...t, sections: items } : { ...t, midiItems: items }; })); setCanvasRedrawCount(n => n + 1); const edgePx = (resize.side === 'left' ? Math.max(0, time) : time) * zoom; const keepMargin = 80; if (edgePx > scrollLeft + rect.width - keepMargin) { wrapper.scrollLeft = edgePx - rect.width + keepMargin; setCanvasRedrawCount(n => n + 1); } else if (edgePx < scrollLeft + keepMargin) { wrapper.scrollLeft = Math.max(0, edgePx - keepMargin); setCanvasRedrawCount(n => n + 1); } }; 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]); // ── Sweep Select mousemove/mouseup ── useEffect(() => { const handleMouseMove = e => { if (!isSweepingRef.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, mouseX / zoom - leadInMarginRef.current); setSweepSelect(prev => { var updated = prev ? { ...prev, endTime: time } : null; sweepSelectRef.current = updated; return updated; }); }; const handleMouseUp = () => { if (!isSweepingRef.current) return; isSweepingRef.current = false; sweepTrackIdRef.current = null; const sweep = sweepSelectRef.current; sweepSelectRef.current = null; captureSelectionUndo(); if (sweep) { const start = Math.min(sweep.startTime, sweep.endTime); const end = Math.max(sweep.startTime, sweep.endTime); if (Math.abs(end - start) < 0.02) { setSelectedItemIds(new Set()); setSweepSelect(null); pushSelectionUndo(); return; } const curTracks = activeTracksRef.current || []; const found = new Set(); curTracks.forEach(t => { (t.midiItems || []).forEach(m => { if (m.startTime < end && m.startTime + m.duration > start) { found.add(m.id); } }); (t.sections || []).forEach(s => { if (s.start < end && s.start + s.duration > start) { found.add(s.id); } }); (t.clips || []).forEach(c => { const dur = c.buffer ? c.buffer.duration / (c.speed || 1.0) : 4; if (c.startTime < end && c.startTime + dur > start) { const cid = c.id === 'default' ? 'default_' + t.id : c.id; found.add(cid); } }); }); setSelectedItemIds(prev => { const next = new Set(prev); found.forEach(id => { if (next.has(id)) next.delete(id); else next.add(id); }); return next; }); setSweepSelect(null); sweepTrackIdRef.current = null; pushSelectionUndo(); } }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); return () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; }, [zoom]); const handleSelectRange = (start, end, reset) => { captureSelectionUndo(); 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); } setSelectionCleared(false); pushSelectionUndo(); }; const handleSelectionInputChange = (field, val) => { captureSelectionUndo(); const numericVal = Math.max(0, parseFloat(val) || 0); if (selectionMode === 'local') { if (field === 'start') { setLocalSelectionStart(numericVal); } else { setLocalSelectionEnd(numericVal); } } else { if (field === 'start') { setSelectionStart(numericVal); } else { setSelectionEnd(numericVal); } } pushSelectionUndo(); }; 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; const playingSubTab = subTabs.find(s => s.isPlaying && s.type === 'PIANO_ROLL'); stopAllPlayback(); setTracks(prev => prev.map(t => t.id === trackId ? { ...t, solo: !t.solo, muted: false } : t )); setTimeout(() => { const soloCtx = getAudioContext(); if (wasPlaying) { startOffsetTimeRef.current = currentTime; startAudioTimeRef.current = soloCtx.currentTime; startBufferOffsetRef.current = currentTime; setIsPlaying(true); startTrackPlayback(currentTime); } if (playingSubTab) { const prSt = subTabs.find(s => s.id === playingSubTab.id); if (prSt) { const prOffset = prSt.currentTime || 0; startOffsetTimeRef.current = prOffset; startAudioTimeRef.current = soloCtx.currentTime; startBufferOffsetRef.current = prOffset * (prSt.speed || 1.0); schedulePianoRollMidi(prSt, prOffset); startSubTabPlayback(prSt, prOffset); setSubTabs(prev => prev.map(s => s.id === prSt.id ? Object.assign({}, s, { isPlaying: true, currentTime: prOffset }) : s)); } } }, 60); 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 updateTrackProp = (trackId, props) => { setTracks(prev => prev.map(t => t.id === trackId ? { ...t, ...props } : t)); setTimeout(() => lucide.createIcons(), 50); }; const toggleTrackDrum = trackId => { var mt = tracks || []; var tidx = mt.findIndex(function(tr) { return tr.id === trackId; }); if (tidx < 0) tidx = 0; setTracks(prev => prev.map(t => { if (t.id !== trackId) return t; const becomingPercussion = !t.is_percussion; let newBank = becomingPercussion ? 128 : (t._saved_sf_bank !== undefined ? t._saved_sf_bank : 0); let newProgram = becomingPercussion ? 0 : (t._saved_sf_program !== undefined ? t._saved_sf_program : 0); let newCh = becomingPercussion ? 9 : (t._saved_sf_channel !== undefined ? t._saved_sf_channel : (tidx % 16)); return { ...t, is_percussion: becomingPercussion, soundfont_bank: newBank, instrumentProgram: newProgram, midiChannel: newCh, _saved_sf_bank: becomingPercussion ? t.soundfont_bank : t._saved_sf_bank, _saved_sf_program: becomingPercussion ? t.instrumentProgram : t._saved_sf_program, _saved_sf_channel: becomingPercussion ? t.midiChannel : t._saved_sf_channel, synth_engine: t.synth_engine ? { ...t.synth_engine, soundfont_bank: newBank, soundfont_program: newProgram } : t.synth_engine }; })); 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) }; })); }; // ── MIDI .mid file parser ── const parseMidiFile = (arrayBuffer) => { const data = new Uint8Array(arrayBuffer); if (data.length < 14) return null; var pos = 0; var read32 = function() { var v = (data[pos] << 24) | (data[pos+1] << 16) | (data[pos+2] << 8) | data[pos+3]; pos += 4; return v; }; var read16 = function() { var v = (data[pos] << 8) | data[pos+1]; pos += 2; return v; }; var readVLQ = function() { var v = 0, b; do { b = data[pos++]; v = (v << 7) | (b & 0x7f); } while (b & 0x80); return v; }; var header = String.fromCharCode(data[0], data[1], data[2], data[3]); if (header !== 'MThd') return null; pos = 8; var fmt = read16(); var numTracks = read16(); var division = read16(); var ticksPerBeat = (division & 0x8000) ? 480 : (division || 480); var bpm = 120; var result = []; for (var t = 0; t < numTracks; t++) { if (pos + 8 > data.length) break; var trkId = String.fromCharCode(data[pos], data[pos+1], data[pos+2], data[pos+3]); pos += 4; var trkLen = read32(); var endPos = Math.min(pos + trkLen, data.length); if (trkId !== 'MTrk') { pos = endPos; continue; } var absTicks = 0; var runningStatus = 0; var trackName = 'MIDI Track ' + (t + 1); var midiNotes = []; var pendingNotes = {}; var maxAbsTick = 0; while (pos < endPos) { try { var delta = readVLQ(); absTicks += delta; var status = data[pos]; if (status >= 0x80) { if (status < 0xf0) { runningStatus = status; } pos++; } else { status = runningStatus; } var cmd = status >> 4; if (cmd === 0x9 || cmd === 0x8) { var chan = status & 0x0F; var pitch = data[pos++]; var vel = pos < endPos ? data[pos++] : 0; var noteKey = chan + '_' + pitch; if (cmd === 0x9 && vel > 0) { pendingNotes[noteKey] = { tick: absTicks, vel: vel }; if (absTicks > maxAbsTick) maxAbsTick = absTicks; } else { var pn = pendingNotes[noteKey]; if (pn) { var durTicks = absTicks - pn.tick; if (durTicks <= 0) durTicks = 240; midiNotes.push({ id: 'mn_' + t + '_' + pitch + '_' + pn.tick, pitch: pitch, start_beat: pn.tick / ticksPerBeat, duration_beats: durTicks / ticksPerBeat, velocity: Math.min(1, pn.vel / 127) }); delete pendingNotes[noteKey]; } } } else if (status >= 0xc0 && status < 0xe0) { if (pos < endPos) pos += 1; } else if (status >= 0xe0 && status < 0xf0) { if (pos + 2 <= endPos) pos += 2; } else if (status >= 0xa0 && status < 0xc0) { if (pos + 2 <= endPos) pos += 2; } else if (status >= 0xf0 && status < 0xf8) { if (pos < endPos) { var sl = readVLQ(); pos += sl; } } else if (status === 0xff) { if (pos >= endPos) break; var metaType = data[pos++]; var metaLen = readVLQ(); if (metaType === 0x03) { try { trackName = String.fromCharCode.apply(null, Array.from(data.subarray(pos, pos + metaLen))); } catch(e) {} } else if (metaType === 0x51 && pos + 3 <= endPos) { bpm = Math.round(60000000 / ((data[pos] << 16) | (data[pos+1] << 8) | data[pos+2])); } pos += Math.min(metaLen, endPos - pos); } else { if (pos + 2 <= endPos) pos += 2; } } catch(e) { pos = endPos; } } Object.keys(pendingNotes).forEach(function(k) { var pn = pendingNotes[k]; var parts = k.split('_'); var p = parseInt(parts[1]); var dur = Math.max(240, maxAbsTick - pn.tick); midiNotes.push({ id: 'mn_' + t + '_' + p + '_' + pn.tick, pitch: p, start_beat: pn.tick / ticksPerBeat, duration_beats: dur / ticksPerBeat, velocity: Math.min(1, pn.vel / 127) }); }); if (midiNotes.length > 0) { var lastEnd = 0; midiNotes.forEach(function(n) { var e = (n.start_beat + n.duration_beats) * 60 / bpm; if (e > lastEnd) lastEnd = e; }); result.push({ name: trackName, notes: midiNotes, duration: lastEnd || 4, startTime: 0, id: 'midi_' + t + '_' + Date.now() }); } pos = endPos; } return result.length > 0 ? result : null; }; // ── Load File on Track (with server upload) ── const loadFileOnTrack = async (trackId, file) => { if (!file) return; var fileName = file.name || ''; var isMidi = /\.mid$|\.midi$/i.test(fileName); showToast(`Đang nạp file ${fileName}...`, 'info'); try { if (isMidi) { var arrayBuffer = await file.arrayBuffer(); var midiResult = parseMidiFile(arrayBuffer); if (!midiResult || midiResult.length === 0) { showToast('Không tìm thấy nốt nhạc trong file MIDI.', 'error'); return; } if (midiResult.length === 1) { updateActiveTracks(prev => prev.map(function(t) { if (t.id !== trackId) return t; return { ...t, name: fileName, midiItems: [midiResult[0]] }; })); showToast('Đã tải MIDI: ' + fileName, 'success'); } else { var colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309', '#ca8a04', '#dc2626', '#0891b2']; var firstDone = false; midiResult.forEach(function(midiItem, idx) { if (!firstDone) { updateActiveTracks(prev => prev.map(function(t) { if (t.id !== trackId) return t; return { ...t, name: midiItem.name || fileName, midiItems: [midiItem] }; })); firstDone = true; } else { var newId = 'midi_track_' + Date.now() + '_' + idx; updateActiveTracks(function(prev) { var curLen = prev.length; return prev.concat([{ id: newId, name: midiItem.name || 'MIDI Track ' + (idx + 1), buffer: null, startTime: 0, volumeDb: 0, pan: 0, muted: false, solo: false, color: colors[idx % colors.length], markers: [], serverFileId: null, clips: [], sections: [], midiItems: [midiItem], isArmed: false, monitoringEnabled: true, inputSource: { deviceType: 'NONE', deviceId: '' } }]); }); } }); showToast('Đã tải MIDI: ' + midiResult.length + ' tracks from ' + fileName, 'success'); } return; } // 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: fileName, buffer: decodedBuffer, channelInfo: channelInfo } : t)); showToast(`Nạp file thành công: ${fileName} (${channelInfo.label})`, 'success'); } catch (err) { showToast(isMidi ? "Lỗi giải mã MIDI." : "Lỗi giải mã âm thanh. Định dạng file không tương thích.", 'error'); } }; // ── Drop MIDI file → create new track(s) ── const handleDropMidiToNewTracks = async (file) => { if (!file) return; showToast('Đang nạp MIDI: ' + file.name + '...', 'info'); try { var arrayBuffer = await file.arrayBuffer(); var midiResult = parseMidiFile(arrayBuffer); if (!midiResult || midiResult.length === 0) { showToast('Không tìm thấy nốt nhạc trong file MIDI.', 'error'); return; } var colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309', '#ca8a04', '#dc2626', '#0891b2']; var now = Date.now(); var newTracks = midiResult.map(function(midiItem, idx) { return { id: 'midi_track_' + now + '_' + idx, name: midiItem.name || (midiResult.length > 1 ? 'MIDI Track ' + (idx + 1) : (file.name || 'MIDI').replace(/\.midi?$/i, '')), buffer: null, startTime: 0, volumeDb: 0, pan: 0, muted: false, solo: false, color: colors[idx % colors.length], markers: [], serverFileId: null, clips: [], sections: [], midiItems: [midiItem], isArmed: false, monitoringEnabled: true, inputSource: { deviceType: 'NONE', deviceId: '' } }; }); updateActiveTracks(function(prev) { return prev.concat(newTracks); }); if (newTracks.length > 0) setSelectedTrackId(newTracks[0].id); showToast('Đã tải MIDI: ' + newTracks.length + ' track(s) từ ' + file.name, 'success'); } catch (err) { showToast('Lỗi giải mã MIDI.', '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); } } updateActiveTracks(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 rearrangeNewId = (curTracks.length + 1).toString(); const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; const selectColor = colors[curTracks.length % colors.length]; updateActiveTracks(prev => [...prev, { id: rearrangeNewId, name: `Track ${rearrangeNewId}`, 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 ${rearrangeNewId}.`, 'info'); setTimeout(() => lucide.createIcons(), 200); return rearrangeNewId; }; // ── Update tracks in active context (main session or section tab) ── const updateActiveTracksRef = useCallback(updater => { const currentSessionTabs = sessionTabsRef.current; const currentActiveTab = activeTabRef.current; var st = currentSessionTabs.find(s => s.id === currentActiveTab); if (st) { setSessionTabs(prev => prev.map(s => s.id === currentActiveTab ? { ...s, tracks: updater(s.tracks), isDirty: true } : s)); return; } var pr = subTabsRef.current ? subTabsRef.current.find(s => s.id === currentActiveTab && s.type === 'PIANO_ROLL') : null; if (pr && pr.parent_tab_id && pr.parent_tab_id.startsWith('session_')) { var parentSt = currentSessionTabs.find(s => s.id === pr.parent_tab_id); if (parentSt) { setSessionTabs(prev => prev.map(s => s.id === pr.parent_tab_id ? { ...s, tracks: updater(s.tracks), isDirty: true } : s)); return; } } 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 } }; })); }; // ── MIDI Export: tạo file MIDI từ tất cả tracks ── const triggerMidiExport = () => { const bpmNum = parseFloat(bpm) || 120; const ppq = 480; // Pulses Per Quarter Note const ticksPerBeat = ppq; const beatDuration = 60 / bpmNum; // Build MIDI tracks let midiTracks = []; let currentTrackNum = 0; tracks.forEach(track => { currentTrackNum++; const events = []; let hasNotes = false; // Collect MIDI events from midiItems const midiItems = track.midiItems || []; midiItems.forEach(item => { const notes = item.notes || []; notes.forEach(note => { hasNotes = true; const startTick = Math.round(note.startBeat * ticksPerBeat); const durTick = Math.round(note.durationBeats * ticksPerBeat); const velocity = Math.round((note.velocity || 0.8) * 100); const pitch = note.pitch || 60; events.push({ tick: startTick, type: 'note_on', pitch, velocity }); events.push({ tick: startTick + durTick, type: 'note_off', pitch, velocity: 0 }); }); }); // If track has audio buffer but no MIDI, create a "rest" track with one silent note if (!hasNotes && track.buffer) { const durSec = track.buffer.duration; const durBeats = durSec / beatDuration; const durTicks = Math.round(durBeats * ticksPerBeat); // Use a C-2 (pitch 0 = rest note) indicator events.push({ tick: 0, type: 'note_on', pitch: 0, velocity: 1 }); events.push({ tick: durTicks, type: 'note_off', pitch: 0, velocity: 0 }); } if (events.length === 0 && !track.buffer) return; // Skip empty tracks // Sort events by tick events.sort((a, b) => a.tick - b.tick); // MIDI track header bytes const trackBytes = []; // Track name const nameStr = (track.name || ('Track ' + currentTrackNum)).slice(0, 255); trackBytes.push(0xFF, 0x03, nameStr.length); for (let i = 0; i < nameStr.length; i++) trackBytes.push(nameStr.charCodeAt(i)); // End of track marker will be calculated later let lastTick = 0; events.forEach(ev => { const delta = ev.tick - lastTick; lastTick = ev.tick; // Delta time as variable-length quantity writeVLQ(trackBytes, delta); if (ev.type === 'note_on') { trackBytes.push(0x90, ev.pitch, ev.velocity); } else { trackBytes.push(0x80, ev.pitch, 0); } }); // End of track writeVLQ(trackBytes, 0); trackBytes.push(0xFF, 0x2F, 0x00); // Track chunk: "MTrk" + length + data const trackData = [0x4D, 0x54, 0x72, 0x6B]; // "MTrk" const len = trackBytes.length; trackData.push((len >> 24) & 0xFF, (len >> 16) & 0xFF, (len >> 8) & 0xFF, len & 0xFF); trackData.push(...trackBytes); midiTracks.push(trackData); }); if (midiTracks.length === 0) { showToast("Không có dữ liệu MIDI nào để xuất.", "warning"); return; } // Header: "MThd" + length(6) + format(1) + tracks + division const header = [0x4D, 0x54, 0x68, 0x64, 0x00, 0x00, 0x00, 0x06, 0x00, 0x01, (midiTracks.length >> 8) & 0xFF, midiTracks.length & 0xFF, (ppq >> 8) & 0xFF, ppq & 0xFF]; const allBytes = header.concat(...midiTracks.flat()); const uint8 = new Uint8Array(allBytes); const blob = new Blob([uint8], { type: 'audio/midi' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = (projectName || 'Project') + '.mid'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); showToast(`Đã xuất file MIDI với ${midiTracks.length} tracks!`, "success"); }; function writeVLQ(bytes, value) { if (value < 0) value = 0; const buf = []; buf.push(value & 0x7F); while (value > 0x7F) { value >>= 7; buf.push(0x80 | (value & 0x7F)); } buf.reverse(); buf.forEach(b => bytes.push(b)); } // ── Insert Track Below Selected ── const insertTrackBelow = () => { const curTracks = activeTracks; const rearrangeNewId = `t${Date.now()}`; const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; const selectColor = colors[(curTracks.length) % colors.length]; const newTrack = { id: rearrangeNewId, name: `Track ${rearrangeNewId}`, 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(rearrangeNewId); showToast(`Đã thêm Track ${rearrangeNewId}.`, 'info'); setTimeout(() => lucide.createIcons(), 200); }; // ── Insert Section at Playhead ── const insertSectionAtPlayhead = () => { const curTracks = activeTracks; const track = curTracks.find(t => t.id === selectedTrackId); if (!track) { showToast('Chọn track trước', 'warning'); return; } const secondsPerBar = (60.0 / (parseInt(bpm) || 120)) * 4; const section = { id: `sec_${Date.now()}`, name: 'Section', start: currentTime, duration: 4 * secondsPerBar, length_bars: 4, color: track.color || '#06b6d4' }; createSectionWithUndo(selectedTrackId, section); updateActiveTracks(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 secondsPerBar = (60.0 / (parseInt(bpm) || 120)) * 4; const midiItem = { id: `midi_${Date.now()}`, name: 'MIDI Item', startTime: currentTime, duration: 4 * secondsPerBar, length_bars: 4, notes: [], color: '#a78bfa' }; createMidiWithUndo(selectedTrackId, midiItem); 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 { const uploadResult = await uploadToServer(file, track.id); const fileId = uploadResult?.file_id || null; 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, serverFileId: fileId }; createClipWithUndo(selectedTrackId, newClip); updateActiveTracks(prev => prev.map(t => t.id === selectedTrackId ? { ...t, serverFileId: t.serverFileId || fileId, 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 handleSaveLocalProject = (name) => { const finalName = name || projectName || 'Dự án mới'; const localId = currentProjectId && currentProjectId.startsWith('local_') ? currentProjectId : 'local_' + Date.now(); const projectSchemaObj = serializeProjectToSchema(localId, finalName, bpm, tracks, subTabs, sessionTabs, masteringSettings); const dataStr = JSON.stringify(projectSchemaObj); localStorage.setItem('sonic_local_project_data', dataStr); localStorage.setItem('sonic_project_id', localId); localStorage.setItem('sonic_project_name', finalName); setCurrentProjectId(localId); setProjectName(finalName); window.SonicStorage.exportProjectToSFS(projectSchemaObj); showToast(`Đã lưu dự án local "${finalName}" thành công!`, "success"); }; const handleSaveCloudProject = async (name, existingProjectId) => { const finalName = name || projectName || 'Dự án mới'; var useProjectId = existingProjectId || currentProjectId; const projectSchemaObj = serializeProjectToSchema(useProjectId || 'project_' + Date.now(), finalName, bpm, tracks, subTabs, sessionTabs, masteringSettings); // Diagnostic: count source vs serialized items var srcMidi = 0, srcClip = 0, srcSec = 0; tracks.forEach(function(t) { srcMidi += (t.midiItems||[]).length; srcClip += (t.clips||[]).length; srcSec += (t.sections||[]).length; }); var serItems = 0; if (projectSchemaObj.main_session && projectSchemaObj.main_session.tracks) { projectSchemaObj.main_session.tracks.forEach(function(st) { if (st.items) serItems += st.items.length; }); } Object.keys(projectSchemaObj.section_store || {}).forEach(function(sk) { (projectSchemaObj.section_store[sk].tracks || []).forEach(function(st) { if (st.items) serItems += st.items.length; }); }); const dataJson = JSON.stringify(projectSchemaObj); try { if (existingProjectId) { await window.SonicAPI.updateCloudProject(existingProjectId, finalName, dataJson); setProjectName(finalName); setCurrentProjectId(existingProjectId); localStorage.setItem('sonic_project_name', finalName); localStorage.setItem('sonic_project_id', existingProjectId); showToast('Đã ghi đè Cloud "' + finalName + '" (src midi=' + srcMidi + ' clip=' + srcClip + ' sec=' + srcSec + ' | ser=' + serItems + ').', 'success'); } else if (currentProjectId && !currentProjectId.startsWith('local_')) { await window.SonicAPI.updateCloudProject(currentProjectId, finalName, dataJson); setProjectName(finalName); localStorage.setItem('sonic_project_name', finalName); localStorage.setItem('sonic_project_id', currentProjectId); showToast('Đã lưu Cloud "' + finalName + '" (src midi=' + srcMidi + ' clip=' + srcClip + ' sec=' + srcSec + ' | ser=' + serItems + ' id=' + currentProjectId + ').', serItems === 0 ? 'warning' : 'success'); } else { const res = await window.SonicAPI.saveCloudProject(finalName, dataJson); const newProjId = res.project_id; setProjectName(finalName); localStorage.setItem('sonic_project_name', finalName); if (newProjId) { setCurrentProjectId(newProjId); localStorage.setItem('sonic_project_id', newProjId); } showToast('Đã lưu Cloud mới "' + finalName + '" (src midi=' + srcMidi + ' clip=' + srcClip + ' sec=' + srcSec + ' | ser=' + serItems + ').', serItems === 0 ? 'warning' : 'success'); } } catch (err) { showToast(err.message || "Lỗi lưu dự án lên Cloud", "error"); } }; const handleSaveProject = async () => { if (!currentProjectId) { setSaveProjectModalOpen(true); return; } if (currentProjectId.startsWith('local_')) { handleSaveLocalProject(projectName); } else { if (!currentUser) { showToast('Cần đăng nhập để lưu Cloud. currentUser=' + (currentUser ? 'OK' : 'NULL') + ' token=' + (localStorage.getItem('sonic_token') ? 'exists' : 'missing'), 'warning'); return; } handleSaveCloudProject(projectName); } }; const handleSaveProjectRef = useRef(handleSaveProject); handleSaveProjectRef.current = handleSaveProject; const handleSaveAsCloud = async (newName) => { const projectSchemaObj = serializeProjectToSchema('project_' + Date.now(), newName, bpm, tracks, subTabs, sessionTabs, masteringSettings); 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, masteringSettings); 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]); let restored = []; let restoredBpm = bpm; let restoredSessionTabs = []; let restoredSubTabs = []; if (proj.main_session) { const result = deserializeProjectFromSchema(proj); restored = result.tracks; restoredBpm = result.bpm; restoredSessionTabs = result.sessionTabs; restoredSubTabs = result.subTabs; if (result.masteringSettings) setMasteringSettings(result.masteringSettings); } else { 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); loadAudioBuffersForTracks(restored); setBpm(restoredBpm.toString()); setProjectName(proj.name || 'Dự án mới'); setCurrentProjectId(null); if (restoredSessionTabs.length > 0) setSessionTabs(restoredSessionTabs); if (restoredSubTabs.length > 0) setSubTabs(restoredSubTabs); localStorage.setItem('sonic_project_name', proj.name || 'Dự án mới'); localStorage.removeItem('sonic_project_id'); showToast(`Đã nạp dự án "${proj.name || 'Dự án mới'}" 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 rearrangeNewId = '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: rearrangeNewId, 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_' + rearrangeNewId, 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(rearrangeNewId); 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; } // Check if piano roll tab is active → route prompt to MIDI generation const activePianoRoll = subTabs.find(s => s.id === activeTab && s.type === 'PIANO_ROLL'); if (activePianoRoll) { setAiProcessing(true); setAiActionLog(prev => [...prev, { type: 'status', text: ` ⏳ Đang gửi prompt đến AI cho Piano Roll...`, 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}`, time: Date.now() }]); let pianoSystemInstruction = 'You are a MIDI composer. Output ONLY valid JSON array of notes. No markdown, no explanation. Use 4/4 time. pitch=C4=60, D4=62, E4=64, F4=65, G4=67, A4=69, B4=71, C5=72.'; const result = await window.AIGateway.executeAIPrompt({ prompt: 'Generate MIDI notes for a piano roll. Return ONLY a JSON array: [{pitch(0-127), start_beat, duration_beats, velocity(0.0-1.0)}]. ' + prompt, provider: provider.name || 'default', model: model, apiKey: apiKey, baseUrl: baseUrl.replace(/\/chat\/completions$/, '').replace(/\/$/, ''), systemInstruction: pianoSystemInstruction }); if (!result) throw new Error('AI không phản hồi'); let notesData = null; const textResp = result.textResponse || result.text || ''; if (textResp && typeof textResp === 'string') { try { const cleaned = textResp.replace(/```json?\s*/g, '').replace(/```/g, '').trim(); notesData = JSON.parse(cleaned); } catch (e1) { console.error('Parse notes error:', e1); } } if (!notesData && result.functionCalls) { for (const fc of result.functionCalls) { if (fc.arguments && fc.arguments.notes) { notesData = fc.arguments.notes; break; } } } if (Array.isArray(notesData) && notesData.length > 0) { const newNotes = notesData.map((n, i) => ({ id: 'note_ai_' + Date.now() + '_' + i, pitch: Math.max(0, Math.min(127, n.pitch || 60)), start_beat: Math.max(0, parseFloat(n.start_beat) || 0), duration_beats: Math.max(0.125, parseFloat(n.duration_beats) || 0.25), velocity: Math.max(0.1, Math.min(1.0, n.velocity ?? 0.8)), pan: 0.0 })); setSubTabs(prev => prev.map(s => s.id === activePianoRoll.id ? { ...s, notes: [...(s.notes || []), ...newNotes], isDirty: true } : s)); setAiActionLog(prev => [...prev, { type: 'status', text: ` ✅ Đã thêm ${newNotes.length} notes vào Piano Roll`, time: Date.now() }]); setCanvasRedrawCount(n => n + 1); } else { setAiActionLog(prev => [...prev, { type: 'error', text: ` ❌ AI không trả về notes hợp lệ.`, time: Date.now() }]); } } catch (err) { setAiActionLog(prev => [...prev, { type: 'error', text: ` ❌ Lỗi: ${err.message}`, time: Date.now() }]); } setAiProcessing(false); return; } // ── MIDI Rearrange Flow: selected MIDI item → use rearrange tool ── if (selectedItemIds && selectedItemIds.size === 1) { const selId = selectedItemIds.values().next().value; let isMidiItem = false; let sourceTrackId = null; let sourceItemName = null; for (const t of activeTracks || []) { const found = (t.midiItems || []).find(m => m.id === selId); if (found) { isMidiItem = true; sourceTrackId = t.id; sourceItemName = found.name; break; } } if (isMidiItem && window.SonicMidiExtractor) { try { setAiProcessing(true); setAiActionLog(prev => [...prev, { type: 'status', text: ` 🎯 Phát hiện MIDI item được chọn — chuyển sang chế độ Rearrange...`, time: Date.now() }]); if (aiProviders.length === 0 || !selectedProviderId) { try { const d = await window.SonicAPI.getAIConfigs(); if (d && d.providers && d.providers.length > 0) { setAiProviders(d.providers); const a = d.providers.find(p => p.is_active) || d.providers[0]; if (a) setSelectedProviderId(a.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: ` Rearrange Provider: ${provider.name || 'default'} | Model: ${model}`, time: Date.now() }]); const srcContext = window.SonicMidiExtractor.extractSelectedMIDIContext(activeTracks || tracks, selId, bpm); setAiActionLog(prev => [...prev, { type: 'info', text: ` 📋 Trích xuất ${srcContext.total_notes} notes từ "${srcContext.item_name}" (${srcContext.track_name})`, time: Date.now() }]); if (window.DAWCommandDispatcher) { window.DAWCommandDispatcher.rearrangeSourceTrackId = sourceTrackId; window.DAWCommandDispatcher.rearrangeSourceItemName = sourceItemName; } const messages = window.AIGateway.buildRearrangeMessage(prompt, srcContext); const completion = await window.AIGateway.callLLM({ provider: provider.name || 'default', model, apiKey, baseUrl: baseUrl.replace(/\/chat\/completions$/, '').replace(/\/$/, ''), messages, tools: [window.AIGateway.REARRANGE_TOOL_SPEC.function], toolChoice: 'auto' }); const functionCalls = window.AIGateway.extractFunctionCalls(completion); if (functionCalls && functionCalls.length > 0) { for (const fc of 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}: thành công — ${fc.arguments.rearrange_title || ''}`, 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: ` ❌ AI không trả về lệnh rearrange hợp lệ.`, time: Date.now() }]); } const textResp = functionCalls.length === 0 && completion.choices && completion.choices[0] && completion.choices[0].message && completion.choices[0].message.content; if (textResp) { setAiActionLog(prev => [...prev, { type: 'status', text: ` AI: ${textResp.slice(0, 500)}`, 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 Rearrange.`, time: Date.now() }]); setAiPrompt(''); setTimeout(() => lucide.createIcons(), 200); setAiProcessing(false); return; } catch (err) { setAiActionLog(prev => [...prev, { type: 'error', text: ` Lỗi Rearrange: ${err.message}`, time: Date.now() }]); showToast(`AI Rearrange Error: ${err.message}`, 'error'); setAiProcessing(false); return; } } } if (window.DAWCommandDispatcher) { window.DAWCommandDispatcher.currentSelectedTrackId = selectedTrackId; window.DAWCommandDispatcher.currentTracks = activeTracks; 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: activeTracks, bpm, selectedTrackId, currentTime, selLeft, selRight }); let matchedInstruction = ''; if (aiPromptMgrRef.current) { const matchResult = aiPromptMgrRef.current.matchPreset(prompt); if (matchResult && matchResult.preset) { matchedInstruction = matchResult.preset.system_instruction_template; setAiActionLog(prev => [...prev, { type: 'info', text: ` Khớp với preset: "${matchResult.preset.name}"`, time: Date.now() }]); } } 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, systemInstruction: matchedInstruction }); 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); }; handleSplitTrackRef.current = handleSplitTrack; // ── 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 rearrangeNewId = addNewTrack(); if (name && name !== `AI_Track_${Date.now()}`) { updateTrackName(rearrangeNewId, name); } return { success: true, trackId: rearrangeNewId, 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 rearrangeNewId = '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: rearrangeNewId, name: cutName, buffer: slicedBuffer, startTime: 0, volumeDb: 0, pan: 0, muted: false, solo: false, color, markers: [], clips: [{ id: 'clip_' + rearrangeNewId, 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 = rearrangeNewId; window.DAWCommandDispatcher.lastCutSourceTrackId = tid; window.DAWCommandDispatcher.lastCutNewTrackId = rearrangeNewId; } setTracks(nextTracks); setSelectedTrackId(rearrangeNewId); selectionRef.current = { start: 0, end: slicedBuffer.duration }; clearLocalSelection(); setSelectionMode('global'); setSelectionStart(0); setSelectionEnd(slicedBuffer.duration); setTimeout(() => lucide.createIcons(), 200); return { success: true, trackId: rearrangeNewId, 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 && (!track.midiItems || track.midiItems.length === 0)) return { success: false, error: 'Track has no audio clips' }; const beatsPerSec = parseFloat(bpm || 120) / 60; const totalDuration = Math.max( clips.length > 0 ? Math.max(...clips.map(c => (c.startTime || 0) + (c.buffer ? c.buffer.duration / (c.speed || 1.0) : 0))) : 0, ...(track.midiItems || []).map(m => (m.startTime || 0) + (m.duration || 4)), ...(track.sections || []).map(s => (s.start || 0) + (s.duration || 4)) ); 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 }; }, generateMultitrackMidi: (args) => { const { composition_title, bpm: aiBpm, total_bars, tracks: aiTracks } = args; if (aiBpm) { setBpm(aiBpm.toString()); } const bpmVal = aiBpm || parseInt(bpm) || 120; const secondsPerBeat = 60.0 / bpmVal; const secondsPerBar = secondsPerBeat * 4; const durationSec = total_bars * secondsPerBar; updateActiveTracks(prev => { let updatedTracks = [...prev]; aiTracks.forEach(aiTrack => { let targetTrack = updatedTracks.find( t => t.name.toLowerCase() === aiTrack.track_name.toLowerCase() ); if (!targetTrack) { const rearrangeNewId = (updatedTracks.length + 1).toString(); const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; const selectColor = colors[updatedTracks.length % colors.length]; targetTrack = { id: rearrangeNewId, name: aiTrack.track_name, type: 'MIDI', volumeDb: 0, pan: 0, muted: false, solo: false, color: selectColor, markers: [], serverFileId: null, clips: [], sections: [], midiItems: [], isArmed: false, monitoringEnabled: true, inputSource: { deviceType: 'NONE', deviceId: '' } }; updatedTracks.push(targetTrack); } // Place MIDI item at current playhead position const itemStartTimeSec = currentTime; const newMidiItem = { id: 'item_ai_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5), name: `${composition_title || 'AI Theme'} - ${aiTrack.track_name}`, parent_track_id: targetTrack.id, startTime: itemStartTimeSec, duration: durationSec, length_bars: total_bars, notes: aiTrack.notes.map((note, index) => ({ id: `note_ai_${Date.now()}_${index}`, pitch: note.pitch, start_beat: note.start_beat, duration_beats: note.duration_beats, velocity: note.velocity || 0.8, pan: 0.0 })) }; targetTrack.midiItems = [...(targetTrack.midiItems || []), newMidiItem]; if (aiTrack.soundfont_bank !== undefined && aiTrack.soundfont_program !== undefined) { targetTrack.soundfont_bank = aiTrack.soundfont_bank; targetTrack.soundfont_program = aiTrack.soundfont_program; targetTrack.soundfont_id = aiTrack.soundfont_id || ''; const sfEngine = { type: 'soundfont', plugin_id: aiTrack.soundfont_id ? 'sf_' + aiTrack.soundfont_id : null, soundfont_bank: aiTrack.soundfont_bank, soundfont_program: aiTrack.soundfont_program, soundfont_id: aiTrack.soundfont_id || '' }; targetTrack.synth_engine = sfEngine; if (window.SonicSF && window.SonicSF.applyAITrackInstrument) { window.SonicSF.applyAITrackInstrument(aiTrack.soundfont_bank, aiTrack.soundfont_program, sfEngine); } } }); return updatedTracks; }); showToast(`Đã nạp ${aiTracks.length} tracks MIDI thế hệ AI!`, 'success'); return { success: true }; }, rearrangeMidiMelody: (args) => { const { rearrange_title, rearranged_notes, soundfont_id, soundfont_bank, soundfont_program } = args; if (!rearranged_notes || rearranged_notes.length === 0) { return { success: false, error: 'No rearranged notes provided' }; } const sourceTrackId = window.DAWCommandDispatcher?.rearrangeSourceTrackId || selectedTrackId; const sourceItemName = window.DAWCommandDispatcher?.rearrangeSourceItemName || 'Source'; const bpmVal = parseInt(bpm) || 120; const secondsPerBeat = 60.0 / bpmVal; const secondsPerBar = secondsPerBeat * 4; const totalBeats = rearranged_notes.reduce((max, n) => Math.max(max, (n.start_beat || 0) + (n.duration_beats || 1)), 0); const totalBars = Math.max(1, Math.ceil(totalBeats / 4)); const durationSec = totalBars * secondsPerBar; const rearrangeNewId = (activeTracks.length + 1).toString(); updateActiveTracks(prev => { let updatedTracks = [...prev]; // Find source track index to insert new track right after it const sourceIdx = sourceTrackId ? updatedTracks.findIndex(t => t.id === sourceTrackId || t.id === 'track_' + sourceTrackId) : -1; const insertIdx = sourceIdx >= 0 ? sourceIdx + 1 : updatedTracks.length; const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309']; const newTrack = { id: rearrangeNewId, name: `[AI Rearrange] ${rearrange_title || 'Variation'}`, type: 'MIDI', volumeDb: 0, pan: 0, muted: false, solo: false, color: colors[insertIdx % colors.length], markers: [], serverFileId: null, clips: [], sections: [], midiItems: [{ id: 'item_rearr_' + Date.now() + '_' + Math.random().toString(36).substr(2, 5), name: `[AI] ${rearrange_title || 'Rearranged'} - var`, parent_track_id: rearrangeNewId, startTime: currentTime, duration: durationSec, length_bars: totalBars, notes: rearranged_notes.map((n, i) => ({ id: `note_rearr_${Date.now()}_${i}`, pitch: Math.max(0, Math.min(127, n.pitch || 60)), start_beat: Math.max(0, parseFloat(n.start_beat) || 0), duration_beats: Math.max(0.125, parseFloat(n.duration_beats) || 0.25), velocity: Math.max(0.1, Math.min(1.0, n.velocity ?? 0.8)), pan: 0.0 })) }] }; if (soundfont_id || (soundfont_bank !== undefined && soundfont_program !== undefined)) { newTrack.soundfont_id = soundfont_id || ''; newTrack.soundfont_bank = soundfont_bank ?? 0; newTrack.soundfont_program = soundfont_program ?? 0; newTrack.synth_engine = { type: 'soundfont', plugin_id: soundfont_id ? 'sf_' + soundfont_id : null, soundfont_bank: soundfont_bank ?? 0, soundfont_program: soundfont_program ?? 0, soundfont_id: soundfont_id || '' }; } updatedTracks.splice(insertIdx, 0, newTrack); return updatedTracks; }); showToast(`✅ AI Rearrange: "${rearrange_title}" — ${rearranged_notes.length} notes`, 'success'); return { success: true, trackId: rearrangeNewId, totalBars }; }, createMidiItem: (args) => { const trackId = args.track_id || selectedTrackId; if (!trackId) return { success: false, error: 'No track_id provided' }; const secondsPerBar = (60.0 / (parseInt(bpm) || 120)) * 4; const startBar = args.start_bar !== undefined ? parseFloat(args.start_bar) : 0; const lengthBars = args.length_bars !== undefined ? parseFloat(args.length_bars) : 4; const midiItem = { id: `midi_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`, name: 'MIDI Item', parent_track_id: trackId, startTime: startBar * secondsPerBar, duration: lengthBars * secondsPerBar, notes: [], color: '#a78bfa' }; updateActiveTracks(prev => prev.map(t => { if (t.id !== trackId && t.id !== 'track_' + trackId) return t; return { ...t, midiItems: [...(t.midiItems || []), midiItem] }; })); return { success: true, itemId: midiItem.id, trackId }; }, modifyMidiNotes: (args) => { const itemId = args.item_id; if (!itemId) return { success: false, error: 'No item_id provided' }; const noteNameToMidi = (name) => { if (typeof name === 'number') return name; if (!name || typeof name !== 'string') return 60; const match = name.match(/^([A-Ga-g]#?|b?)(-?\d+)$/); if (!match) { const num = parseInt(name); return isNaN(num) ? 60 : num; } const noteNames = { 'c': 0, 'c#': 1, 'db': 1, 'd': 2, 'd#': 3, 'eb': 3, 'e': 4, 'f': 5, 'f#': 6, 'gb': 6, 'g': 7, 'g#': 8, 'ab': 8, 'a': 9, 'a#': 10, 'bb': 10, 'b': 11 }; const key = match[1].toLowerCase(); const octave = parseInt(match[2]); const base = noteNames[key] !== undefined ? noteNames[key] : 0; return (octave + 1) * 12 + base; }; const newNotes = (args.notes || []).map((n, index) => ({ id: `note_mod_${Date.now()}_${index}`, pitch: noteNameToMidi(n.pitch), start_beat: parseFloat(n.start_time || 0), duration_beats: parseFloat(n.duration || 1), velocity: n.velocity !== undefined ? n.velocity / 127.0 : 0.8, pan: 0.0 })); updateActiveTracks(prev => prev.map(t => { const items = t.midiItems || []; const exists = items.some(m => m.id === itemId); if (!exists) return t; return { ...t, midiItems: items.map(m => m.id === itemId ? { ...m, notes: newNotes } : m) }; })); return { success: true, itemId }; } }; 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]); const drawMixerVuMeter = (canvas, peak) => { 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); ctx.fillStyle = '#0d0d0d'; ctx.fillRect(0, 0, w, h); const barH = peak * h; const grad = ctx.createLinearGradient(0, h, 0, 0); grad.addColorStop(0, '#22c55e'); grad.addColorStop(0.5, '#eab308'); grad.addColorStop(0.85, '#ef4444'); ctx.fillStyle = grad; ctx.fillRect(0, h - barH, w, barH); }; // Master & Track VU meter animation loop const masterVUAnimRef = useRef(null); useEffect(() => { function tick() { // 1. Master VU Meter if (masterBus && masterBus.analyser) { const data = new Uint8Array(128); masterBus.analyser.getByteTimeDomainData(data); let peak = 0; for (let i = 0; i < data.length; i++) { const v = Math.abs(data[i] - 128) / 128; if (v > peak) peak = v; } setMasterVU(peak); setMasterMeterPeak(prev => Math.max(prev * 0.97, peak)); } // 2. Track VU Meters const trackNodes = activeTrackNodesRef.current || {}; const activeKeys = Object.keys(trackVuRefs.current); activeKeys.forEach(key => { const trackId = key.replace('_mixer', ''); const isRecordingThisTrack = activeAudioRecordersRef.current && activeAudioRecordersRef.current[trackId]; if (isRecordingThisTrack) return; const node = trackNodes[trackId]; const canvas = trackVuRefs.current[key]; if (!canvas) return; // Only animate when real audio actually passes through the track (solo/mute aware) var vuTracks = activeTracksRef.current || []; var anySoloVU = vuTracks.some(function(t) { return t.solo; }); var vuTrack = vuTracks.find(function(t) { return t.id === trackId; }); var isAudible = vuTrack ? (anySoloVU ? !!vuTrack.solo : !vuTrack.muted) : true; let audioPeak = 0; if (isAudible && node && node.analyserNode) { const analyser = node.analyserNode; const data = new Uint8Array(128); analyser.getByteTimeDomainData(data); for (let i = 0; i < data.length; i++) { const v = Math.abs(data[i] - 128) / 128; if (v > audioPeak) audioPeak = v; } } let midiPeak = isPlaying && isAudible ? (midiVuActivityRef.current[trackId] || 0) : 0; if (midiPeak > 0) { midiVuActivityRef.current[trackId] = midiPeak * 0.90; if (midiVuActivityRef.current[trackId] < 0.01) { midiVuActivityRef.current[trackId] = 0; } } const peak = Math.max(audioPeak, midiPeak); const db = peak > 0 ? 20 * Math.log10(peak) : -60; if (peak > 0.001) { if (key.endsWith('_mixer')) { drawMixerVuMeter(canvas, peak); } else { drawVuMeter(canvas, db); } } else { if (key.endsWith('_mixer')) { drawMixerVuMeter(canvas, 0); } else { drawVuMeter(canvas, -60); } } }); masterVUAnimRef.current = requestAnimationFrame(tick); } masterVUAnimRef.current = requestAnimationFrame(tick); return () => { if (masterVUAnimRef.current) cancelAnimationFrame(masterVUAnimRef.current); }; }, [isPlaying]); 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: () => setOpenProjectModalOpen(true) }, { 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 rearrangeNewId = (tracks.length + 1).toString(); setTimeout(() => loadFileOnTrack(rearrangeNewId, e.target.files[0]), 100); } }; input.click(); showToast('Import audio', 'info'); } }, { label: 'Export Mix...', icon: 'file-output', action: () => triggerWavExport() }, { label: 'Export MIDI...', icon: 'music', action: () => triggerMidiExport() }, { label: 'Mastering Suite', icon: 'wand-2', shortcut: 'Ctrl+Shift+M', action: () => setShowMasteringModal(true) }, { 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: () => setShowMixer(p => !p) }, { 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: 'AI MIDI Preset Manager...', icon: 'sliders', action: () => setAiPresetModalOpen(true) }, { label: 'DSP Tools Panel', icon: 'wrench', action: () => openPanel('python_tools') }, { sep: true }, { label: 'Plugin Manager (SoundFont/VSTi)', icon: 'zap', action: () => { setPluginManagerModalOpen(true); window.SonicAPI.listPlugins().then(data => setPluginsData(data)).catch(() => {}); } }] }, { 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 => { const isTabActive = activeTab === st.id; const tabColor = st.color || (isTabActive ? '#06b6d4' : null); const borderStyle = isTabActive ? { borderBottomColor: tabColor, color: tabColor } : (st.color ? { color: st.color, borderBottomColor: 'transparent' } : {}); return /*#__PURE__*/React.createElement("div", { key: st.id, className: "flex items-stretch" }, /*#__PURE__*/React.createElement("button", { onClick: () => setActiveTab(st.id), onAuxClick: e => { if (e.button === 1) { e.preventDefault(); closeSessionTab(st.id); } }, onContextMenu: e => { e.preventDefault(); setTabContextMenu({ x: e.clientX, y: e.clientY, tabId: st.id, tabType: 'session' }); }, style: borderStyle, className: `px-2 text-xs font-medium border-b-2 transition flex items-center gap-1 ${isTabActive ? '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": "layers", className: "w-3 h-3" })), /*#__PURE__*/React.createElement("span", { className: "max-w-[120px] truncate" }, st.name), st.isDirty && /*#__PURE__*/React.createElement("span", { className: "w-1.5 h-1.5 rounded-full bg-amber-500 ml-1", title: "Chưa lưu thay đổi" })), /*#__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 => { const isTabActive = activeTab === st.id; const tabColor = st.color || (isTabActive ? '#f59e0b' : null); const borderStyle = isTabActive ? { borderBottomColor: tabColor, color: tabColor } : (st.color ? { color: st.color, borderBottomColor: 'transparent' } : {}); const iconName = st.type === 'PIANO_ROLL' ? 'music' : 'file-edit'; return /*#__PURE__*/React.createElement("div", { key: st.id, className: "flex items-stretch" }, /*#__PURE__*/React.createElement("button", { onClick: () => setActiveTab(st.id), onAuxClick: e => { if (e.button === 1) { e.preventDefault(); closeSubTab(st.id); } }, onContextMenu: e => { e.preventDefault(); setTabContextMenu({ x: e.clientX, y: e.clientY, tabId: st.id, tabType: 'sub' }); }, style: borderStyle, className: `px-2 text-xs font-medium border-b-2 transition flex items-center gap-1 ${isTabActive ? '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": iconName, className: "w-3 h-3" })), /*#__PURE__*/React.createElement("span", { className: "max-w-[100px] truncate" }, st.label), st.isDirty && /*#__PURE__*/React.createElement("span", { className: "w-1.5 h-1.5 rounded-full bg-amber-500 ml-1", title: "Chưa lưu thay đổi" })), /*#__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")), sessionTabs.some(s => s.id === activeTab) && /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { className: "w-[1px] h-5 bg-zinc-700 mx-0.5" }), /*#__PURE__*/React.createElement("button", { onClick: () => handleSaveSectionTab(activeTab), className: "px-2 py-1 bg-emerald-600 hover:bg-emerald-500 text-white font-semibold rounded text-xs flex items-center gap-1 shadow transition", title: "Lưu Section vào Main Session" }, /*#__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" })), /*#__PURE__*/React.createElement("span", null, "Lưu Section"))), /*#__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 && (!window.UndoRedoEngine || !window.UndoRedoEngine.canUndo()), 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 && (!window.UndoRedoEngine || !window.UndoRedoEngine.canRedo()), 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: () => seekPlaybackTo(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: () => { const isSubTab = subTabs.some(sub => sub.id === activeTab); if (isSubTab) { const st = subTabs.find(s => s.id === activeTab); if (!st) return; const left = st.selectionStart !== null && st.selectionEnd !== null ? Math.min(st.selectionStart, st.selectionEnd) : null; if (left !== null) seekPlaybackTo(left); } else { if (selLeft !== null) seekPlaybackTo(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: () => { const isSubTab = subTabs.some(sub => sub.id === activeTab); if (isSubTab) { 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: () => { const isSubTab = subTabs.some(sub => sub.id === activeTab); if (isSubTab) { 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); // Sync loop state with active sub-tab (piano roll / audio editor) const activeSub = activeTab && subTabs.find(s => s.id === activeTab && ['PIANO_ROLL', 'AUDIO_CLIP_EDITOR', 'SECTION_EDITOR'].includes(s.type)); if (activeSub) { const newLoop = !activeSub.isLooping; setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, isLooping: newLoop } : s)); if (newLoop) { const bpmVal = parseInt(bpm) || 120; const beatSec = 60.0 / bpmVal; let maxEnd = 0; if (activeSub.type === 'PIANO_ROLL') { (activeSub.notes || []).forEach(n => { const end = (n.start_beat || 0) + (n.duration_beats || 1); if (end > maxEnd) maxEnd = end; }); } else if (activeSub.type === 'SECTION_EDITOR') { (activeSub.clips || []).forEach(c => { const end = (c.startTime || 0) + (c.duration || 0); if (end > maxEnd) maxEnd = end; }); } else if (activeSub.type === 'AUDIO_CLIP_EDITOR') { const dur = activeSub.buffer?.duration || 0; if (dur > maxEnd) maxEnd = dur; } const loopEndTime = activeSub.type === 'PIANO_ROLL' ? Math.max(maxEnd, 16) * beatSec + 1.0 : Math.max(maxEnd, 1); setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, selectionStart: 0, selectionEnd: loopEndTime } : s)); } } else { // Main timeline: auto-derive loop end from tracks const bpmVal = parseInt(bpm) || 120; const secPerBar = (60.0 / bpmVal) * 4; let maxEnd = 0; activeTracks.forEach(t => { (t.clips || []).forEach(c => { const end = (c.startTime || 0) + (c.duration || 0); if (end > maxEnd) maxEnd = end; }); (t.items || []).forEach(it => { const end = (it.start || 0) + (it.duration || 4); if (end > maxEnd) maxEnd = end; }); }); if (maxEnd > 0) { const loopEnd = maxEnd + secPerBar * 2; setSelectionStart(0); setSelectionEnd(loopEnd); } } }, 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 => onSnapChangeue(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("option", { value: "4" }, "4")), /*#__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: "0", value: beginBar, onChange: e => { const b = parseInt(e.target.value) || 0; setBeginBar(b); const beatDuration = 60 / parseInt(bpm || 120); const t = b * 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: "0", value: endBar, onChange: e => { const b = parseInt(e.target.value) || 0; setEndBar(b); const beatDuration = 60 / parseInt(bpm || 120); const t = b * 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("button", { onClick: () => setSelectionFollowsTempo(prev => !prev), className: `px-1.5 py-0.5 text-[14px] rounded border font-bold ${selectionFollowsTempo ? 'bg-amber-700 text-white border-amber-500' : 'bg-zinc-800 text-zinc-400 border-zinc-700'}`, title: selectionFollowsTempo ? "Selection theo tempo (đổi BPM → selection thay đổi)" : "Selection theo thời gian (cố định)" }, selectionFollowsTempo ? "♪T" : "⏱T"), /*#__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') { const selMidiInfo = getSelectedMidiItemInfo(); const hasSelItem = !!selMidiInfo; if (window.PromptTemplateManager && !aiPromptMgrRef.current) { aiPromptMgrRef.current = new window.PromptTemplateManager(); } // Re-read from localStorage when presets change (e.g., AIPresetModal saved) if (aiPromptMgrRef.current && window.__aiPresetVersion !== aiPresetVersion) { window.__aiPresetVersion = aiPresetVersion; aiPromptMgrRef.current.loadPresets(); } const promptMgr = aiPromptMgrRef.current; const suggestions = promptMgr ? promptMgr.presets : []; const handleApplySuggestion = (preset) => { if (hasSelItem) { setAiPrompt(`Rearrange this melody line in ${preset.name} style`); } else { setAiPrompt(preset.system_instruction_template); } setShowAiTypeahead(false); setAiSuggestions([]); }; 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: () => setAiPresetModalOpen(true), className: "text-zinc-600 hover:text-zinc-300 mr-0.5", title: "Preset Manager" }, /*#__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("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 cursor-pointer hover:text-zinc-200 select-none", onClick: () => setShowAIActionLog(!showAIActionLog) }, /*#__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", showAIActionLog ? " \u2212" : " +")), /*#__PURE__*/React.createElement("div", { ref: actionLogContainerRef, className: "flex-1 overflow-y-auto no-scrollbar bg-[#0f0f0f] rounded border border-zinc-800 select-text" + (showAIActionLog ? '' : ' hidden') }, aiActionLog.length === 0 ? /*#__PURE__*/React.createElement("div", { className: "text-xs text-zinc-600 italic select-text p-1" }, "Ch\u01B0a c\u00F3 h\u00E0nh \u0111\u1ED9ng n\u00E0o.") : aiActionLog.map(function(entry, i) { return /*#__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); }))), showAISuggestions ? /*#__PURE__*/React.createElement("div", { className: "flex flex-col gap-1 mb-1 shrink-0" }, /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-1 text-[10px] font-bold text-zinc-400 uppercase" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "sparkles", className: "w-3 h-3 text-purple-400" })), " AI Suggestion", hasSelItem ? /*#__PURE__*/React.createElement("span", { className: "flex-1 text-right text-[10px] text-amber-400 font-semibold uppercase normal-case truncate ml-2" }, "MIDI: ", selMidiInfo.itemName || selMidiInfo.itemId) : null), /*#__PURE__*/React.createElement("div", { className: "overflow-y-auto no-scrollbar max-h-36 bg-[#0f0f0f] rounded border border-zinc-800" }, suggestions.slice(0, 50).map(p => /*#__PURE__*/React.createElement("button", { key: p.id, onClick: () => handleApplySuggestion(p), className: "w-full text-left px-2 py-1 text-[11px] hover:bg-zinc-800 border-b border-zinc-900 last:border-0 flex items-center justify-between gap-2" }, /*#__PURE__*/React.createElement("span", { className: "truncate flex-1" }, /*#__PURE__*/React.createElement("span", { className: "text-zinc-400" }, p.is_favorite ? "★ " : "✨ "), /*#__PURE__*/React.createElement("span", { className: "text-zinc-200 font-semibold" }, p.name)), /*#__PURE__*/React.createElement("span", { className: "text-[9px] text-zinc-500 shrink-0" }, p.category))))) : null, /*#__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("button", { onClick: () => setShowAISuggestions(!showAISuggestions), className: "ml-auto text-[9px] px-1.5 py-0.5 rounded border font-semibold " + (showAISuggestions ? 'bg-zinc-800 text-zinc-400 border-zinc-700 hover:bg-zinc-700' : 'bg-indigo-950/40 text-indigo-400 border-indigo-800/50 hover:bg-indigo-900/50'), title: showAISuggestions ? 'Ẩn AI Suggestion' : 'Hiện AI Suggestion' }, "Sug")), /*#__PURE__*/React.createElement("textarea", { value: aiPrompt, onChange: e => { const v = e.target.value; aiPromptUndoPush(v); setAiPrompt(v); if (v.trim().length >= 2 && promptMgr) { const matches = promptMgr.presets.filter(p => p.keywords.some(kw => kw.toLowerCase().includes(v.toLowerCase())) || p.name.toLowerCase().includes(v.toLowerCase()) ); setAiSuggestions(matches); setShowAiTypeahead(matches.length > 0); } else { setShowAiTypeahead(false); } }, placeholder: hasSelItem ? "Nhập lệnh rearrange... (VD: Jazz Swing, Arpeggio)" : "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-y", rows: 8, onKeyDown: e => { if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); e.stopPropagation(); const u = aiPromptUndoRef.current; if (u.idx > 0) { u.idx--; setAiPrompt(u.stack[u.idx]); showToast('Undo: AI Prompt', 'info'); } return; } if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); e.stopPropagation(); const u = aiPromptUndoRef.current; if (u.idx < u.stack.length - 1) { u.idx++; setAiPrompt(u.stack[u.idx]); showToast('Redo: AI Prompt', 'info'); } return; } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleAISend(); } else if (e.key === 'Tab' && showAiTypeahead && aiSuggestions.length > 0) { e.preventDefault(); handleApplySuggestion(aiSuggestions[0]); } else if (e.key === 'ArrowUp' && promptHistRef.current.length > 0 && e.target.selectionStart === 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.target.selectionStart === aiPrompt.length) { 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]); } } } }), showAiTypeahead && aiSuggestions.length > 0 && /*#__PURE__*/React.createElement("div", { ref: aiTypeaheadRef, className: "absolute bottom-full left-0 right-0 bg-[#1e1e1e] border border-indigo-600/50 rounded-lg shadow-2xl z-50 max-h-36 overflow-y-auto mb-1" }, /*#__PURE__*/React.createElement("div", { className: "px-2 py-1 text-[10px] uppercase tracking-wider font-semibold text-indigo-400 bg-[#141414] border-b border-zinc-800" }, "Gợi ý (", aiSuggestions.length, ")"), aiSuggestions.slice(0, 8).map(p => /*#__PURE__*/React.createElement("div", { key: p.id, onClick: () => handleApplySuggestion(p), className: "px-2 py-1 hover:bg-indigo-700/30 cursor-pointer border-b border-zinc-800/30 flex items-center justify-between text-[11px]" }, /*#__PURE__*/React.createElement("span", null, /*#__PURE__*/React.createElement("span", { className: "font-semibold text-zinc-200" }, p.name), /*#__PURE__*/React.createElement("span", { className: "ml-1.5 text-zinc-500" }, "(", p.category, ")")), /*#__PURE__*/React.createElement("span", { className: "text-[10px] bg-zinc-800 text-zinc-400 px-1 py-0.5 rounded" }, "Tab"))))), /*#__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" }, hasSelItem ? "Enter gửi rearrange | Tab chọn gợi ý" : "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: "0", value: beginBar, onChange: e => { const b = parseInt(e.target.value) || 0; setBeginBar(b); const beatDuration = 60 / parseInt(bpm || 120); const t = b * 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: "0", value: endBar, onChange: e => { const b = parseInt(e.target.value) || 0; setEndBar(b); const beatDuration = 60 / parseInt(bpm || 120); const t = b * 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: "shrink-0 z-20 bg-[#262626] overflow-y-auto flex flex-col border-r border-zinc-900 no-scrollbar", style: { width: tcpWidth + 'px', 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: e => { const v = e.target.value; if (v && String(bpm) !== v) setBpmWithUndo(v); localStorage.setItem('studio_bpm', bpm); }, onKeyDown: e => { if (e.key === 'Enter') { e.target.blur(); } }, 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); var prTab = subTabs.find(function(s) { return s.type === 'PIANO_ROLL' && s.trackId === track.id; }); if (prTab && prTab.notes && prTab.notes.length > 0) { stopAllPlayback(); setSubTabs(function(prev) { return prev.map(function(s) { return s.id === prTab.id ? Object.assign({}, s, { isPlaying: true }) : s; }); }); schedulePianoRollMidi(prTab, 0); startSubTabPlayback(prTab, 0); } } }, /*#__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 ${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": track.solo ? "headphones" : "headphone-off", className: "w-3 h-3" })), /*#__PURE__*/React.createElement("button", { onClick: e => { e.stopPropagation(); toggleTrackDrum(track.id); }, title: track.is_percussion ? "Drum Channel (CH 10) - Click to disable" : "Toggle Drum Channel (CH 10)", className: `px-1.5 py-0.5 text-[9px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${track.is_percussion ? 'bg-rose-900 text-rose-300 border-rose-700' : 'bg-zinc-800 text-zinc-500 border-transparent hover:text-zinc-300'}` }, /*#__PURE__*/React.createElement("span", { className: "text-[11px]" }, "🥁"), track.is_percussion ? /*#__PURE__*/React.createElement("span", { className: "text-[9px]" }, "D") : null), /*#__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" }, /*#__PURE__*/React.createElement("option", { value: "MIDI_KEYBOARD:ALL" }, "Any MIDI Keyboard"), 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(); setFxSelectorTrackId(track.id); }, 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" }, track.fxType || "None")), /*#__PURE__*/React.createElement("button", { onClick: (e) => { e.stopPropagation(); openInstrumentSelector(track.id); }, 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 max-w-[120px]" }, /*#__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: "truncate text-[10px]" }, track.instrumentName || track.instrumentId || "Synth"), /*#__PURE__*/React.createElement("i", { "data-lucide": "chevron-down", className: "w-3 h-3 shrink-0" }))), /*#__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", { className: "w-1 cursor-ew-resize hover:bg-cyan-500/50 active:bg-cyan-400 transition-colors shrink-0 z-10", onMouseDown: startTcpResize }), /*#__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-30 bg-[#1a1a1a]" }, /*#__PURE__*/React.createElement(TimelineRuler, { bpm: parseInt(bpm) || 120, zoom: zoom, timelineWidth: timelineWidth, viewportWidth: viewportWidth, onPlayheadSet: handlePlayheadSet, snapValue: snapValue, onRulerMouseDown: e => { setSelectionFollowsTempo(false); handleRulerMouseDown(e); }, scrollLeft: scrollLeft, canvasRedrawCount: canvasRedrawCount }), /*#__PURE__*/React.createElement(TempoTrackLane, { bpm: parseInt(bpm) || 120, zoom: zoom, timelineWidth: timelineWidth, viewportWidth: viewportWidth, onPlayheadSet: handlePlayheadSet, snapValue: snapValue, onRulerMouseDown: e => { setSelectionFollowsTempo(true); handleRulerMouseDown(e); }, scrollLeft: scrollLeft, canvasRedrawCount: canvasRedrawCount })), 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: '80px' } }, /*#__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: "flex-1 flex flex-col divide-y divide-[#141414] relative bg-[#111111] min-h-full", onDragOver: e => e.preventDefault(), onDrop: e => { e.preventDefault(); const f = e.dataTransfer.files && e.dataTransfer.files[0]; if (!f) return; if (/\.mid$|\.midi$/i.test(f.name || '')) { handleDropMidiToNewTracks(f); } else if (selectedTrackId) { loadFileOnTrack(selectedTrackId, f); } }, onMouseDown: e => { if (e.ctrlKey && !e.shiftKey && !e.altKey && e.button === 0) { const wrapper = timelineWrapperRef.current; if (wrapper) { const rect = wrapper.getBoundingClientRect(); const sl = wrapper.scrollLeft; const raw = Math.max(0, (e.clientX - rect.left + sl) / zoom - leadInMargin); const time = snapValue !== 'free' ? snapTime(raw, snapValue, bpm) : raw; handleSweepSelectStart(null, time); } } } }, 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(); e.stopPropagation(); const f = e.dataTransfer.files && e.dataTransfer.files[0]; if (!f) return; loadFileOnTrack(track.id, f); }, onMouseEnter: () => { setHoveredTrackId(track.id); hoveredTrackIdRef.current = track.id; } }, /*#__PURE__*/React.createElement(WaveformLane, { track: track, zoom: zoom, timelineWidth: timelineWidth, viewportWidth: viewportWidth, scrollLeft: scrollLeft, onSelectRange: handleSelectRange, onPlayheadSet: handlePlayheadSet, isSelected: isSelected, selectedItemIds: selectedItemIds, onSelectTrack: setSelectedTrackId, markers: track.markers, onTrackLaneMouseDown: handleTrackLaneMouseDown, onClearSelection: () => { captureSelectionUndo(); setSelectedItemIds(new Set()); }, onSweepSelectStart: handleSweepSelectStart, onDeselectItem: handleDeselectItem, onAddToSelection: handleAddToSelection, onSetPendingDrag: handleSetPendingDrag, 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: () => { captureSelectionUndo(); clearLocalSelection(); }, onSetSelectionMode: mode => { captureSelectionUndo(); setSelectionMode(mode); }, onSetSelectionStart: val => { captureSelectionUndo(); setSelectionStart(val); }, onSetSelectionEnd: val => { captureSelectionUndo(); setSelectionEnd(val); }, 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') })), sweepSelect && /*#__PURE__*/React.createElement("div", { className: "absolute top-0 bottom-0 border-l border-r border-amber-400 bg-amber-500/10 z-20 pointer-events-none", style: { left: `${Math.min(sweepSelect.startTime, sweepSelect.endTime) * zoom}px`, width: `${Math.abs(sweepSelect.endTime - sweepSelect.startTime) * zoom}px` } }), 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, activeTracks: activeTracks, onClose: () => closeSubTab(st.id), onUpdateNotes: handleUpdateMidiNotes, onSaveNotes: handleSaveMidiNotes, setSubTabs: setSubTabs, onPlayPause: handlePlayPause, onStop: stopAllPlayback, isPlaying: isPlaying, playPreviewNote: playMidiPreviewNote, showToast: showToast, midiDevices: midiDevices, recordingState: recordingState, recTempMidiNotes: recTempMidiNotes, onRecord: handleRecordClick, selectedMidiInputId: selectedMidiInputId, onMidiInputSelect: handleMidiInputSelect, activeMidiPitches: activeMidiPitches, onInstrumentSelect: (trackId) => { openInstrumentSelector(trackId); }, onRealtimePlay: handlePianoRollRealtimePlay, onRescheduleMidi: (updatedNotes) => { const playingSub = subTabs.find(s => s.id === activeTab && s.type === 'PIANO_ROLL' && s.isPlaying); if (playingSub) { const offset = playingSub.currentTime || 0; const ctx = getAudioContext(); const tNode = activeTrackNodesRef.current[playingSub.trackId]; if (tNode && tNode.gainNode) { tNode.gainNode.gain.setValueAtTime(tNode.gainNode.gain.value || 1, ctx.currentTime); tNode.gainNode.gain.linearRampToValueAtTime(0.001, ctx.currentTime + 0.04); } setTimeout(() => { window.SonicSF.stopAll(); if (tNode && tNode.gainNode) { const trackData = activeTracksRef.current ? activeTracksRef.current.find(t => t.id === playingSub.trackId) : null; const volDb = trackData ? (trackData.volumeDb ?? 0) : 0; const volLinear = volDb <= -50 ? 0 : Math.pow(10, volDb / 20); tNode.gainNode.gain.setValueAtTime(0.001, ctx.currentTime); tNode.gainNode.gain.linearRampToValueAtTime(volLinear || 0.8, ctx.currentTime + 0.015); } startOffsetTimeRef.current = offset; startAudioTimeRef.current = ctx.currentTime; startBufferOffsetRef.current = offset * (playingSub.speed || 1.0); schedulePianoRollMidi(playingSub, offset, updatedNotes); }, 50); } }, onSeekPlayhead: (clickTime) => { const seekSt = subTabs.find(s => s.id === activeTab && s.type === 'PIANO_ROLL'); if (!seekSt) return; if (seekSt.isPlaying) { stopAllPlayback(); window.SonicSF.stopAll(); setSubTabs(prev => prev.map(s => s.id === seekSt.id ? { ...s, currentTime: clickTime, isPlaying: true } : s)); const ctx = getAudioContext(); startOffsetTimeRef.current = clickTime; startAudioTimeRef.current = ctx.currentTime; startBufferOffsetRef.current = clickTime * (seekSt.speed || 1.0); schedulePianoRollMidi(seekSt, clickTime); startSubTabPlayback(seekSt, clickTime); } else { setSubTabs(prev => prev.map(s => s.id === seekSt.id ? { ...s, currentTime: clickTime } : s)); } } }); } const subTrack = tracks.find(t => t.id === st.trackId); const vTrack = subTrack ? { ...subTrack, buffer: st.buffer, isSubTab: true } : null; const subTabDuration = st.type === 'PIANO_ROLL' ? (() => { const notes = st.notes || []; const beatSec = 60.0 / (parseFloat(bpm) || 120); let maxEnd = 0; notes.forEach(n => { const end = (n.start_beat || 0) + (n.duration_beats || 1); if (end > maxEnd) maxEnd = end; }); return maxEnd * beatSec + 1.0; })() : (st.buffer && 'duration' in st.buffer ? st.buffer.duration : 4.0); const subTabTimelineWidth = Math.max(zoom * subTabDuration, viewportWidth); return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { className: "shrink-0 z-30 bg-[#262626] overflow-y-scroll no-scrollbar flex flex-col border-r border-zinc-900", style: { width: tcpWidth + 'px', 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 ${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(subTabDuration) : '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", { className: "w-1 cursor-ew-resize hover:bg-cyan-500/50 active:bg-cyan-400 transition-colors shrink-0 z-10", onMouseDown: startTcpResize }), /*#__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-30 bg-[#1a1a1a]" }, /*#__PURE__*/React.createElement(TimelineRuler, { bpm: parseInt(bpm) || 120, zoom: zoom, timelineWidth: subTabTimelineWidth, viewportWidth: viewportWidth, onPlayheadSet: setCurrentTime, snapValue: snapValue, onRulerMouseDown: e => { setSelectionFollowsTempo(false); const wrapper = timelineWrapperRef.current; if (!wrapper) return; const rect = wrapper.getBoundingClientRect(); const sl = wrapper.scrollLeft; const raw = Math.max(0, (e.clientX - rect.left + sl) / zoom); const t = snapValue !== 'free' ? snapTime(raw, snapValue, bpm) : raw; if (e.ctrlKey) { e.preventDefault(); e.stopPropagation(); setSubTabs(prev => prev.map(s => s.id === activeTab ? { ...s, selectionStart: null, selectionEnd: null } : s)); return; } if (e.shiftKey) { e.preventDefault(); e.stopPropagation(); } handlePlayheadSet(t); subTabDragStartRef.current = t; isDraggingSubTabRef.current = true; }, scrollLeft: scrollLeft, canvasRedrawCount: canvasRedrawCount })), /*#__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'), showMixer && /*#__PURE__*/React.createElement("div", { className: "flex flex-col border-t border-zinc-700 bg-slate-900 shrink-0 select-none", style: { height: mixerHeight + 'px' } }, /*#__PURE__*/React.createElement("div", { className: "flex items-center justify-between px-2 py-0.5 bg-zinc-800/80 shrink-0 cursor-ns-resize", onMouseDown: e => { e.preventDefault(); var startY = e.clientY; var startH = mixerHeight; var onMove = function(ev) { var newH = Math.max(80, Math.min(400, startH - (ev.clientY - startY))); setMixerHeight(newH); localStorage.setItem('studio_mixer_height', newH.toString()); }; var onUp = function() { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); } }, /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-2" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "grip-horizontal", className: "w-3 h-3 text-zinc-600" })), /*#__PURE__*/React.createElement("span", { className: "text-[10px] font-bold text-zinc-400 uppercase tracking-wider" }, "MIXER")), /*#__PURE__*/React.createElement("button", { onClick: () => setShowMixer(false), className: "p-0.5 rounded text-zinc-500 hover:text-zinc-200 hover:bg-zinc-700 transition" }, /*#__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 flex overflow-x-auto p-1.5 gap-1.5 items-stretch" }, /*#__PURE__*/React.createElement(MasterStripConsole, { masterVolume: masterVolume, setMasterVolume: setMasterVolume, showMasteringModal: showMasteringModal, setShowMasteringModal: setShowMasteringModal, masteringSettings: masteringSettings, setMasteringSettings: setMasteringSettings, isPlaying: isPlaying }), activeTracks.length > 0 && /*#__PURE__*/React.createElement("div", { className: "w-px bg-zinc-700 shrink-0 self-stretch mx-0.5" }), activeTracks.map(function(track, idx) { return /*#__PURE__*/React.createElement(TrackStripConsole, { key: track.id, track: track, index: idx, onUpdateTrack: updateTrackProp, trackVuRefs: trackVuRefs }); })))); })(), /*#__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"), hasAnySolo && /*#__PURE__*/React.createElement("span", { className: "text-amber-500 font-semibold" }, "Solo: ", tracks.filter(t => t.solo).length, " track(s)"), 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("button", { onClick: () => setShowMixer(p => !p), className: `px-1.5 py-0.5 rounded text-xs font-semibold transition flex items-center gap-1 ${showMixer ? 'bg-indigo-900 text-indigo-300' : 'text-zinc-500 hover:text-zinc-300'}`, title: "Mixer Panel (F7)" }, /*#__PURE__*/React.createElement("span", { className: "inline-flex items-center shrink-0" }, /*#__PURE__*/React.createElement("i", { "data-lucide": "sliders-horizontal", className: "w-3 h-3" }))), /*#__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 overflow-y-auto", style: { left: Math.min(contextMenu.x, window.innerWidth - 260), top: contextMenu.y + 200 > window.innerHeight ? undefined : contextMenu.y, bottom: contextMenu.y + 200 > window.innerHeight ? (window.innerHeight - contextMenu.y) : undefined, maxHeight: '60vh' }, 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 overflow-y-auto", style: { left: Math.min(contextMenu.x, window.innerWidth - 260), top: contextMenu.y + 260 > window.innerHeight ? undefined : contextMenu.y, bottom: contextMenu.y + 260 > window.innerHeight ? (window.innerHeight - contextMenu.y) : undefined, maxHeight: '60vh' }, 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("div", { className: "px-3 py-0.5 text-[10px] text-zinc-500 font-bold uppercase tracking-wider" }, "Insert"), !sessionTabs.some(s => s.id === activeTab) && /*#__PURE__*/React.createElement("button", { onClick: () => { insertSectionAtPlayhead(); 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": "folder-plus", className: "w-3.5 h-3.5 text-amber-400 shrink-0" })), /*#__PURE__*/React.createElement("span", { className: "flex-1" }, "Insert Section")), /*#__PURE__*/React.createElement("button", { onClick: () => { insertMidiItemAtPlayhead(); 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": "music", className: "w-3.5 h-3.5 text-purple-400 shrink-0" })), /*#__PURE__*/React.createElement("span", { className: "flex-1" }, "Insert MIDI Item")), /*#__PURE__*/React.createElement("button", { onClick: () => { insertSoundClipAtCursor(); 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": "file-audio", className: "w-3.5 h-3.5 text-emerald-400 shrink-0" })), /*#__PURE__*/React.createElement("span", { className: "flex-1" }, "Insert Sound Clip")), /*#__PURE__*/React.createElement("button", { onClick: () => { insertTrackBelow(); 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": "plus-square", className: "w-3.5 h-3.5 text-cyan-400 shrink-0" })), /*#__PURE__*/React.createElement("span", { className: "flex-1" }, "Insert Track")), /*#__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"))))), tabContextMenu && /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { className: "fixed inset-0 z-[190]", onClick: () => setTabContextMenu(null), onContextMenu: e => { e.preventDefault(); setTabContextMenu(null); } }), /*#__PURE__*/React.createElement("div", { className: "fixed bg-zinc-900 border border-zinc-800 rounded-lg shadow-2xl p-2 z-[200] flex gap-1.5", style: { top: tabContextMenu.y, left: tabContextMenu.x }, onClick: e => e.stopPropagation() }, ['#f43f5e', '#f59e0b', '#10b981', '#06b6d4', '#8b5cf6', '#64748b'].map(color => /*#__PURE__*/React.createElement("button", { key: color, className: "w-5 h-5 rounded-full border border-zinc-700 hover:scale-110 active:scale-95 transition", style: { backgroundColor: color }, onClick: () => { handleSetTabColor(tabContextMenu.tabId, tabContextMenu.tabType, color); setTabContextMenu(null); } })))), /*#__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, loadAudioBuffersForTracks: loadAudioBuffersForTracks }), /*#__PURE__*/React.createElement(SaveProjectModal, { isOpen: saveProjectModalOpen, onClose: () => setSaveProjectModalOpen(false), projectName: projectName, onSaveCloud: (newName, existingId) => { handleSaveCloudProject(newName, existingId); }, onSaveLocal: (newName) => { handleSaveLocalProject(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(OpenProjectModal, { isOpen: openProjectModalOpen, onClose: () => setOpenProjectModalOpen(false), onOpenCloud: (projId, projName) => { setOpenProjectModalOpen(false); handleOpenProject(projId, projName); }, onOpenLocal: () => { setOpenProjectModalOpen(false); handleImportSFS(); } }), /*#__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) }), /*#__PURE__*/React.createElement(PluginManagerModal, { isOpen: pluginManagerModalOpen, onClose: () => setPluginManagerModalOpen(false), pluginsData: pluginsData }), /*#__PURE__*/React.createElement(AIPresetModal, { isOpen: aiPresetModalOpen, onClose: () => { setAiPresetModalOpen(false); setAiPresetVersion(v => v + 1); } }), /*#__PURE__*/React.createElement(MasteringModal, { isOpen: showMasteringModal, onClose: () => setShowMasteringModal(false), masteringSettings: masteringSettings, setMasteringSettings: setMasteringSettings }), instrumentSelectorTrackId && /*#__PURE__*/React.createElement("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm", onClick: closeInstrumentSelector }, /*#__PURE__*/React.createElement("div", { className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-4xl p-5 text-slate-200", onClick: e => e.stopPropagation() }, /*#__PURE__*/React.createElement("div", { className: "flex justify-between items-center pb-3 border-b border-[#383838]" }, /*#__PURE__*/React.createElement("h3", { className: "text-sm font-bold text-amber-400" }, "Select Instrument"), /*#__PURE__*/React.createElement("div", { className: "flex items-center gap-2" }, /*#__PURE__*/React.createElement("select", { value: instrumentSelectorTrackId || '', onChange: e => { var tid = e.target.value; if (tid) { openInstrumentSelector(tid); } }, className: "bg-[#1e1e1e] text-zinc-300 border border-zinc-700 rounded px-2 py-1 text-xs font-mono cursor-pointer" }, activeTracks.map(function(at) { return /*#__PURE__*/React.createElement("option", { key: at.id, value: at.id }, at.name + ' (' + (at.instrumentName || 'Synth') + ')'); })), /*#__PURE__*/React.createElement("button", { onClick: closeInstrumentSelector, className: "text-slate-400 hover:text-slate-200" }, "\u2715") ) ), /*#__PURE__*/React.createElement("input", { type: "text", placeholder: "T\u00ecm nh\u1ea1c c\u1ee5...", value: sfPresetSearchQuery, onChange: e => setSfPresetSearchQuery(e.target.value), autoFocus: true, className: "w-full bg-black border border-zinc-700 rounded px-2 py-1.5 outline-none my-3 text-sm" }), /*#__PURE__*/React.createElement("div", { className: "flex gap-4", style: { height: "420px" } }, /*#__PURE__*/React.createElement("div", { className: "w-1/3 border-r border-[#383838] overflow-y-auto space-y-0.5 pr-2" }, /*#__PURE__*/React.createElement("button", { onClick: () => { setSelectedSoundFontId(null); setSynthCategory(null); }, className: "w-full text-left px-3 py-2 text-sm rounded " + (!selectedSoundFontId ? "bg-amber-700 text-white" : "bg-zinc-800 hover:bg-zinc-700 text-zinc-300") }, "All Instruments"), (instrumentSelectorData?.soundfonts || []).map(sf => { const sfId = sf.id.startsWith('sf_') ? sf.id : 'sf_' + sf.id; const sfName = sf.display || sf.name || sf.id; return /*#__PURE__*/React.createElement("button", { key: sfId, onClick: () => { setSelectedSoundFontId(sfId); setSfPresets(null); const baseId = sfId.replace('sf_', ''); window.SonicAPI.listSoundfontInstruments(baseId).then(data => { if (data && data.presets) { const mapping = data.presets.map(p => ({ ...p, _sfId: sfId, _sfName: sfName, _sfDisplay: sfName.substring(0, 30) })); setSfPresets(mapping); setInstrumentSelectorData(prev => prev ? { ...prev, soundfonts: (prev.soundfonts || []).map(s => s.id === sf.id ? { ...s, presets: data.presets } : s) } : prev); } else { setSfPresets([]); } }).catch(() => { setSfPresets([]); }); }, className: "w-full text-left px-3 py-2 text-sm rounded " + (selectedSoundFontId === sfId ? "bg-amber-700 text-white" : "bg-zinc-800 hover:bg-zinc-700 text-zinc-300") }, sfName); }) ), /*#__PURE__*/React.createElement("div", { className: "flex-1 overflow-y-auto space-y-0.5" }, /*#__PURE__*/React.createElement("button", { onClick: () => setTrackInstrumentWithUndo(instrumentSelectorTrackId, null), className: "w-full text-left px-3 py-2 text-sm rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-400" }, "None (Default Synth)"), sfPresets === null ? ( /*#__PURE__*/React.createElement("p", { className: "text-sm text-zinc-500 py-2" }, "Loading instruments...") ) : ( sfPresets.length > 0 ? ( sfPresets .filter(p => !selectedSoundFontId || p._sfId === selectedSoundFontId) .filter(p => !sfPresetSearchQuery || (p.name || '').toLowerCase().includes(sfPresetSearchQuery.toLowerCase()) || (p._sfName || '').toLowerCase().includes(sfPresetSearchQuery.toLowerCase())) .map((p, i) => /*#__PURE__*/React.createElement("button", { key: i, onClick: () => setTrackInstrumentWithUndo(instrumentSelectorTrackId, p._sfId, p.name || 'Preset ' + p.program, p.bank, p.program), onDoubleClick: () => { setTrackInstrumentWithUndo(instrumentSelectorTrackId, p._sfId, p.name || 'Preset ' + p.program, p.bank, p.program); closeInstrumentSelector(); }, className: "w-full text-left px-3 py-2 text-sm rounded bg-zinc-800/60 hover:bg-amber-800 text-zinc-300 truncate flex items-center gap-2" }, /*#__PURE__*/React.createElement("span", { className: "text-xs text-zinc-500 shrink-0" }, p._sfDisplay), p.bank === 128 ? /*#__PURE__*/React.createElement("span", { className: "mr-1" }, "🥁") : null, p.name || 'Preset ' + p.program )) ) : ( /*#__PURE__*/React.createElement("p", { className: "text-sm text-zinc-500 py-2" }, sfPresetSearchQuery ? "Kh\u00f4ng t\u00ecm th\u1ea5y nh\u1ea1c c\u1ee5 ph\u00f9 h\u1ee3p." : "No presets found.") ) ) ) ) ) ), fxSelectorTrackId && /*#__PURE__*/React.createElement("div", { className: "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm", onClick: () => setFxSelectorTrackId(null) }, /*#__PURE__*/React.createElement("div", { className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-64 p-4 text-slate-200", onClick: e => e.stopPropagation() }, /*#__PURE__*/React.createElement("h3", { className: "text-sm font-bold text-purple-400 mb-3" }, "Track FX"), /*#__PURE__*/React.createElement("div", { className: "space-y-1" }, /*#__PURE__*/React.createElement("button", { onClick: () => handleSetTrackFx(fxSelectorTrackId, null), className: "w-full text-left px-3 py-2 text-xs rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-400" }, "None"), /*#__PURE__*/React.createElement("button", { onClick: () => handleSetTrackFx(fxSelectorTrackId, 'chorus'), className: "w-full text-left px-3 py-2 text-xs rounded bg-zinc-800 hover:bg-cyan-800 text-cyan-300" }, "Chorus"), /*#__PURE__*/React.createElement("button", { onClick: () => handleSetTrackFx(fxSelectorTrackId, 'reverb'), className: "w-full text-left px-3 py-2 text-xs rounded bg-zinc-800 hover:bg-purple-800 text-purple-300" }, "Reverb") ))), instrumentDropdownTrackId && instrumentDropdownBtnRect && /*#__PURE__*/React.createElement("div", { "data-instr-dropdown": "", className: "fixed z-[100] bg-[#1e1e1e] border border-zinc-700 rounded shadow-xl w-[220px] max-h-[300px] flex flex-col", style: { top: instrumentDropdownBtnRect.bottom + 4, left: Math.max(4, Math.min(instrumentDropdownBtnRect.left, window.innerWidth - 224)) } }, /*#__PURE__*/React.createElement("input", { type: "text", placeholder: "T\u00ecm nh\u1ea1c c\u1ee5...", value: instrumentSearchQuery, onChange: e => setInstrumentSearchQuery(e.target.value), autoFocus: true, className: "w-full bg-black border-b border-zinc-700 px-2 py-1.5 text-xs outline-none" }), /*#__PURE__*/React.createElement("div", { className: "flex-1 overflow-y-auto" }, /*#__PURE__*/React.createElement("button", { onClick: () => setTrackInstrumentWithUndo(instrumentDropdownTrackId, null), className: "w-full text-left px-3 py-1.5 text-xs bg-zinc-800 hover:bg-zinc-700 text-zinc-400" }, "None (Default Synth)"), filteredInstruments.soundfonts.length > 0 && /*#__PURE__*/React.createElement("div", { className: "text-[10px] text-zinc-500 px-3 py-1 mt-1 uppercase font-bold" }, "SoundFonts"), filteredInstruments.soundfonts.map((sf, i) => /*#__PURE__*/React.createElement("button", { key: "sfd_" + i, onClick: () => { setInstrumentDropdownTrackId(null); setInstrumentDropdownBtnRect(null); setTrackInstrumentWithUndo(instrumentDropdownTrackId, "sf_" + sf.id, sf.display || sf.name || sf.id, undefined, undefined); }, className: "w-full text-left px-3 py-1.5 text-xs bg-zinc-800 hover:bg-amber-800 text-zinc-200 flex items-center justify-between" }, /*#__PURE__*/React.createElement("span", { className: "truncate" }, sf.display || sf.name || sf.id), /*#__PURE__*/React.createElement("span", { className: "text-[10px] text-amber-400 shrink-0 ml-1" }, "SF"))), filteredInstruments.vst.length > 0 && /*#__PURE__*/React.createElement("div", { className: "text-[10px] text-zinc-500 px-3 py-1 mt-1 uppercase font-bold" }, "VST Instruments"), filteredInstruments.vst.map((v, i) => /*#__PURE__*/React.createElement("button", { key: "vstd_" + i, onClick: () => { setInstrumentDropdownTrackId(null); setInstrumentDropdownBtnRect(null); setTrackInstrumentWithUndo(instrumentDropdownTrackId, v.id, v.name || v.id); }, className: "w-full text-left px-3 py-1.5 text-xs bg-zinc-800 hover:bg-violet-800 text-zinc-200 flex items-center justify-between" }, /*#__PURE__*/React.createElement("span", { className: "truncate" }, v.name || v.id), /*#__PURE__*/React.createElement("span", { className: "text-[10px] text-cyan-400 shrink-0 ml-1" }, v.type || "VST"))), (!filteredInstruments.soundfonts.length && !filteredInstruments.vst.length) && /*#__PURE__*/React.createElement("p", { className: "text-xs text-zinc-500 py-4 text-center" }, "Kh\u00f4ng t\u00ecm th\u1ea5y nh\u1ea1c c\u1ee5 n\u00e0o") ))); }; const root = ReactDOM.createRoot(document.getElementById('root')); root.render(/*#__PURE__*/React.createElement(App, null)); setTimeout(() => lucide.createIcons(), 300);