FEAT: thêm tính năng MasteringModules Modal
This commit is contained in:
@@ -6691,6 +6691,625 @@ const deserializeProjectFromSchema = (schemaObj) => {
|
||||
};
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// MASTERING MODAL COMPONENT (from md/45_MASTERING_MODULE.md)
|
||||
// ──────────────────────────────────────────────
|
||||
const MasteringModal = ({ isOpen, onClose }) => {
|
||||
const [ozState, setOzState] = React.useState({
|
||||
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
|
||||
});
|
||||
const [isPlaying, setIsPlaying] = React.useState(false);
|
||||
const [peakLevels, setPeakLevels] = React.useState({ inPeak: 0.05, outPeak: 0.05 });
|
||||
|
||||
const audioRef = React.useRef({ ctx: null, nodes: {}, 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);
|
||||
|
||||
const ozStateRef = React.useRef(ozState);
|
||||
ozStateRef.current = ozState;
|
||||
|
||||
function getAudioCtx() {
|
||||
if (audioRef.current.ctx) return audioRef.current.ctx;
|
||||
const ctx = (window.SonicAudio && window.SonicAudio.getAudioContext && window.SonicAudio.getAudioContext()) || new (window.AudioContext || window.webkitAudioContext)();
|
||||
audioRef.current.ctx = ctx;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function initOzoneAudioEngine() {
|
||||
const ctx = getAudioCtx();
|
||||
const n = audioRef.current.nodes;
|
||||
if (n.inputAnalyser) return;
|
||||
|
||||
n.inputAnalyser = ctx.createAnalyser();
|
||||
n.inputAnalyser.fftSize = 2048;
|
||||
|
||||
n.eqLowFilter = ctx.createBiquadFilter();
|
||||
n.eqLowFilter.type = 'lowshelf';
|
||||
n.eqLowFilter.frequency.value = 100;
|
||||
|
||||
n.eqMid1Filter = ctx.createBiquadFilter();
|
||||
n.eqMid1Filter.type = 'peaking';
|
||||
n.eqMid1Filter.frequency.value = 822;
|
||||
n.eqMid1Filter.Q.value = 0.7;
|
||||
|
||||
n.eqMid2Filter = ctx.createBiquadFilter();
|
||||
n.eqMid2Filter.type = 'peaking';
|
||||
n.eqMid2Filter.frequency.value = 3200;
|
||||
n.eqMid2Filter.Q.value = 1.2;
|
||||
|
||||
n.eqHighFilter = ctx.createBiquadFilter();
|
||||
n.eqHighFilter.type = 'highshelf';
|
||||
n.eqHighFilter.frequency.value = 10000;
|
||||
|
||||
n.maximizerBoostGain = ctx.createGain();
|
||||
n.maximizerCompressor = ctx.createDynamicsCompressor();
|
||||
n.maximizerCompressor.threshold.value = -0.1;
|
||||
n.maximizerCompressor.knee.value = 0.0;
|
||||
n.maximizerCompressor.ratio.value = 20.0;
|
||||
n.maximizerCompressor.attack.value = 0.001;
|
||||
n.maximizerCompressor.release.value = 0.05;
|
||||
|
||||
n.outputAnalyser = ctx.createAnalyser();
|
||||
n.outputAnalyser.fftSize = 2048;
|
||||
|
||||
n.inputAnalyser.connect(n.eqLowFilter);
|
||||
n.eqLowFilter.connect(n.eqMid1Filter);
|
||||
n.eqMid1Filter.connect(n.eqMid2Filter);
|
||||
n.eqMid2Filter.connect(n.eqHighFilter);
|
||||
n.eqHighFilter.connect(n.maximizerBoostGain);
|
||||
n.maximizerBoostGain.connect(n.maximizerCompressor);
|
||||
n.maximizerCompressor.connect(n.outputAnalyser);
|
||||
n.outputAnalyser.connect(ctx.destination);
|
||||
|
||||
updateAudioGraphValues();
|
||||
}
|
||||
|
||||
function updateAudioGraphValues() {
|
||||
const n = audioRef.current.nodes;
|
||||
if (!n.eqLowFilter) return;
|
||||
const s = ozStateRef.current;
|
||||
const now = audioRef.current.ctx.currentTime;
|
||||
n.eqLowFilter.gain.setTargetAtTime(s.eqActive ? s.eqLowGain : 0, now, 0.01);
|
||||
n.eqMid1Filter.gain.setTargetAtTime(s.eqActive ? s.eqMid1Gain : 0, now, 0.01);
|
||||
n.eqMid2Filter.gain.setTargetAtTime(s.eqActive ? s.eqMid2Gain : 0, now, 0.01);
|
||||
n.eqHighFilter.gain.setTargetAtTime(s.eqActive ? s.eqHighGain : 0, now, 0.01);
|
||||
const boostLinear = s.maximizerActive ? Math.pow(10, s.maxGain / 20) : 1.0;
|
||||
n.maximizerBoostGain.gain.setTargetAtTime(boostLinear, now, 0.01);
|
||||
}
|
||||
|
||||
function startAudioDemo() {
|
||||
initOzoneAudioEngine();
|
||||
const ctx = audioRef.current.ctx;
|
||||
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(audioRef.current.nodes.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;
|
||||
initOzoneAudioEngine();
|
||||
const n = audioRef.current.nodes;
|
||||
const eqCtxFn = () => {
|
||||
const c = eqCanvasRef.current;
|
||||
return c ? c.getContext('2d') : null;
|
||||
};
|
||||
const imagerCtxFn = () => {
|
||||
const c = imagerCanvasRef.current;
|
||||
return c ? c.getContext('2d') : null;
|
||||
};
|
||||
const inCtxFn = () => {
|
||||
const c = inMeterCanvasRef.current;
|
||||
return c ? c.getContext('2d') : null;
|
||||
};
|
||||
const outCtxFn = () => {
|
||||
const c = outMeterCanvasRef.current;
|
||||
return c ? c.getContext('2d') : null;
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
resizeAll();
|
||||
window.addEventListener('resize', resizeAll);
|
||||
|
||||
const fftData = new Uint8Array(1024);
|
||||
|
||||
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 (n.outputAnalyser && isPlaying) {
|
||||
n.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();
|
||||
if (isPlaying) {
|
||||
ic.fillStyle = '#38bdf8';
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const angle = (Math.random() - 0.5) * (Math.PI / 2) + (-Math.PI / 2);
|
||||
const radius = (Math.random() * (ih / 3.2));
|
||||
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 = (ctxRef, level, 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 barH = level * 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) el.innerText = isPlaying ? `${(20 * Math.log10(level)).toFixed(1)} dB` : '-inf dB';
|
||||
};
|
||||
renderMeter(inMeterCanvasRef, isPlaying ? 0.55 : 0.05, 'inPeakText');
|
||||
renderMeter(outMeterCanvasRef, isPlaying ? 0.85 : 0.05, 'outPeakText');
|
||||
}
|
||||
|
||||
renderFrame();
|
||||
|
||||
// Knob setup
|
||||
if (!knobsInitializedRef.current) {
|
||||
knobsInitializedRef.current = true;
|
||||
const container = document.getElementById('masteringModalBody');
|
||||
if (container) {
|
||||
container.querySelectorAll('.knob-container').forEach(knob => {
|
||||
let isDragging = false, startY = 0, startVal = 0;
|
||||
const param = knob.dataset.param;
|
||||
if (!param) return;
|
||||
const min = parseFloat(knob.dataset.min);
|
||||
const max = parseFloat(knob.dataset.max);
|
||||
const unit = knob.dataset.unit || '';
|
||||
const dial = knob.querySelector('.knob-dial');
|
||||
const valText = knob.querySelector('.knob-val');
|
||||
|
||||
function updateUI(val) {
|
||||
const pct = (val - min) / (max - min);
|
||||
const angle = -135 + pct * 270;
|
||||
if (dial) dial.style.transform = `rotate(${angle}deg)`;
|
||||
if (valText) valText.innerText = `${val > 0 && unit === 'dB' ? '+' : ''}${val.toFixed(1)} ${unit}`;
|
||||
setOzState(prev => ({ ...prev, [param]: val }));
|
||||
}
|
||||
|
||||
const startValParsed = parseFloat(knob.dataset.value);
|
||||
updateUI(startValParsed);
|
||||
|
||||
knob.addEventListener('pointerdown', e => {
|
||||
isDragging = true;
|
||||
startY = e.clientY;
|
||||
startVal = ozStateRef.current[param] !== undefined ? ozStateRef.current[param] : startValParsed;
|
||||
knob.setPointerCapture(e.pointerId);
|
||||
});
|
||||
knob.addEventListener('pointermove', e => {
|
||||
if (!isDragging) return;
|
||||
const deltaY = startY - e.clientY;
|
||||
let newVal = startVal + (deltaY / 100) * (max - min);
|
||||
newVal = Math.min(max, Math.max(min, newVal));
|
||||
updateUI(newVal);
|
||||
});
|
||||
knob.addEventListener('pointerup', e => {
|
||||
isDragging = false;
|
||||
knob.releasePointerCapture(e.pointerId);
|
||||
});
|
||||
knob.addEventListener('pointercancel', e => {
|
||||
isDragging = false;
|
||||
knob.releasePointerCapture(e.pointerId);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resizeAll);
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
React.useEffect(() => {
|
||||
updateAudioGraphValues();
|
||||
}, [ozState.eqLowGain, ozState.eqMid1Gain, ozState.eqMid2Gain, ozState.eqHighGain, ozState.maxGain, ozState.eqActive, ozState.maximizerActive]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) {
|
||||
stopAudioDemo();
|
||||
knobsInitializedRef.current = false;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const switchModule = (name) => setOzState(prev => ({ ...prev, activeModule: name }));
|
||||
|
||||
const bandKnob = (param, min, max, val, unit, label, freq, color, filterType) => (
|
||||
<div className="bg-slate-900/80 border border-slate-800 p-2.5 rounded-lg flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between text-xs font-bold" style={{color}}>
|
||||
<span>{label}</span>
|
||||
<span className="text-[10px] font-mono text-slate-400">{freq}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-around my-2">
|
||||
<div className="knob-container" data-param={param} data-min={min} data-max={max} data-value={val} data-unit={unit}>
|
||||
<div className="knob-dial w-9 h-9 rounded-full bg-slate-800 border-2 relative flex items-center justify-center shadow-md" style={{borderColor: color}}>
|
||||
<div className="knob-pointer w-0.5 h-3 rounded absolute top-1" style={{backgroundColor: color}}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center font-mono">
|
||||
<span className="knob-val text-xs text-slate-200 font-bold">{val > 0 && unit === 'dB' ? '+' : ''}{val.toFixed(1)} {unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[9px] text-slate-500 font-mono text-center">{filterType}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={onClose}>
|
||||
<div id="masteringModalBody" className="flex flex-col bg-slate-950 text-slate-200 w-[95vw] h-[92vh] max-w-[1400px] rounded-2xl border border-slate-800/80 shadow-2xl overflow-hidden" onClick={e => e.stopPropagation()} style={{fontFamily: "'Inter', sans-serif"}}>
|
||||
|
||||
{/* TOP TRANSPORT & SESSION BAR */}
|
||||
<header className="h-12 bg-slate-900/90 border-b border-slate-800/80 flex items-center justify-between px-4 z-30 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-7 h-7 rounded-lg bg-cyan-600 flex items-center justify-center font-bold text-white shadow-lg shadow-cyan-950">
|
||||
<i data-lucide="zap" className="w-3.5 h-3.5"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xs font-bold tracking-wider text-white flex items-center gap-2">
|
||||
MASTERING SUITE <span className="text-[9px] bg-cyan-950 text-cyan-400 border border-cyan-800/80 px-1.5 py-0.5 rounded oz-font-mono">WEB MASTERING V10.5</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 bg-slate-950 px-3 py-1 rounded-lg border border-slate-800/80">
|
||||
<button onClick={startAudioDemo} className="px-3 py-1 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold flex items-center gap-1.5 transition-all shadow-md shadow-cyan-950">
|
||||
<i data-lucide="play" className="w-3 h-3"></i> <span>Play Reference</span>
|
||||
</button>
|
||||
<button onClick={stopAudioDemo} className="px-3 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded text-xs font-semibold flex items-center gap-1.5 transition-all">
|
||||
<i data-lucide="square" className="w-3 h-3"></i> <span>Stop</span>
|
||||
</button>
|
||||
<div className="h-4 w-[1px] bg-slate-800 mx-1"></div>
|
||||
<div className="flex items-center gap-2 text-xs oz-font-mono">
|
||||
<span className="text-slate-400 text-[11px]">Preset:</span>
|
||||
<select className="bg-slate-900 border border-slate-700 rounded px-2 py-0.5 text-xs text-cyan-300 outline-none focus:border-cyan-500">
|
||||
<option value="adaptive">Adaptive Dynamic Master</option>
|
||||
<option value="edm_club">EDM / Club Punch Maximizer</option>
|
||||
<option value="wide_space">Cinematic Stereo Expansion</option>
|
||||
<option value="transparent">Transparent High-Clarity Limiter</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs oz-font-mono">
|
||||
<span className="text-slate-400 text-[11px]">Target LUFS:</span>
|
||||
<span className="text-cyan-400 font-bold bg-slate-950 border border-cyan-900/60 px-2 py-0.5 rounded">-11.0 LUFS</span>
|
||||
<button onClick={onClose} className="text-slate-500 hover:text-slate-300 ml-2">
|
||||
<i data-lucide="x" className="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* MODULE CHAIN STRIP */}
|
||||
<div className="h-16 bg-slate-950 border-b border-slate-800 px-4 flex items-center gap-2 overflow-x-auto shrink-0 oz-scrollbar">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mr-1 oz-font-mono shrink-0">CHAIN:</span>
|
||||
|
||||
<div onClick={() => 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'}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={e => { e.stopPropagation(); setOzState(prev => ({ ...prev, eqActive: !prev.eqActive })); }} className="w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold" style={{backgroundColor: ozState.eqActive ? '#38bdf8' : '#334155', color: ozState.eqActive ? '#0f172a' : '#94a3b8'}}>
|
||||
<i data-lucide="power" className="w-2.5 h-2.5"></i>
|
||||
</button>
|
||||
<div>
|
||||
<div className="text-[11px] font-bold text-slate-200">Dynamic EQ</div>
|
||||
<div className="text-[9px] text-cyan-400 oz-font-mono">4-Band Peak</div>
|
||||
</div>
|
||||
</div>
|
||||
<i data-lucide="activity" className="w-3.5 h-3.5 text-slate-500"></i>
|
||||
</div>
|
||||
|
||||
<div onClick={() => 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'}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={e => { e.stopPropagation(); setOzState(prev => ({ ...prev, imagerActive: !prev.imagerActive })); }} className="w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold" style={{backgroundColor: ozState.imagerActive ? '#38bdf8' : '#334155', color: ozState.imagerActive ? '#0f172a' : '#94a3b8'}}>
|
||||
<i data-lucide="power" className="w-2.5 h-2.5"></i>
|
||||
</button>
|
||||
<div>
|
||||
<div className="text-[11px] font-bold text-slate-200">Imager</div>
|
||||
<div className="text-[9px] text-slate-400 oz-font-mono">4-Band Width</div>
|
||||
</div>
|
||||
</div>
|
||||
<i data-lucide="radio" className="w-3.5 h-3.5 text-slate-500"></i>
|
||||
</div>
|
||||
|
||||
<div onClick={() => 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'}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={e => { e.stopPropagation(); setOzState(prev => ({ ...prev, maximizerActive: !prev.maximizerActive })); }} className="w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold" style={{backgroundColor: ozState.maximizerActive ? '#38bdf8' : '#334155', color: ozState.maximizerActive ? '#0f172a' : '#94a3b8'}}>
|
||||
<i data-lucide="power" className="w-2.5 h-2.5"></i>
|
||||
</button>
|
||||
<div>
|
||||
<div className="text-[11px] font-bold text-slate-200">Maximizer</div>
|
||||
<div className="text-[9px] text-slate-400 oz-font-mono">IRC IV True Peak</div>
|
||||
</div>
|
||||
</div>
|
||||
<i data-lucide="gauge" className="w-3.5 h-3.5 text-slate-500"></i>
|
||||
</div>
|
||||
|
||||
<div className="w-24 h-12 rounded-lg border border-dashed border-slate-800 flex items-center justify-center text-slate-600 hover:text-slate-400 hover:border-slate-600 cursor-pointer transition-all shrink-0">
|
||||
<i data-lucide="plus" className="w-4 h-4"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MAIN WORKSPACE */}
|
||||
<main className="flex-1 flex overflow-hidden min-h-0">
|
||||
|
||||
{/* LEFT: MODULE VIEWS */}
|
||||
<div className="flex-1 flex flex-col bg-slate-950 relative overflow-y-auto oz-scrollbar">
|
||||
|
||||
{/* SUB HEADER TOOLBAR */}
|
||||
<div className="h-9 bg-slate-900/60 border-b border-slate-800/80 px-4 flex items-center justify-between text-xs oz-font-mono shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-slate-400 flex items-center gap-1.5"><i data-lucide="headphones" className="w-3 h-3 text-cyan-400"></i> Delta Listen</span>
|
||||
<select className="bg-slate-950 border border-slate-800 rounded px-2 py-0.5 text-[11px] text-slate-300 outline-none">
|
||||
<option value="irc4">IRC IV - Classic</option>
|
||||
<option value="irc3">IRC III - Balanced</option>
|
||||
<option value="irc2">IRC II - Crisp</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<span className="text-slate-400">Learn Input Gain:</span>
|
||||
<button className="bg-cyan-950 text-cyan-300 border border-cyan-800/80 px-2 py-0.5 rounded font-bold hover:bg-cyan-900 transition-colors">-11.0 LUFS</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VIEW: DYNAMIC EQ */}
|
||||
<div className={`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule === 'eq' ? '' : 'hidden'}`}>
|
||||
<div className="relative w-full h-64 bg-slate-950 border border-slate-800/90 rounded-xl overflow-hidden shadow-inner cursor-crosshair">
|
||||
<canvas ref={eqCanvasRef} className="w-full h-full block"></canvas>
|
||||
</div>
|
||||
<div className="oz-panel p-3 rounded-xl grid grid-cols-4 gap-3">
|
||||
{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')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VIEW: STEREO IMAGER */}
|
||||
<div className={`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule === 'imager' ? '' : 'hidden'}`}>
|
||||
<div className="grid grid-cols-12 gap-4 flex-1">
|
||||
<div className="col-span-8 bg-slate-950 border border-slate-800/90 rounded-xl p-3 flex flex-col relative shadow-inner">
|
||||
<span className="text-xs font-bold text-cyan-400 uppercase oz-font-mono mb-2">
|
||||
<i data-lucide="radio" className="w-3.5 h-3.5"></i> Polar Vectorscope & Correlation Meter
|
||||
</span>
|
||||
<div className="flex-1 relative w-full h-56 flex items-center justify-center">
|
||||
<canvas ref={imagerCanvasRef} className="w-full h-full block"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-4 oz-panel p-3 rounded-xl flex flex-col justify-between">
|
||||
<span className="text-xs font-bold text-slate-300 uppercase oz-font-mono">4-Band Stereo Width</span>
|
||||
<div className="space-y-3 my-auto">
|
||||
{[{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 => (
|
||||
<div key={b.id}>
|
||||
<div className="flex justify-between text-[11px] font-mono mb-1">
|
||||
<span style={{color:b.color,fontWeight:700}}>{b.label}</span>
|
||||
<span id={b.id+'Val'}>{b.val}%</span>
|
||||
</div>
|
||||
<input type="range" min="-100" max="100" value={b.val}
|
||||
onChange={e => setOzState(prev => ({...prev, [b.id]: parseInt(e.target.value)}))}
|
||||
className="w-full h-1 cursor-pointer" style={{accentColor: b.color}} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VIEW: MAXIMIZER */}
|
||||
<div className={`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule === 'maximizer' ? '' : 'hidden'}`}>
|
||||
<div className="oz-panel p-4 rounded-xl flex-1 grid grid-cols-12 gap-6 items-center">
|
||||
<div className="col-span-4 flex flex-col items-center justify-center border-r border-slate-800 pr-4">
|
||||
<span className="text-xs font-bold text-slate-400 uppercase oz-font-mono mb-3">Maximizer Gain Boost</span>
|
||||
<div className="knob-container my-2" data-param="maxGain" data-min="0" data-max="12" data-value="5.4" data-unit="dB">
|
||||
<div className="knob-dial w-24 h-24 rounded-full bg-slate-900 border-4 border-cyan-500 relative flex items-center justify-center shadow-2xl">
|
||||
<div className="knob-pointer w-1 h-8 bg-cyan-400 rounded absolute top-2"></div>
|
||||
<span className="knob-val text-sm font-bold oz-font-mono text-cyan-300 z-10">+5.4 dB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full mt-4 bg-slate-950 p-2 rounded-lg border border-slate-800 text-xs oz-font-mono flex justify-between items-center">
|
||||
<span className="text-slate-400">Ceiling Level:</span>
|
||||
<span className="text-cyan-400 font-bold">{ozState.ceiling.toFixed(2)} dB</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-8 grid grid-cols-3 gap-4">
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center">
|
||||
<span className="text-[10px] font-bold text-slate-400 oz-font-mono mb-2">UPWARD COMPRESS</span>
|
||||
<div className="knob-container" data-param="maxUpward" data-min="0" data-max="10" data-value="2.0" data-unit="dB">
|
||||
<div className="knob-dial w-12 h-12 rounded-full bg-slate-800 border-2 border-cyan-500 relative flex items-center justify-center">
|
||||
<div className="knob-pointer w-0.5 h-4 bg-cyan-400 rounded absolute top-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="knob-val text-xs font-mono text-slate-200 mt-2">+2.0 dB</span>
|
||||
</div>
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center">
|
||||
<span className="text-[10px] font-bold text-slate-400 oz-font-mono mb-2">SOFT CLIPPER</span>
|
||||
<div className="knob-container" data-param="maxSoftClip" data-min="0" data-max="100" data-value="15" data-unit="%">
|
||||
<div className="knob-dial w-12 h-12 rounded-full bg-slate-800 border-2 border-amber-500 relative flex items-center justify-center">
|
||||
<div className="knob-pointer w-0.5 h-4 bg-amber-400 rounded absolute top-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="knob-val text-xs font-mono text-slate-200 mt-2">15%</span>
|
||||
</div>
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center">
|
||||
<span className="text-[10px] font-bold text-slate-400 oz-font-mono mb-2">TRANSIENT EMPHASIS</span>
|
||||
<div className="knob-container" data-param="maxTransient" data-min="0" data-max="100" data-value="25" data-unit="%">
|
||||
<div className="knob-dial w-12 h-12 rounded-full bg-slate-800 border-2 border-emerald-500 relative flex items-center justify-center">
|
||||
<div className="knob-pointer w-0.5 h-4 bg-emerald-400 rounded absolute top-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="knob-val text-xs font-mono text-slate-200 mt-2">25%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* RIGHT SIDEBAR: I/O METERS */}
|
||||
<div className="w-64 bg-slate-900 border-l border-slate-800/80 p-3 flex flex-col justify-between shadow-2xl oz-font-mono text-xs z-20 shrink-0">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-2 mb-2">
|
||||
<span className="text-xs font-bold text-white tracking-wider uppercase flex items-center gap-1.5">
|
||||
<i data-lucide="sliders" className="w-3 h-3 text-cyan-400"></i> I/O METERS
|
||||
</span>
|
||||
<span className="text-[9px] bg-cyan-950 border border-cyan-800 text-cyan-300 px-1 py-0.5 rounded">TRUE PEAK</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-[10px] text-center mb-2">
|
||||
<div className="bg-slate-950 border border-slate-800/80 p-1.5 rounded">
|
||||
<div className="text-slate-500 font-bold">IN PEAK</div>
|
||||
<div id="inPeakText" className="text-cyan-400 font-bold oz-font-mono">-inf dB</div>
|
||||
</div>
|
||||
<div className="bg-slate-950 border border-slate-800/80 p-1.5 rounded">
|
||||
<div className="text-slate-500 font-bold">OUT PEAK</div>
|
||||
<div id="outPeakText" className="text-emerald-400 font-bold oz-font-mono">-inf dB</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-slate-950 border border-slate-800/90 rounded-xl p-2 flex justify-around relative overflow-hidden my-1">
|
||||
<div className="flex flex-col items-center h-full">
|
||||
<span className="text-[9px] text-slate-500 mb-1">IN</span>
|
||||
<div className="flex-1 w-5 bg-slate-900 border border-slate-800 rounded relative overflow-hidden flex items-end">
|
||||
<canvas ref={inMeterCanvasRef} className="w-full h-full block"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center h-full">
|
||||
<span className="text-[9px] text-slate-500 mb-1">OUT</span>
|
||||
<div className="flex-1 w-6 bg-slate-900 border border-slate-800 rounded relative overflow-hidden flex items-end">
|
||||
<canvas ref={outMeterCanvasRef} className="w-full h-full block"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-1.5 mt-2">
|
||||
<button onClick={() => setOzState(prev => ({ ...prev, isBypassed: !prev.isBypassed }))}
|
||||
className={`py-1.5 rounded text-[10px] font-bold border transition-colors ${ozState.isBypassed ? 'bg-cyan-600 border-cyan-500 text-white' : 'bg-slate-800 hover:bg-slate-700 text-slate-200 border-slate-700'}`}>
|
||||
Bypass
|
||||
</button>
|
||||
<button className="bg-slate-800 hover:bg-slate-700 text-slate-200 py-1.5 rounded text-[10px] font-bold border border-slate-700 transition-colors">Gain Match</button>
|
||||
<button className="bg-slate-800 hover:bg-slate-700 text-slate-300 py-1.5 rounded text-[10px] border border-slate-700">Codec</button>
|
||||
<button className="bg-slate-800 hover:bg-slate-700 text-slate-300 py-1.5 rounded text-[10px] border border-slate-700">Dither</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
// ── State Definitions ──
|
||||
const [tracks, setTracks] = useState([{
|
||||
@@ -7707,6 +8326,7 @@ const App = () => {
|
||||
const [aiConfigModalOpen, setAiConfigModalOpen] = useState(false);
|
||||
const [aiPresetModalOpen, setAiPresetModalOpen] = useState(false);
|
||||
const [aiPresetVersion, setAiPresetVersion] = useState(0);
|
||||
const [showMasteringModal, setShowMasteringModal] = useState(false);
|
||||
const [pluginManagerModalOpen, setPluginManagerModalOpen] = useState(false);
|
||||
const [pluginsData, setPluginsData] = useState(null);
|
||||
useEffect(() => {
|
||||
@@ -15582,6 +16202,11 @@ const App = () => {
|
||||
label: 'Export MIDI...',
|
||||
icon: 'music',
|
||||
action: () => triggerMidiExport()
|
||||
}, {
|
||||
label: 'Mastering Suite',
|
||||
icon: 'wand-2',
|
||||
shortcut: 'Ctrl+Shift+M',
|
||||
action: () => setShowMasteringModal(true)
|
||||
}, {
|
||||
sep: true
|
||||
}, ...(currentUser ? [{
|
||||
@@ -18661,6 +19286,9 @@ const App = () => {
|
||||
}), /*#__PURE__*/React.createElement(AIPresetModal, {
|
||||
isOpen: aiPresetModalOpen,
|
||||
onClose: () => { setAiPresetModalOpen(false); setAiPresetVersion(v => v + 1); }
|
||||
}), /*#__PURE__*/React.createElement(MasteringModal, {
|
||||
isOpen: showMasteringModal,
|
||||
onClose: () => setShowMasteringModal(false)
|
||||
}), instrumentSelectorTrackId && /*#__PURE__*/React.createElement("div", {
|
||||
className: "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm",
|
||||
onClick: closeInstrumentSelector
|
||||
|
||||
Reference in New Issue
Block a user