FIX: hiển thị MASTERING PANEL và các modules không bị lỗi

This commit is contained in:
2026-08-03 18:29:34 +07:00
parent ed91e4534c
commit 8c1a8ead56
4 changed files with 737 additions and 102 deletions
+661 -82
View File
@@ -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=(midside)/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
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -24,7 +24,7 @@
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
<script src="/static/js/app.precompiled.js?v=202608032600" defer></script>
<script src="/static/js/app.precompiled.js?v=202608033000" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style>
:root {