FIX: hiển thị MASTERING PANEL và các modules không bị lỗi
This commit is contained in:
+661
-82
@@ -156,7 +156,7 @@ function applyMasteringSettings(s) {
|
||||
// Re-applying identical values in rapid bursts is "fast parameter automation"
|
||||
// and makes Chromium flag the biquad EQ filters as unstable ("state is bad").
|
||||
// Only touch the graph when a value actually changed.
|
||||
const sig = [s.eqActive, s.eqLowGain, s.eqMid1Gain, s.eqMid2Gain, s.eqHighGain, s.imagerActive, s.w1, s.w2, s.w3, s.w4, s.maximizerActive, s.maxGain, s.maxSoftClip, s.maxUpward, s.ceiling].join('|');
|
||||
const sig = [s.eqActive, s.eqLowGain, s.eqMid1Gain, s.eqMid2Gain, s.eqHighGain, s.imagerActive, s.w1, s.w2, s.w3, s.w4, s.maximizerActive, s.maxGain, s.maxSoftClip, s.maxUpward, s.ceiling, s.compActive, s.compThreshold, s.compRatio, s.compMakeup, s.limActive, s.limThreshold, s.excActive, s.excDrive, s.rebalActive, s.rebalMid, s.rebalSide].join('|');
|
||||
if (sig === _lastMasteringSig) return;
|
||||
_lastMasteringSig = sig;
|
||||
|
||||
@@ -224,6 +224,43 @@ function applyMasteringSettings(s) {
|
||||
// Limiter Threshold
|
||||
const ceilingVal = s.maximizerActive ? clamp(s.ceiling, -60, 0) : -0.1;
|
||||
masterBus.maximizerCompressor.threshold.setTargetAtTime(ceilingVal, now, 0.01);
|
||||
|
||||
// 4. Bus Compressor module (mastering_expand.md §II.2)
|
||||
if (masterBus.compNode) {
|
||||
const compOn = !!s.compActive;
|
||||
masterBus.compNode.threshold.setTargetAtTime(compOn ? clamp(s.compThreshold, -60, 0) : 0, now, 0.02);
|
||||
masterBus.compNode.ratio.setTargetAtTime(compOn ? clamp(s.compRatio, 1, 20) : 1, now, 0.02);
|
||||
masterBus.compMakeup.gain.setTargetAtTime(compOn ? Math.pow(10, clamp(s.compMakeup, 0, 12) / 20) : 1.0, now, 0.02);
|
||||
}
|
||||
|
||||
// 5. Brickwall Limiter module (ratio 20:1, knee 0)
|
||||
if (masterBus.limNode) {
|
||||
const limOn = !!s.limActive;
|
||||
masterBus.limNode.threshold.setTargetAtTime(limOn ? clamp(s.limThreshold, -24, 0) : 0, now, 0.02);
|
||||
masterBus.limNode.ratio.setTargetAtTime(limOn ? 20 : 1, now, 0.02);
|
||||
}
|
||||
|
||||
// 6. Harmonic Exciter module (dry/wet mix; dry stays 1.0 for subtle warmth)
|
||||
if (masterBus.excWet) {
|
||||
const excOn = !!s.excActive;
|
||||
const wetAmt = excOn ? clamp(s.excDrive, 0, 100) / 100 : 0;
|
||||
masterBus.excWet.gain.setTargetAtTime(wetAmt * 0.6, now, 0.02);
|
||||
masterBus.excDry.gain.setTargetAtTime(1.0, now, 0.02);
|
||||
}
|
||||
|
||||
// 7. Master Rebalance module (M/S gains via L/R crossfeed)
|
||||
// L' = a·L + b·R, R' = b·L + a·R with a=(mid+side)/2, b=(mid−side)/2
|
||||
if (masterBus.gLLr) {
|
||||
const rebOn = !!s.rebalActive;
|
||||
const midLin = rebOn ? Math.pow(10, clamp(s.rebalMid, -24, 24) / 20) : 1.0;
|
||||
const sideLin = rebOn ? Math.pow(10, clamp(s.rebalSide, -24, 24) / 20) : 1.0;
|
||||
const a = (midLin + sideLin) / 2;
|
||||
const b = (midLin - sideLin) / 2;
|
||||
masterBus.gLLr.gain.setTargetAtTime(a, now, 0.01);
|
||||
masterBus.gRRr.gain.setTargetAtTime(a, now, 0.01);
|
||||
masterBus.gRLr.gain.setTargetAtTime(b, now, 0.01);
|
||||
masterBus.gLRr.gain.setTargetAtTime(b, now, 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
function initMasterBus(ctx) {
|
||||
@@ -358,6 +395,68 @@ function initMasterBus(ctx) {
|
||||
|
||||
upwardSummingGain.connect(maximizerCompressor);
|
||||
|
||||
// ── Bus Compressor module (mastering_expand.md §II.2) ──
|
||||
const compInput = ctx.createGain();
|
||||
const compNode = ctx.createDynamicsCompressor();
|
||||
compNode.threshold.value = -16;
|
||||
compNode.knee.value = 8;
|
||||
compNode.ratio.value = 3;
|
||||
compNode.attack.value = 0.02;
|
||||
compNode.release.value = 0.25;
|
||||
const compMakeup = ctx.createGain();
|
||||
compMakeup.gain.value = 1.0;
|
||||
const compOutput = ctx.createGain();
|
||||
compInput.connect(compNode);
|
||||
compNode.connect(compMakeup);
|
||||
compMakeup.connect(compOutput);
|
||||
|
||||
// ── Brickwall Limiter module (ratio 20:1, knee 0) ──
|
||||
const limInput = ctx.createGain();
|
||||
const limNode = ctx.createDynamicsCompressor();
|
||||
limNode.threshold.value = -1.0;
|
||||
limNode.knee.value = 0;
|
||||
limNode.ratio.value = 20;
|
||||
limNode.attack.value = 0.001;
|
||||
limNode.release.value = 0.05;
|
||||
const limOutput = ctx.createGain();
|
||||
limInput.connect(limNode);
|
||||
limNode.connect(limOutput);
|
||||
|
||||
// ── Harmonic Exciter module (WaveShaper saturator + high-pass, dry/wet) ──
|
||||
const excInput = ctx.createGain();
|
||||
const excHp = ctx.createBiquadFilter();
|
||||
excHp.type = 'highpass';
|
||||
excHp.frequency.value = clampF(2000);
|
||||
excHp.Q.value = 0.7;
|
||||
const excShaper = ctx.createWaveShaper();
|
||||
excShaper.curve = makeDistortionCurve(3);
|
||||
excShaper.oversample = '4x';
|
||||
const excDry = ctx.createGain();
|
||||
excDry.gain.value = 1.0;
|
||||
const excWet = ctx.createGain();
|
||||
excWet.gain.value = 0.0;
|
||||
const excOutput = ctx.createGain();
|
||||
excInput.connect(excDry);
|
||||
excDry.connect(excOutput);
|
||||
excInput.connect(excHp);
|
||||
excHp.connect(excShaper);
|
||||
excShaper.connect(excWet);
|
||||
excWet.connect(excOutput);
|
||||
|
||||
// ── Master Rebalance module (M/S gains via L/R crossfeed) ──
|
||||
const rebalInput = ctx.createGain();
|
||||
const rebalSplit = ctx.createChannelSplitter(2);
|
||||
const rebalMerge = ctx.createChannelMerger(2);
|
||||
const rebalOutput = ctx.createGain();
|
||||
const gLLr = ctx.createGain(); const gRLr = ctx.createGain();
|
||||
const gLRr = ctx.createGain(); const gRRr = ctx.createGain();
|
||||
rebalInput.connect(rebalSplit);
|
||||
rebalSplit.connect(gLLr, 0); rebalSplit.connect(gRLr, 0);
|
||||
rebalSplit.connect(gLRr, 1); rebalSplit.connect(gRRr, 1);
|
||||
gLLr.connect(rebalMerge, 0, 0); gRLr.connect(rebalMerge, 0, 0);
|
||||
gLRr.connect(rebalMerge, 0, 1); gRRr.connect(rebalMerge, 0, 1);
|
||||
rebalMerge.connect(rebalOutput);
|
||||
|
||||
// Setup Analysers
|
||||
const inputAnalyser = ctx.createAnalyser();
|
||||
inputAnalyser.fftSize = 2048;
|
||||
@@ -415,20 +514,20 @@ function initMasterBus(ctx) {
|
||||
gainLL4, gainRL4, gainLR4, gainRR4,
|
||||
maximizerBoostGain, maximizerSoftClipper,
|
||||
upwardCompressor, upwardGain, upwardSummingGain,
|
||||
maximizerCompressor
|
||||
maximizerCompressor,
|
||||
// Extension modules (mastering_expand.md §II.2)
|
||||
compInput, compNode, compMakeup, compOutput,
|
||||
limInput, limNode, limOutput,
|
||||
excInput, excHp, excShaper, excDry, excWet, excOutput,
|
||||
rebalInput, rebalSplit, rebalMerge, gLLr, gRLr, gLRr, gRRr, rebalOutput
|
||||
};
|
||||
|
||||
// Connect EQ chain
|
||||
// Connect EQ chain (internal — the module BOUNDARIES are wired dynamically
|
||||
// by rebuildMasteringGraph so modules can be reordered on the CHAIN bar)
|
||||
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);
|
||||
@@ -458,28 +557,147 @@ function setMasterVolume(linear) {
|
||||
|
||||
let _lastMasteringActive = null;
|
||||
let _lastMasteringSig = null;
|
||||
let _lastChainSig = null;
|
||||
|
||||
// Module input/output boundary nodes for dynamic chain re-routing
|
||||
// (mastering_expand.md §II.3 — rebuildAudioGraph). Each module's INTERNAL
|
||||
// wiring is fixed; only the boundaries get re-connected per chain order.
|
||||
const MASTER_MODULE_IO = {
|
||||
eq: { input: 'eqLowFilter', output: 'eqHighFilter' },
|
||||
imager: { input: 'imagerInput', output: 'imagerOutput' },
|
||||
maximizer: { input: 'maximizerBoostGain', output: 'maximizerCompressor' },
|
||||
compressor: { input: 'compInput', output: 'compOutput' },
|
||||
limiter: { input: 'limInput', output: 'limOutput' },
|
||||
exciter: { input: 'excInput', output: 'excOutput' },
|
||||
rebalance: { input: 'rebalInput', output: 'rebalOutput' }
|
||||
};
|
||||
|
||||
const DEFAULT_MASTER_CHAIN = [
|
||||
{ id: 'mod_eq', type: 'eq', name: 'Dynamic EQ', active: true },
|
||||
{ id: 'mod_imager', type: 'imager', name: 'Imager', active: true },
|
||||
{ id: 'mod_maximizer', type: 'maximizer', name: 'Maximizer', active: true }
|
||||
];
|
||||
|
||||
function chainSignature(chainArray) {
|
||||
return (chainArray || []).map(m => (m.type || '') + (m.active ? '1' : '0')).join(',');
|
||||
}
|
||||
|
||||
// ── Track FX module factory (mastering_expand.md §II.4) ──
|
||||
// The SAME module DSP used in the mastering chain, instantiated per-track for
|
||||
// the [FX] button on track strips. Returns { input, output, nodes, dispose }.
|
||||
function createTrackFxModule(type, ctx) {
|
||||
const input = ctx.createGain();
|
||||
const output = ctx.createGain();
|
||||
let nodes = {};
|
||||
if (type === 'compressor') {
|
||||
const comp = ctx.createDynamicsCompressor();
|
||||
comp.threshold.value = -16; comp.knee.value = 8; comp.ratio.value = 3;
|
||||
comp.attack.value = 0.02; comp.release.value = 0.25;
|
||||
const makeup = ctx.createGain(); makeup.gain.value = 1.0;
|
||||
input.connect(comp); comp.connect(makeup); makeup.connect(output);
|
||||
nodes = { comp, makeup };
|
||||
} else if (type === 'limiter') {
|
||||
const lim = ctx.createDynamicsCompressor();
|
||||
lim.threshold.value = -1.0; lim.knee.value = 0; lim.ratio.value = 20;
|
||||
lim.attack.value = 0.001; lim.release.value = 0.05;
|
||||
input.connect(lim); lim.connect(output);
|
||||
nodes = { lim };
|
||||
} else if (type === 'exciter') {
|
||||
const hp = ctx.createBiquadFilter();
|
||||
hp.type = 'highpass'; hp.frequency.value = 2000; hp.Q.value = 0.7;
|
||||
const shaper = ctx.createWaveShaper();
|
||||
shaper.curve = makeDistortionCurve(3); shaper.oversample = '4x';
|
||||
const dry = ctx.createGain(); dry.gain.value = 1.0;
|
||||
const wet = ctx.createGain(); wet.gain.value = 0.4;
|
||||
input.connect(dry); dry.connect(output);
|
||||
input.connect(hp); hp.connect(shaper); shaper.connect(wet); wet.connect(output);
|
||||
nodes = { hp, shaper, dry, wet };
|
||||
} else if (type === 'rebalance') {
|
||||
const split = ctx.createChannelSplitter(2);
|
||||
const merge = ctx.createChannelMerger(2);
|
||||
const gLL = ctx.createGain(), gRL = ctx.createGain(), gLR = ctx.createGain(), gRR = ctx.createGain();
|
||||
input.connect(split);
|
||||
split.connect(gLL, 0); split.connect(gRL, 0);
|
||||
split.connect(gLR, 1); split.connect(gRR, 1);
|
||||
gLL.connect(merge, 0, 0); gRL.connect(merge, 0, 0);
|
||||
gLR.connect(merge, 0, 1); gRR.connect(merge, 0, 1);
|
||||
merge.connect(output);
|
||||
nodes = { gLL, gRL, gLR, gRR };
|
||||
} else {
|
||||
// 'eq' or default: 4-band EQ
|
||||
const f1 = ctx.createBiquadFilter(); f1.type = 'lowshelf'; f1.frequency.value = 100;
|
||||
const f2 = ctx.createBiquadFilter(); f2.type = 'peaking'; f2.frequency.value = 800; f2.Q.value = 0.7;
|
||||
const f3 = ctx.createBiquadFilter(); f3.type = 'peaking'; f3.frequency.value = 3200; f3.Q.value = 1.2;
|
||||
const f4 = ctx.createBiquadFilter(); f4.type = 'highshelf'; f4.frequency.value = 10000;
|
||||
input.connect(f1); f1.connect(f2); f2.connect(f3); f3.connect(f4); f4.connect(output);
|
||||
nodes = { f1, f2, f3, f4 };
|
||||
}
|
||||
return { input, output, nodes, type };
|
||||
}
|
||||
|
||||
// Dynamic signal-chain reconstruction (mastering_expand.md §II.3):
|
||||
// disconnect every module boundary, then wire the ACTIVE modules in series
|
||||
// between inputAnalyser (chain input) and outputAnalyser (chain output).
|
||||
function rebuildMasteringGraph(activate, chainArray) {
|
||||
if (!masterBus) return;
|
||||
const active = !!(activate);
|
||||
try {
|
||||
// 1. Disconnect all module boundary outputs (breaks static + previous dynamic links)
|
||||
masterBus.inputAnalyser.disconnect();
|
||||
masterBus.eqHighFilter.disconnect();
|
||||
masterBus.imagerOutput.disconnect();
|
||||
masterBus.maximizerCompressor.disconnect();
|
||||
masterBus.compOutput.disconnect();
|
||||
masterBus.limOutput.disconnect();
|
||||
masterBus.excOutput.disconnect();
|
||||
masterBus.rebalOutput.disconnect();
|
||||
|
||||
if (!active) {
|
||||
// 2a. No mastering → straight chain: inputAnalyser → outputAnalyser
|
||||
masterBus.inputAnalyser.connect(masterBus.outputAnalyser);
|
||||
masterBus.masteringActive = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 2b. Filter active modules
|
||||
const activeMods = (chainArray || []).filter(m => m && m.active);
|
||||
if (activeMods.length === 0) {
|
||||
masterBus.inputAnalyser.connect(masterBus.outputAnalyser);
|
||||
masterBus.masteringActive = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Wire in series: Input → mod[0].input → mod[0].output → mod[1].input → … → Output
|
||||
let prev = masterBus.inputAnalyser;
|
||||
activeMods.forEach(mod => {
|
||||
const io = MASTER_MODULE_IO[mod.type] || MASTER_MODULE_IO.eq;
|
||||
const inNode = masterBus[io.input];
|
||||
if (inNode) prev.connect(inNode);
|
||||
prev = masterBus[io.output] || prev;
|
||||
});
|
||||
prev.connect(masterBus.outputAnalyser);
|
||||
masterBus.masteringActive = true;
|
||||
} catch (e) {
|
||||
console.warn('rebuildMasteringGraph error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMasteringOnMaster(activate, isBypassed) {
|
||||
if (!masterBus) return;
|
||||
|
||||
const active = !!(activate && !isBypassed);
|
||||
// Idempotent: don't disconnect/reconnect the mastering chain on every call.
|
||||
if (_lastMasteringActive === active && masterBus.masteringActive === active) return;
|
||||
const chain = (window.currentMasteringSettings && window.currentMasteringSettings.chain) || DEFAULT_MASTER_CHAIN;
|
||||
const cSig = chainSignature(chain);
|
||||
// Idempotent: don't rebuild unless active state OR chain layout changed.
|
||||
if (_lastMasteringActive === active && masterBus.masteringActive === active && _lastChainSig === cSig) return;
|
||||
_lastMasteringActive = active;
|
||||
_lastChainSig = cSig;
|
||||
|
||||
// Disconnect + immediately reconnect in one synchronous block so the master
|
||||
// routing can NEVER be left broken (a mid-swap exception would otherwise
|
||||
// disconnect inputAnalyser and silence ALL audio globally).
|
||||
try {
|
||||
masterBus.inputAnalyser.disconnect();
|
||||
masterBus.maximizerCompressor.disconnect();
|
||||
if (active) {
|
||||
masterBus.inputAnalyser.connect(masterBus.eqLowFilter);
|
||||
masterBus.maximizerCompressor.connect(masterBus.outputAnalyser);
|
||||
masterBus.masteringActive = true;
|
||||
} else {
|
||||
masterBus.inputAnalyser.connect(masterBus.outputAnalyser);
|
||||
masterBus.masteringActive = false;
|
||||
}
|
||||
rebuildMasteringGraph(active, chain);
|
||||
} catch (e) {
|
||||
console.warn('toggleMasteringOnMaster error:', e);
|
||||
// Restore a guaranteed-valid default routing regardless of the failure.
|
||||
@@ -1238,6 +1456,7 @@ const TrackStripConsole = ({ track, index, onUpdateTrack, trackVuRefs }) => {
|
||||
const [pan, setPan] = React.useState(0.0);
|
||||
const [panLabel, setPanLabel] = React.useState('center');
|
||||
const [isPhaseInverted, setIsPhaseInverted] = React.useState(false);
|
||||
const [fxChainOpen, setFxChainOpen] = React.useState(false);
|
||||
const [isFxActive, setIsFxActive] = React.useState(true);
|
||||
const panPointerRef = React.useRef(null);
|
||||
const setVuCanvas = React.useCallback(function(el) {
|
||||
@@ -1245,6 +1464,13 @@ const TrackStripConsole = ({ track, index, onUpdateTrack, trackVuRefs }) => {
|
||||
else delete trackVuRefs.current[track.id + '_mixer'];
|
||||
}, [track.id, trackVuRefs]);
|
||||
|
||||
// Track FX chain editor (mastering_expand.md §II.4): reuse the same module
|
||||
// types as the mastering suite inside this track's FX chain.
|
||||
var FX_MODULE_TYPES = ['compressor', 'limiter', 'exciter', 'rebalance', 'eq'];
|
||||
const updateFxChain = function(nextChain) {
|
||||
if (onUpdateTrack) onUpdateTrack(track.id, { fxChain: nextChain });
|
||||
};
|
||||
|
||||
const handlePanPointerDown = (e) => {
|
||||
e.currentTarget._panStartY = e.clientY;
|
||||
e.currentTarget._startPan = pan;
|
||||
@@ -1268,9 +1494,10 @@ const TrackStripConsole = ({ track, index, onUpdateTrack, trackVuRefs }) => {
|
||||
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"
|
||||
},
|
||||
return React.createElement(React.Fragment, null,
|
||||
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",
|
||||
@@ -1378,8 +1605,9 @@ const TrackStripConsole = ({ track, index, onUpdateTrack, trackVuRefs }) => {
|
||||
title: "Bypass: track KHÔNG qua FX + mastering ở Main out"
|
||||
}, 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"
|
||||
onClick: function() { setFxChainOpen(true); },
|
||||
className: "btn-daw h-[24px] rounded text-slate-300 font-semibold flex items-center justify-center text-[7px]" + ((track.fxChain || []).length > 0 ? " text-cyan-400" : ""),
|
||||
title: "Track FX Chain (mastering_expand.md §II.4)"
|
||||
}, "FX"),
|
||||
React.createElement("button", {
|
||||
onClick: function() { setIsFxActive(function(p) { return !p; }); },
|
||||
@@ -1437,6 +1665,64 @@ const TrackStripConsole = ({ track, index, onUpdateTrack, trackVuRefs }) => {
|
||||
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)
|
||||
),
|
||||
fxChainOpen && React.createElement("div", {
|
||||
className: "fixed inset-0 z-[120] bg-black/70 backdrop-blur-sm flex items-center justify-center p-4",
|
||||
onClick: function() { setFxChainOpen(false); }
|
||||
},
|
||||
React.createElement("div", {
|
||||
className: "w-full max-w-md bg-slate-900 border border-slate-700 rounded-2xl p-5 space-y-4 shadow-2xl text-slate-200",
|
||||
onClick: function(e) { e.stopPropagation(); }
|
||||
},
|
||||
React.createElement("div", { className: "flex items-center justify-between border-b border-slate-800 pb-3" },
|
||||
React.createElement("h3", { className: "text-xs font-bold text-white uppercase tracking-wider font-mono" },
|
||||
"FX CHAIN — " + trackName
|
||||
),
|
||||
React.createElement("button", { onClick: function() { setFxChainOpen(false); }, className: "text-slate-400 hover:text-white" },
|
||||
React.createElement("i", { className: "fa-solid fa-xmark" })
|
||||
)
|
||||
),
|
||||
React.createElement("div", { className: "space-y-2" },
|
||||
((track.fxChain || []).length === 0) && React.createElement("div", { className: "text-[11px] text-slate-500 font-mono py-2" },
|
||||
"Chưa có FX. Thêm module bên dưới (dùng chung DSP với Mastering Suite)."
|
||||
),
|
||||
(track.fxChain || []).map(function(m, idx) {
|
||||
var fxName = { compressor: 'Bus Compressor', limiter: 'Brickwall Limiter', exciter: 'Harmonic Exciter', rebalance: 'Master Rebalance', eq: 'EQ 4-Band' }[m.type || m] || (m.type || m);
|
||||
return React.createElement("div", { key: idx, className: "flex items-center justify-between bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-xs" },
|
||||
React.createElement("span", { className: "font-mono text-slate-200" }, (idx + 1) + ". " + fxName),
|
||||
React.createElement("div", { className: "flex items-center gap-2" },
|
||||
React.createElement("button", {
|
||||
onClick: function() {
|
||||
var next = (track.fxChain || []).map(function(x, i) { return i === idx ? { ...x, active: !(x.active !== false) } : x; });
|
||||
updateFxChain(next);
|
||||
},
|
||||
className: "text-[10px] font-bold px-2 py-0.5 rounded border " + ((m.active !== false) ? "bg-cyan-700 border-cyan-500 text-white" : "bg-slate-800 border-slate-700 text-slate-400")
|
||||
}, (m.active !== false) ? "ON" : "OFF"),
|
||||
React.createElement("button", {
|
||||
onClick: function() {
|
||||
var next = (track.fxChain || []).filter(function(x, i) { return i !== idx; });
|
||||
updateFxChain(next);
|
||||
},
|
||||
className: "text-slate-500 hover:text-red-400 px-1"
|
||||
}, React.createElement("i", { className: "fa-solid fa-trash" }))
|
||||
)
|
||||
);
|
||||
})
|
||||
),
|
||||
React.createElement("div", { className: "border-t border-slate-800 pt-3" },
|
||||
React.createElement("span", { className: "text-[10px] font-bold text-slate-500 uppercase tracking-widest font-mono block mb-2" }, "Thêm module:"),
|
||||
React.createElement("div", { className: "flex flex-wrap gap-1.5" },
|
||||
FX_MODULE_TYPES.map(function(t) {
|
||||
return React.createElement("button", {
|
||||
key: t,
|
||||
onClick: function() { updateFxChain([...(track.fxChain || []), { type: t, active: true }]); },
|
||||
className: "px-2 py-1 rounded border border-slate-700 text-[10px] font-bold text-slate-300 hover:border-cyan-500 hover:text-cyan-300 transition-colors"
|
||||
}, { compressor: 'Comp', limiter: 'Limiter', exciter: 'Exciter', rebalance: 'M/S', eq: 'EQ' }[t]);
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
const WaveformLane = ({
|
||||
@@ -4931,7 +5217,12 @@ const ProfileModal = ({
|
||||
maxSoftClip: 15,
|
||||
maxTransient: 25,
|
||||
ceiling: -0.1,
|
||||
isBypassed: false
|
||||
isBypassed: false,
|
||||
chain: DEFAULT_MASTER_CHAIN.map(m => ({ ...m })),
|
||||
compActive: false, compThreshold: -16, compRatio: 3, compMakeup: 0,
|
||||
limActive: false, limThreshold: -1.0,
|
||||
excActive: false, excDrive: 40,
|
||||
rebalActive: false, rebalMid: 0, rebalSide: 0,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -8142,6 +8433,7 @@ const serializeTracksList = (tracksList, secondsPerBar) => {
|
||||
soundfont_program: t.soundfont_program !== undefined ? t.soundfont_program : (t.synth_engine ? t.synth_engine.soundfont_program : null),
|
||||
synth_engine: t.synth_engine || undefined,
|
||||
midi_channel: t.midiChannel !== undefined ? t.midiChannel : null,
|
||||
fx_chain: (t.fxChain || []).map(m => typeof m === 'string' ? { type: m, active: true } : { type: m.type, active: m.active !== false }),
|
||||
server_file_id: t.serverFileId || null,
|
||||
items: items
|
||||
};
|
||||
@@ -8230,7 +8522,8 @@ const deserializeTracksList = (schemaTracks, secondsPerBar, sectionStore) => {
|
||||
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
|
||||
synth_engine: t.synth_engine || undefined,
|
||||
fxChain: (t.fx_chain || []).map(m => typeof m === 'string' ? { type: m, active: true } : { type: m.type || m, active: m.active !== false }),
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -8392,15 +8685,32 @@ const deserializeProjectFromSchema = (schemaObj) => {
|
||||
// saved after the migration carry `imagerScale: 'v2'` and are kept as-is.
|
||||
const _migrateMasteringSettings = (ms) => {
|
||||
if (!ms) return null;
|
||||
if (ms.imagerScale === 'v2') return ms;
|
||||
return {
|
||||
...ms,
|
||||
w1: (ms.w1 ?? 0) + 100,
|
||||
w2: (ms.w2 ?? 0) + 100,
|
||||
w3: (ms.w3 ?? 0) + 100,
|
||||
w4: (ms.w4 ?? 0) + 100,
|
||||
imagerScale: 'v2'
|
||||
};
|
||||
const migrated = ms.imagerScale === 'v2'
|
||||
? { ...ms }
|
||||
: {
|
||||
...ms,
|
||||
w1: (ms.w1 ?? 0) + 100,
|
||||
w2: (ms.w2 ?? 0) + 100,
|
||||
w3: (ms.w3 ?? 0) + 100,
|
||||
w4: (ms.w4 ?? 0) + 100,
|
||||
imagerScale: 'v2'
|
||||
};
|
||||
// mastering_expand.md: extension modules + dynamic chain (default = old chain order)
|
||||
if (!Array.isArray(migrated.chain)) {
|
||||
migrated.chain = DEFAULT_MASTER_CHAIN.map(m => ({ ...m }));
|
||||
}
|
||||
if (migrated.compActive === undefined) migrated.compActive = false;
|
||||
if (migrated.compThreshold === undefined) migrated.compThreshold = -16;
|
||||
if (migrated.compRatio === undefined) migrated.compRatio = 3;
|
||||
if (migrated.compMakeup === undefined) migrated.compMakeup = 0;
|
||||
if (migrated.limActive === undefined) migrated.limActive = false;
|
||||
if (migrated.limThreshold === undefined) migrated.limThreshold = -1.0;
|
||||
if (migrated.excActive === undefined) migrated.excActive = false;
|
||||
if (migrated.excDrive === undefined) migrated.excDrive = 40;
|
||||
if (migrated.rebalActive === undefined) migrated.rebalActive = false;
|
||||
if (migrated.rebalMid === undefined) migrated.rebalMid = 0;
|
||||
if (migrated.rebalSide === undefined) migrated.rebalSide = 0;
|
||||
return migrated;
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -8937,10 +9247,122 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Hooks MUST be declared before the early return below — React requires a
|
||||
// stable hook count across renders (error #310 otherwise).
|
||||
const dragChainIndexRef = React.useRef(null);
|
||||
const [addModuleOpen, setAddModuleOpen] = React.useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const switchModule = (name) => setOzState(prev => ({ ...prev, activeModule: name }));
|
||||
|
||||
// ── Mastering expand: dynamic module chain (mastering_expand.md §II.3) ──
|
||||
const MODULE_META = {
|
||||
eq: { name: 'Dynamic EQ', sub: '4-Band Peak', icon: 'activity', color: '#22d3ee' },
|
||||
imager: { name: 'Imager', sub: '4-Band Width', icon: 'radio', color: '#a855f7' },
|
||||
maximizer: { name: 'Maximizer', sub: 'IRC IV True Peak', icon: 'gauge', color: '#34d399' },
|
||||
compressor: { name: 'Bus Compressor', sub: 'Glue & Punch', icon: 'compress', color: '#fbbf24' },
|
||||
limiter: { name: 'Brickwall Limiter', sub: 'True-Peak 20:1', icon: 'shield-half', color: '#f43f5e' },
|
||||
exciter: { name: 'Harmonic Exciter', sub: 'Saturation & Air', icon: 'wand-2', color: '#c084fc' },
|
||||
rebalance: { name: 'Master Rebalance', sub: 'M/S Balance', icon: 'sliders-horizontal', color: '#38bdf8' }
|
||||
};
|
||||
const chainFlag = (type) => type === 'eq' ? 'eqActive' : type === 'imager' ? 'imagerActive' : type === 'maximizer' ? 'maximizerActive' : type === 'compressor' ? 'compActive' : type === 'limiter' ? 'limActive' : type === 'exciter' ? 'excActive' : 'rebalActive';
|
||||
const chainActive = (type) => !!ozState[chainFlag(type)];
|
||||
|
||||
const toggleChainModule = (modId) => {
|
||||
setOzState(prev => {
|
||||
const chain = prev.chain.map(m => {
|
||||
if (m.id !== modId) return m;
|
||||
const next = !m.active;
|
||||
return { ...m, active: next };
|
||||
});
|
||||
const flags = {};
|
||||
chain.forEach(m => { flags[chainFlag(m.type)] = !!m.active; });
|
||||
return { ...prev, chain, ...flags };
|
||||
});
|
||||
};
|
||||
const removeChainModule = (modId) => {
|
||||
setOzState(prev => {
|
||||
let chain = prev.chain.filter(m => m.id !== modId);
|
||||
if (chain.length === 0) chain = DEFAULT_MASTER_CHAIN.map(m => ({ ...m })); // keep ≥1
|
||||
const flags = {};
|
||||
chain.forEach(m => { flags[chainFlag(m.type)] = !!m.active; });
|
||||
const activeModule = prev.activeModule;
|
||||
return { ...prev, chain, ...flags, activeModule: chain.some(m => m.type === activeModule) ? activeModule : chain[chain.length - 1].type };
|
||||
});
|
||||
};
|
||||
const reorderChain = (fromIdx, toIdx) => {
|
||||
if (fromIdx === toIdx) return;
|
||||
setOzState(prev => {
|
||||
const chain = [...prev.chain];
|
||||
const moved = chain.splice(fromIdx, 1)[0];
|
||||
chain.splice(toIdx, 0, moved);
|
||||
return { ...prev, chain };
|
||||
});
|
||||
};
|
||||
const addModuleToChain = (type) => {
|
||||
const meta = MODULE_META[type];
|
||||
const id = 'mod_' + type + '_' + Date.now();
|
||||
setOzState(prev => ({
|
||||
...prev,
|
||||
chain: [...(prev.chain || []), { id, type, name: meta.name, active: true }],
|
||||
[chainFlag(type)]: true,
|
||||
activeModule: type
|
||||
}));
|
||||
setAddModuleOpen(false);
|
||||
};
|
||||
|
||||
// ── EQ Preset Library (mastering_expand.md §II.1) ──
|
||||
const EQ_PRESET_LIBRARY = {
|
||||
flat: { name: 'Flat / Reset', bands: [
|
||||
{ id: 1, type: 'lowshelf', freq: 100, gain: 0.0, q: 0.7 },
|
||||
{ id: 2, type: 'peaking', freq: 800, gain: 0.0, q: 0.7 },
|
||||
{ id: 3, type: 'peaking', freq: 3200, gain: 0.0, q: 1.2 },
|
||||
{ id: 4, type: 'highshelf', freq: 10000, gain: 0.0, q: 0.7 }] },
|
||||
vocal_clarity: { name: 'Vocal Unmask & Clarity', bands: [
|
||||
{ id: 1, type: 'lowshelf', freq: 90, gain: -2.5, q: 0.7 },
|
||||
{ id: 2, type: 'peaking', freq: 500, gain: -1.8, q: 1.0 },
|
||||
{ id: 3, type: 'peaking', freq: 2800, gain: 3.2, q: 1.2 },
|
||||
{ id: 4, type: 'highshelf', freq: 12000, gain: 2.0, q: 0.7 }] },
|
||||
bass_punch: { name: 'EDM Low-End Punch', bands: [
|
||||
{ id: 1, type: 'lowshelf', freq: 80, gain: 4.0, q: 0.8 },
|
||||
{ id: 2, type: 'peaking', freq: 300, gain: -3.0, q: 1.4 },
|
||||
{ id: 3, type: 'peaking', freq: 4000, gain: 1.5, q: 1.0 },
|
||||
{ id: 4, type: 'highshelf', freq: 10000, gain: 1.0, q: 0.7 }] },
|
||||
warm_tape: { name: 'Warm Vintage Analog', bands: [
|
||||
{ id: 1, type: 'lowshelf', freq: 120, gain: 2.0, q: 0.6 },
|
||||
{ id: 2, type: 'peaking', freq: 1500, gain: 1.0, q: 0.5 },
|
||||
{ id: 3, type: 'peaking', freq: 5000, gain: -2.0, q: 1.0 },
|
||||
{ id: 4, type: 'highshelf', freq: 8000, gain: -3.0, q: 0.7 }] }
|
||||
};
|
||||
const applyEQPreset = (presetKey) => {
|
||||
const preset = EQ_PRESET_LIBRARY[presetKey];
|
||||
if (!preset) return;
|
||||
const bus = masterBus;
|
||||
if (bus && bus.eqLowFilter) {
|
||||
const now = audioCtx ? audioCtx.currentTime : 0;
|
||||
const filters = [bus.eqLowFilter, bus.eqMid1Filter, bus.eqMid2Filter, bus.eqHighFilter];
|
||||
preset.bands.forEach((bd, i) => {
|
||||
const f = filters[i];
|
||||
if (!f) return;
|
||||
try {
|
||||
f.frequency.setTargetAtTime(bd.freq, now, 0.02);
|
||||
f.gain.setTargetAtTime(bd.gain, now, 0.02);
|
||||
f.Q.setTargetAtTime(bd.q, now, 0.02);
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
// Sync UI knobs + canvas: [eqLowGain, eqMid1Gain, eqMid2Gain, eqHighGain]
|
||||
setOzState(prev => ({
|
||||
...prev,
|
||||
eqLowGain: preset.bands[0] ? preset.bands[0].gain : prev.eqLowGain,
|
||||
eqMid1Gain: preset.bands[1] ? preset.bands[1].gain : prev.eqMid1Gain,
|
||||
eqMid2Gain: preset.bands[2] ? preset.bands[2].gain : prev.eqMid2Gain,
|
||||
eqHighGain: preset.bands[3] ? preset.bands[3].gain : prev.eqHighGain,
|
||||
eqPreset: presetKey
|
||||
}));
|
||||
};
|
||||
|
||||
const bandKnob = (param, min, max, val, unit, label, freq, color, filterType) => (
|
||||
<div className="bg-slate-900/80 border border-slate-800 p-2 flex flex-col justify-between items-center rounded-lg">
|
||||
<div className="flex items-center justify-between text-[10px] font-bold w-full" style={{color}}>
|
||||
@@ -9013,52 +9435,44 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* MODULE CHAIN STRIP */}
|
||||
{/* MODULE CHAIN STRIP (dynamic — mastering_expand.md §II.3) */}
|
||||
<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>
|
||||
{(ozState.chain || []).map((mod, idx) => {
|
||||
const meta = MODULE_META[mod.type] || { name: mod.type, sub: '', icon: 'circle', color: '#94a3b8' };
|
||||
const isEditing = ozState.activeModule === mod.type;
|
||||
const isOn = chainActive(mod.type);
|
||||
return (
|
||||
<div key={mod.id}
|
||||
draggable
|
||||
onDragStart={e => { dragChainIndexRef.current = idx; e.dataTransfer.effectAllowed = 'move'; }}
|
||||
onDragOver={e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
|
||||
onDrop={e => { e.preventDefault(); const from = dragChainIndexRef.current; if (from !== null && from !== idx) reorderChain(from, idx); dragChainIndexRef.current = null; }}
|
||||
onClick={() => switchModule(mod.type)}
|
||||
className={`w-40 h-12 rounded-lg p-2 flex items-center justify-between cursor-pointer transition-all shrink-0 ${isEditing ? 'oz-card-active' : 'oz-card'}`}>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<button onClick={e => { e.stopPropagation(); toggleChainModule(mod.id); }} className="w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold shrink-0" style={{backgroundColor: isOn ? '#38bdf8' : '#334155', color: isOn ? '#0f172a' : '#94a3b8'}}>
|
||||
<i data-lucide="power" className="w-2.5 h-2.5"></i>
|
||||
</button>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-bold text-slate-200 truncate">{meta.name}</div>
|
||||
<div className="text-[9px] oz-font-mono truncate" style={{color: meta.color}}>{idx + 1}. {meta.sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<i data-lucide={meta.icon} className="w-3 h-3 text-slate-500"></i>
|
||||
<button onClick={e => { e.stopPropagation(); removeChainModule(mod.id); }} className="text-slate-600 hover:text-red-400 text-xs px-0.5" title="Xóa module">
|
||||
<i data-lucide="x" className="w-3 h-3"></i>
|
||||
</button>
|
||||
</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">
|
||||
<button onClick={() => setAddModuleOpen(true)} 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" title="Thêm module vào chain">
|
||||
<i data-lucide="plus" className="w-4 h-4"></i>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* MAIN WORKSPACE */}
|
||||
@@ -9078,6 +9492,19 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
{ozState.activeModule === 'eq' && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-slate-400">EQ Presets:</span>
|
||||
<select
|
||||
value={ozState.eqPreset || 'flat'}
|
||||
onChange={e => applyEQPreset(e.target.value)}
|
||||
className="bg-slate-950 border border-slate-700 text-cyan-300 rounded px-2 py-0.5 text-[11px] outline-none focus:border-cyan-500">
|
||||
{Object.keys(EQ_PRESET_LIBRARY).map(k => (
|
||||
<option key={k} value={k}>{EQ_PRESET_LIBRARY[k].name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
@@ -9204,6 +9631,107 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VIEW: BUS COMPRESSOR */}
|
||||
<div className={`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule === 'compressor' ? '' : '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-amber-400 uppercase oz-font-mono mb-3">Bus Compressor</span>
|
||||
<div className="my-2">
|
||||
<MasteringKnob
|
||||
param="compMakeup"
|
||||
min={0} max={12}
|
||||
value={ozState.compMakeup}
|
||||
unit="dB" label="MAKE-UP GAIN"
|
||||
color="#fbbf24"
|
||||
onChange={(p, v) => setOzState(prev => ({ ...prev, [p]: v }))}
|
||||
/>
|
||||
</div>
|
||||
<button onClick={() => toggleChainModule((ozState.chain || []).find(m => m.type === 'compressor')?.id)}
|
||||
className={`px-3 py-1 rounded text-[10px] font-bold border transition-colors mt-2 ${ozState.compActive ? 'bg-amber-700 border-amber-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300'}`}>
|
||||
{ozState.compActive ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
</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 justify-center h-full">
|
||||
<MasteringKnob param="compThreshold" min={-60} max={0} value={ozState.compThreshold} unit="dB" label="THRESHOLD" color="#fbbf24" onChange={(p, v) => setOzState(prev => ({ ...prev, [p]: v }))} />
|
||||
</div>
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
|
||||
<MasteringKnob param="compRatio" min={1} max={20} value={ozState.compRatio} unit=":1" label="RATIO" color="#f59e0b" onChange={(p, v) => setOzState(prev => ({ ...prev, [p]: v }))} />
|
||||
</div>
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
|
||||
<span className="text-[10px] text-slate-500 oz-font-mono text-center leading-snug px-2">Glue & Punch<br/>Attack 20ms · Release 250ms · Knee 8dB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VIEW: BRICKWALL LIMITER */}
|
||||
<div className={`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule === 'limiter' ? '' : '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-rose-400 uppercase oz-font-mono mb-3">Brickwall Limiter</span>
|
||||
<button onClick={() => toggleChainModule((ozState.chain || []).find(m => m.type === 'limiter')?.id)}
|
||||
className={`px-3 py-1 rounded text-[10px] font-bold border transition-colors mt-2 ${ozState.limActive ? 'bg-rose-700 border-rose-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300'}`}>
|
||||
{ozState.limActive ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
</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 justify-center h-full">
|
||||
<MasteringKnob param="limThreshold" min={-24} max={0} value={ozState.limThreshold} unit="dB" label="CEILING" color="#f43f5e" onChange={(p, v) => setOzState(prev => ({ ...prev, [p]: v }))} />
|
||||
</div>
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
|
||||
<span className="text-[10px] text-slate-500 oz-font-mono text-center leading-snug px-2">True-Peak limiting<br/>Ratio 20:1 · Knee 0dB<br/>Attack 1ms · Release 50ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VIEW: HARMONIC EXCITER */}
|
||||
<div className={`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule === 'exciter' ? '' : '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-purple-400 uppercase oz-font-mono mb-3">Harmonic Exciter</span>
|
||||
<button onClick={() => toggleChainModule((ozState.chain || []).find(m => m.type === 'exciter')?.id)}
|
||||
className={`px-3 py-1 rounded text-[10px] font-bold border transition-colors mt-2 ${ozState.excActive ? 'bg-purple-700 border-purple-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300'}`}>
|
||||
{ozState.excActive ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
</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 justify-center h-full">
|
||||
<MasteringKnob param="excDrive" min={0} max={100} value={ozState.excDrive} unit="%" label="DRIVE / MIX" color="#c084fc" onChange={(p, v) => setOzState(prev => ({ ...prev, [p]: v }))} />
|
||||
</div>
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
|
||||
<span className="text-[10px] text-slate-500 oz-font-mono text-center leading-snug px-2">WaveShaper saturation<br/>High-pass 2kHz<br/>4× oversampled · wet/dry mix</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VIEW: MASTER REBALANCE */}
|
||||
<div className={`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule === 'rebalance' ? '' : '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-sky-400 uppercase oz-font-mono mb-3">Master Rebalance (M/S)</span>
|
||||
<button onClick={() => toggleChainModule((ozState.chain || []).find(m => m.type === 'rebalance')?.id)}
|
||||
className={`px-3 py-1 rounded text-[10px] font-bold border transition-colors mt-2 ${ozState.rebalActive ? 'bg-sky-700 border-sky-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300'}`}>
|
||||
{ozState.rebalActive ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
</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 justify-center h-full">
|
||||
<MasteringKnob param="rebalMid" min={-12} max={12} value={ozState.rebalMid} unit="dB" label="MID GAIN" color="#38bdf8" onChange={(p, v) => setOzState(prev => ({ ...prev, [p]: v }))} />
|
||||
</div>
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
|
||||
<MasteringKnob param="rebalSide" min={-12} max={12} value={ozState.rebalSide} unit="dB" label="SIDE GAIN" color="#22d3ee" onChange={(p, v) => setOzState(prev => ({ ...prev, [p]: v }))} />
|
||||
</div>
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
|
||||
<span className="text-[10px] text-slate-500 oz-font-mono text-center leading-snug px-2">ChannelSplitter + M/S gains<br/>Center (vocal/bass) vs Sides (stereo width)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* WAVE OBSERVER INTEGRATION */}
|
||||
<div className="border-t border-slate-800 bg-slate-900/60 p-3 flex flex-col shrink-0">
|
||||
{/* Header */}
|
||||
@@ -9336,6 +9864,34 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* ADD MODULE POPUP (mastering_expand.md §III) */}
|
||||
{addModuleOpen && (
|
||||
<div className="fixed inset-0 z-[110] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4" onClick={() => setAddModuleOpen(false)}>
|
||||
<div className="w-full max-w-lg bg-slate-900 border border-slate-700 rounded-2xl p-5 space-y-4 shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-3">
|
||||
<h3 className="text-xs font-bold text-white uppercase tracking-wider oz-font-mono">THÊM MODULE VÀO MASTERING CHAIN</h3>
|
||||
<button onClick={() => setAddModuleOpen(false)} className="text-slate-400 hover:text-white"><i data-lucide="x" className="w-4 h-4"></i></button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
{[
|
||||
{ t: 'compressor', c: 'text-amber-400', i: 'compress', d: 'Nén dynamic range, glue & punch cho master.' },
|
||||
{ t: 'limiter', c: 'text-rose-400', i: 'shield-half', d: 'True-Peak ceiling (ratio 20:1, knee 0) chống clipping.' },
|
||||
{ t: 'exciter', c: 'text-purple-400', i: 'wand-2', d: 'Saturation hài cho warmth và top-end brilliance.' },
|
||||
{ t: 'rebalance', c: 'text-sky-400', i: 'sliders-horizontal', d: 'Cân bằng Mid/Side (Vocal/Bass vs stereo width).' },
|
||||
{ t: 'eq', c: 'text-cyan-400', i: 'activity', d: 'EQ 4-band (lowshelf, 2× peaking, highshelf) + presets.' },
|
||||
{ t: 'imager', c: 'text-fuchsia-400', i: 'radio', d: 'Stereo width 4-band M/S + vectorscope/correlation.' },
|
||||
{ t: 'maximizer', c: 'text-emerald-400', i: 'gauge', d: 'Maximizer: boost, soft clip, upward comp, ceiling.' }
|
||||
].map(m => (
|
||||
<button key={m.t} onClick={() => addModuleToChain(m.t)} className="p-3 bg-slate-950 hover:bg-slate-800 border border-slate-800 rounded-xl text-left space-y-1 transition-colors">
|
||||
<div className={`font-bold ${m.c} flex items-center gap-1.5`}><i data-lucide={m.i} className="w-3.5 h-3.5"></i> {MODULE_META[m.t].name}</div>
|
||||
<div className="text-[10px] text-slate-400">{m.d}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -12565,7 +13121,12 @@ const App = () => {
|
||||
maxSoftClip: 15,
|
||||
maxTransient: 25,
|
||||
ceiling: -0.1,
|
||||
isBypassed: false
|
||||
isBypassed: false,
|
||||
chain: DEFAULT_MASTER_CHAIN.map(m => ({ ...m })),
|
||||
compActive: false, compThreshold: -16, compRatio: 3, compMakeup: 0,
|
||||
limActive: false, limThreshold: -1.0,
|
||||
excActive: false, excDrive: 40,
|
||||
rebalActive: false, rebalMid: 0, rebalSide: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -12800,7 +13361,12 @@ const App = () => {
|
||||
maxSoftClip: 15,
|
||||
maxTransient: 25,
|
||||
ceiling: -0.1,
|
||||
isBypassed: false
|
||||
isBypassed: false,
|
||||
chain: DEFAULT_MASTER_CHAIN.map(m => ({ ...m })),
|
||||
compActive: false, compThreshold: -16, compRatio: 3, compMakeup: 0,
|
||||
limActive: false, limThreshold: -1.0,
|
||||
excActive: false, excDrive: 40,
|
||||
rebalActive: false, rebalMid: 0, rebalSide: 0,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -15871,18 +16437,31 @@ const App = () => {
|
||||
gainNode.connect(route.dryGain);
|
||||
|
||||
let fxStopFn;
|
||||
// Track FX chain (mastering_expand.md §II.4): reusable module instances
|
||||
// in series, inserted BEFORE the legacy chorus/reverb single FX.
|
||||
let fxChainTail = gainNode;
|
||||
const fxChain = track.fxChain || [];
|
||||
if (fxChain.length > 0) {
|
||||
const chainMods = fxChain.filter(m => m && m.active !== false).map(m => {
|
||||
try { return createTrackFxModule(m.type || m, context); } catch (e) { return null; }
|
||||
}).filter(Boolean);
|
||||
chainMods.forEach(mod => {
|
||||
fxChainTail.connect(mod.input);
|
||||
fxChainTail = mod.output;
|
||||
});
|
||||
}
|
||||
if (track.fxType === 'chorus') {
|
||||
const fxInput = context.createGain();
|
||||
gainNode.connect(fxInput);
|
||||
fxChainTail.connect(fxInput);
|
||||
const chorus = createChorusNode(context, fxInput, pannerNode);
|
||||
fxStopFn = chorus.stop;
|
||||
} else if (track.fxType === 'reverb') {
|
||||
const fxInput = context.createGain();
|
||||
gainNode.connect(fxInput);
|
||||
fxChainTail.connect(fxInput);
|
||||
createReverbNode(context, fxInput, pannerNode);
|
||||
fxStopFn = null;
|
||||
} else {
|
||||
gainNode.connect(pannerNode);
|
||||
fxChainTail.connect(pannerNode);
|
||||
}
|
||||
node = { gainNode, pannerNode, fxStopFn, analyserNode, route };
|
||||
// Realtime mute/solo: apply the track's current mute/solo/volume state to
|
||||
|
||||
Reference in New Issue
Block a user