FEAT: thêm FX RACK PANEL cho track, áp dụng cho midi item và audioclip item
This commit is contained in:
+582
-85
@@ -585,20 +585,27 @@ function chainSignature(chainArray) {
|
||||
// ── 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) {
|
||||
// ── Track FX module factory (mastering_expand.md §II.4 / unified_fx_rack_panel.md) ──
|
||||
// The SAME module DSP used in the mastering chain, instantiated per-track for
|
||||
// the [FX] button on track strips. `params` are stored per chain entry so the
|
||||
// unified FX Rack panel edits them declaratively. Returns { input, output, nodes }.
|
||||
function createTrackFxModule(type, ctx, params) {
|
||||
const input = ctx.createGain();
|
||||
const output = ctx.createGain();
|
||||
const p = params || {};
|
||||
let nodes = {};
|
||||
if (type === 'compressor') {
|
||||
const comp = ctx.createDynamicsCompressor();
|
||||
comp.threshold.value = -16; comp.knee.value = 8; comp.ratio.value = 3;
|
||||
comp.threshold.value = p.threshold !== undefined ? p.threshold : -16;
|
||||
comp.knee.value = 8; comp.ratio.value = p.ratio !== undefined ? p.ratio : 3;
|
||||
comp.attack.value = 0.02; comp.release.value = 0.25;
|
||||
const makeup = ctx.createGain(); makeup.gain.value = 1.0;
|
||||
const makeup = ctx.createGain(); makeup.gain.value = p.makeup !== undefined ? Math.pow(10, p.makeup / 20) : 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.threshold.value = p.ceiling !== undefined ? p.ceiling : -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 };
|
||||
@@ -608,7 +615,7 @@ function createTrackFxModule(type, ctx) {
|
||||
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;
|
||||
const wet = ctx.createGain(); wet.gain.value = ((p.drive !== undefined ? p.drive : 40) / 100) * 0.6;
|
||||
input.connect(dry); dry.connect(output);
|
||||
input.connect(hp); hp.connect(shaper); shaper.connect(wet); wet.connect(output);
|
||||
nodes = { hp, shaper, dry, wet };
|
||||
@@ -616,6 +623,10 @@ function createTrackFxModule(type, ctx) {
|
||||
const split = ctx.createChannelSplitter(2);
|
||||
const merge = ctx.createChannelMerger(2);
|
||||
const gLL = ctx.createGain(), gRL = ctx.createGain(), gLR = ctx.createGain(), gRR = ctx.createGain();
|
||||
const midLin = Math.pow(10, (p.mid !== undefined ? p.mid : 0) / 20);
|
||||
const sideLin = Math.pow(10, (p.side !== undefined ? p.side : 0) / 20);
|
||||
const a = (midLin + sideLin) / 2, b = (midLin - sideLin) / 2;
|
||||
gLL.gain.value = a; gRR.gain.value = a; gRL.gain.value = b; gLR.gain.value = b;
|
||||
input.connect(split);
|
||||
split.connect(gLL, 0); split.connect(gRL, 0);
|
||||
split.connect(gLR, 1); split.connect(gRR, 1);
|
||||
@@ -624,17 +635,30 @@ function createTrackFxModule(type, ctx) {
|
||||
merge.connect(output);
|
||||
nodes = { gLL, gRL, gLR, gRR };
|
||||
} else {
|
||||
// 'eq' or default: 4-band EQ
|
||||
// 'eq' or default: 4-band EQ (params.g1..g4 = band gains in dB)
|
||||
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;
|
||||
f1.gain.value = p.g1 !== undefined ? p.g1 : 0;
|
||||
f2.gain.value = p.g2 !== undefined ? p.g2 : 0;
|
||||
f3.gain.value = p.g3 !== undefined ? p.g3 : 0;
|
||||
f4.gain.value = p.g4 !== undefined ? p.g4 : 0;
|
||||
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 };
|
||||
}
|
||||
|
||||
// Track-EQ preset library (unified_fx_rack_panel.md §III.1) — band gains only.
|
||||
const TRACK_EQ_PRESETS = {
|
||||
flat: { name: 'Flat / Reset', g: [0, 0, 0, 0] },
|
||||
vocal_clarity: { name: 'Vocal Unmask & Clarity', g: [-2.5, -1.8, 3.2, 2.0] },
|
||||
bass_punch: { name: 'EDM Low-End Punch', g: [4.0, -3.0, 1.5, 1.0] },
|
||||
warm_tape: { name: 'Warm Vintage Analog', g: [2.0, 1.0, -2.0, -3.0] },
|
||||
guitar_edge: { name: 'Guitar Cut & Presence', g: [-3.0, -2.0, 3.5, 1.5] }
|
||||
};
|
||||
|
||||
// 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).
|
||||
@@ -1456,7 +1480,6 @@ 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) {
|
||||
@@ -1494,8 +1517,7 @@ const TrackStripConsole = ({ track, index, onUpdateTrack, trackVuRefs }) => {
|
||||
document.addEventListener('pointerup', onUp);
|
||||
};
|
||||
|
||||
return React.createElement(React.Fragment, null,
|
||||
React.createElement("div", {
|
||||
return React.createElement("div", {
|
||||
className: "flex flex-col items-stretch w-[145px] shrink-0 strip-bg-track rounded-md text-slate-300 select-none shadow-2xl border border-slate-900 overflow-hidden"
|
||||
},
|
||||
/* 1. Top Track Color Accent Bar */
|
||||
@@ -1605,7 +1627,7 @@ 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", {
|
||||
onClick: function() { setFxChainOpen(true); },
|
||||
onClick: function() { if (window.__openFxRack) window.__openFxRack(track.id, track.name); },
|
||||
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"),
|
||||
@@ -1665,66 +1687,9 @@ 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 = ({
|
||||
track,
|
||||
zoom,
|
||||
@@ -8795,6 +8760,451 @@ const MasteringKnob = ({ param, min, max, value, unit, label, color, onChange, s
|
||||
);
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// UNIFIED FX RACK PANEL (unified_fx_rack_panel.md) — một panel dùng chung,
|
||||
// bind động vào track khi bấm [FX]; mọi thay đổi rebuild graph RIÊNG của track.
|
||||
// ──────────────────────────────────────────────
|
||||
const TRACK_FX_META = {
|
||||
eq: { name: 'EQ 4-Band', icon: 'activity', color: '#22d3ee', sub: '4-Band Peak' },
|
||||
compressor: { name: 'Bus Compressor', icon: 'compress', color: '#fbbf24', sub: 'Glue & Punch' },
|
||||
limiter: { name: 'Brickwall Limiter', icon: 'shield-half', color: '#f43f5e', sub: 'True-Peak 20:1' },
|
||||
exciter: { name: 'Harmonic Exciter', icon: 'wand-2', color: '#c084fc', sub: 'Saturation & Air' },
|
||||
rebalance: { name: 'Master Rebalance', icon: 'sliders-horizontal', color: '#38bdf8', sub: 'M/S Balance' }
|
||||
};
|
||||
const TRACK_FX_DEFAULTS = {
|
||||
eq: { g1: 0, g2: 0, g3: 0, g4: 0 },
|
||||
compressor: { threshold: -16, ratio: 3, makeup: 0 },
|
||||
limiter: { ceiling: -1.0 },
|
||||
exciter: { drive: 40 },
|
||||
rebalance: { mid: 0, side: 0 }
|
||||
};
|
||||
|
||||
const FXRackModal = ({ track, onUpdateTrack, onClose }) => {
|
||||
const [activeType, setActiveType] = React.useState('eq');
|
||||
const [addModuleOpen, setAddModuleOpen] = React.useState(false);
|
||||
const dragChainIndexRef = React.useRef(null);
|
||||
// Wave Observer scope state (unified_fx_rack_panel_update.md §III.3)
|
||||
const [scopeChannel, setScopeChannel] = React.useState('stereo');
|
||||
const [scopeMode, setScopeMode] = React.useState('waveform');
|
||||
const [scopeDuration, setScopeDuration] = React.useState(2.0);
|
||||
const [scopeZoom, setScopeZoom] = React.useState(0);
|
||||
const [scopePaused, setScopePaused] = React.useState(false);
|
||||
const scopeCanvasRef = React.useRef(null);
|
||||
const scopeMeterLRef = React.useRef(null);
|
||||
const scopeMeterRRef = React.useRef(null);
|
||||
const eqCurveRef = React.useRef(null);
|
||||
const scopeStateRef = React.useRef({ L: null, R: null, head: 0, len: 0, tmpL: null, tmpR: null, freq: null });
|
||||
|
||||
if (!track) return null;
|
||||
|
||||
const chain = track.fxChain || [];
|
||||
const setChain = (next) => {
|
||||
if (onUpdateTrack) onUpdateTrack(track.id, { fxChain: next });
|
||||
if (window.__rebuildTrackFxGraph) window.__rebuildTrackFxGraph(track.id);
|
||||
};
|
||||
const paramsOf = (m) => ({ ...(TRACK_FX_DEFAULTS[m.type] || {}), ...(m.params || {}) });
|
||||
const setParams = (idx, patch) => {
|
||||
const next = chain.map((m, i) => i === idx ? { ...m, params: { ...paramsOf(m), ...patch } } : m);
|
||||
setChain(next);
|
||||
};
|
||||
const toggleMod = (idx) => {
|
||||
const next = chain.map((m, i) => i === idx ? { ...m, active: !(m.active !== false) } : m);
|
||||
setChain(next);
|
||||
};
|
||||
const removeMod = (idx) => setChain(chain.filter((_, i) => i !== idx));
|
||||
const addMod = (type) => {
|
||||
setChain([...chain, { type, active: true, params: { ...(TRACK_FX_DEFAULTS[type] || {}) } }]);
|
||||
setActiveType(type);
|
||||
setAddModuleOpen(false);
|
||||
};
|
||||
const applyPreset = (key) => {
|
||||
const preset = TRACK_EQ_PRESETS[key];
|
||||
if (!preset) return;
|
||||
const idx = chain.findIndex(m => m.type === 'eq' && m.active !== false);
|
||||
if (idx >= 0) setParams(idx, { g1: preset.g[0], g2: preset.g[1], g3: preset.g[2], g4: preset.g[3] });
|
||||
};
|
||||
|
||||
const activeMod = chain.find(m => m.type === activeType) || chain[chain.length - 1] || null;
|
||||
const activeIdx = chain.findIndex(m => m === activeMod);
|
||||
const ap = activeMod ? paramsOf(activeMod) : {};
|
||||
|
||||
const slider = (label, val, min, max, step, color, onChange, fmt) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex justify-between text-[11px] font-mono">
|
||||
<span style={{ color, fontWeight: 700 }}>{label}</span>
|
||||
<span className="text-slate-300">{fmt ? fmt(val) : val}</span>
|
||||
</div>
|
||||
<input type="range" min={min} max={max} step={step || 0.1} value={val}
|
||||
onChange={e => onChange(parseFloat(e.target.value))}
|
||||
className="w-full h-1 cursor-pointer" style={{ accentColor: color }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Wave Observer real-time scope render loop ──
|
||||
React.useEffect(() => {
|
||||
const canvas = scopeCanvasRef.current;
|
||||
if (!canvas || !track) return;
|
||||
const st = scopeStateRef.current;
|
||||
let raf = null;
|
||||
const draw = () => {
|
||||
raf = requestAnimationFrame(draw);
|
||||
const w = canvas.width, h = canvas.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
// Grid
|
||||
ctx.strokeStyle = 'rgba(51, 65, 85, 0.35)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.font = '9px monospace';
|
||||
ctx.fillStyle = '#475569';
|
||||
for (let i = 1; i < 6; i++) {
|
||||
const y = (i / 6) * h;
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
|
||||
}
|
||||
ctx.fillText('+0.0 dB', 4, 12);
|
||||
ctx.fillText('-6.0 dB', 4, h / 2 - 4);
|
||||
ctx.fillText('-12 dB', 4, h - 6);
|
||||
const ana = window.__getTrackScopeAnalysers ? window.__getTrackScopeAnalysers(track.id) : null;
|
||||
if (!ana || scopePaused) {
|
||||
if (!ana) { ctx.fillStyle = '#334155'; ctx.font = '11px monospace'; ctx.fillText('Không có tín hiệu — bấm Play để xem waveform', w / 2 - 120, h / 2); }
|
||||
return;
|
||||
}
|
||||
const sr = ana.sr || 44100;
|
||||
const maxS = Math.floor(5 * sr);
|
||||
if (!st.L || st.L.length !== maxS) { st.L = new Float32Array(maxS); st.R = new Float32Array(maxS); st.head = 0; st.len = 0; }
|
||||
if (!st.tmpL || st.tmpL.length !== ana.L.fftSize) { st.tmpL = new Float32Array(ana.L.fftSize); st.tmpR = new Float32Array(ana.R.fftSize); }
|
||||
ana.L.getFloatTimeDomainData(st.tmpL);
|
||||
ana.R.getFloatTimeDomainData(st.tmpR);
|
||||
const nRead = st.tmpL.length;
|
||||
for (let i = 0; i < nRead; i++) { st.L[st.head] = st.tmpL[i]; st.R[st.head] = st.tmpR[i]; st.head = (st.head + 1) % maxS; }
|
||||
st.len = Math.min(st.len + nRead, maxS);
|
||||
const n = Math.min(st.len, Math.floor(scopeDuration * sr));
|
||||
if (n < 2) return;
|
||||
const amp = Math.pow(10, scopeZoom / 20);
|
||||
const getS = (i) => { // i in [0,n)
|
||||
const idx = (st.head - n + i + maxS) % maxS;
|
||||
const l = st.L[idx], r = st.R[idx];
|
||||
if (scopeChannel === 'left') return [l, null];
|
||||
if (scopeChannel === 'right') return [r, null];
|
||||
if (scopeChannel === 'mid') return [(l + r) / 2, null];
|
||||
if (scopeChannel === 'side') return [(l - r) / 2, null];
|
||||
return [l, r];
|
||||
};
|
||||
// Meters
|
||||
let pL = 0, pR = 0;
|
||||
for (let i = 0; i < n; i += 8) {
|
||||
const idx = (st.head - n + i + maxS) % maxS;
|
||||
const al = Math.abs(st.L[idx]), ar = Math.abs(st.R[idx]);
|
||||
if (al > pL) pL = al; if (ar > pR) pR = ar;
|
||||
}
|
||||
if (scopeMeterLRef.current) scopeMeterLRef.current.style.width = Math.min(100, pL * 150) + '%';
|
||||
if (scopeMeterRRef.current) scopeMeterRRef.current.style.width = Math.min(100, pR * 150) + '%';
|
||||
if (scopeMode === 'lissajous') {
|
||||
ctx.fillStyle = 'rgba(34, 211, 238, 0.55)';
|
||||
const stepN = Math.max(1, Math.floor(n / 700));
|
||||
for (let i = 0; i < n; i += stepN) {
|
||||
const idx = (st.head - n + i + maxS) % maxS;
|
||||
const x = w / 2 + st.L[idx] * amp * (w / 2);
|
||||
const y = h / 2 - st.R[idx] * amp * (h / 2);
|
||||
ctx.fillRect(x, y, 2, 2);
|
||||
}
|
||||
ctx.strokeStyle = '#0e7490';
|
||||
ctx.beginPath(); ctx.moveTo(w / 2, 0); ctx.lineTo(w / 2, h); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2); ctx.stroke();
|
||||
} else if (scopeMode === 'spectrum') {
|
||||
if (!st.freq || st.freq.length !== ana.L.frequencyBinCount) st.freq = new Uint8Array(ana.L.frequencyBinCount);
|
||||
ana.L.getByteFrequencyData(st.freq);
|
||||
const bins = st.freq.length;
|
||||
const bars = 96;
|
||||
const fMax = sr / 2;
|
||||
ctx.fillStyle = 'rgba(34, 211, 238, 0.85)';
|
||||
for (let b = 0; b < bars; b++) {
|
||||
const f0 = 20 * Math.pow(fMax / 20, b / bars);
|
||||
const f1 = 20 * Math.pow(fMax / 20, (b + 1) / bars);
|
||||
const bi0 = Math.max(0, Math.floor(f0 / fMax * bins));
|
||||
const bi1 = Math.min(bins - 1, Math.ceil(f1 / fMax * bins));
|
||||
let peak = 0;
|
||||
for (let k = bi0; k <= bi1; k++) { if (st.freq[k] > peak) peak = st.freq[k]; }
|
||||
const bh = (peak / 255) * (h - 8);
|
||||
const bx = (b / bars) * w;
|
||||
ctx.fillRect(bx, h - bh, Math.max(1, w / bars - 1), bh);
|
||||
}
|
||||
ctx.fillStyle = '#475569';
|
||||
ctx.fillText('20Hz', 4, h - 4);
|
||||
ctx.fillText('20kHz', w - 40, h - 4);
|
||||
} else {
|
||||
// waveform (stereo draws L cyan + R amber; single channel draws cyan)
|
||||
const stepX = w / n;
|
||||
ctx.lineWidth = 1.5;
|
||||
for (const [col, ch] of [['#22d3ee', 0], ['#fbbf24', 1]]) {
|
||||
if (ch === 1 && scopeChannel !== 'stereo') continue;
|
||||
ctx.strokeStyle = col;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < n; i++) {
|
||||
const [l, r] = getS(i);
|
||||
const v = ch === 0 ? l : r;
|
||||
if (v === null) continue;
|
||||
const x = i * stepX;
|
||||
const y = h / 2 - v * amp * (h / 2) * 0.9;
|
||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
};
|
||||
draw();
|
||||
return () => { if (raf) cancelAnimationFrame(raf); };
|
||||
}, [track && track.id, scopeChannel, scopeMode, scopeDuration, scopeZoom, scopePaused]);
|
||||
|
||||
// ── Interactive Module Vector Display: EQ response curve ──
|
||||
React.useEffect(() => {
|
||||
const cv = eqCurveRef.current;
|
||||
if (!cv) return;
|
||||
const w = cv.width = (cv.clientWidth || 300) * 2;
|
||||
const h = cv.height = 88 * 2;
|
||||
const ctx = cv.getContext('2d');
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
// background grid
|
||||
ctx.strokeStyle = 'rgba(51, 65, 85, 0.35)';
|
||||
for (let i = 1; i < 5; i++) { const y = (i / 5) * h; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
|
||||
const midY = h / 2;
|
||||
ctx.strokeStyle = 'rgba(148, 163, 184, 0.25)';
|
||||
ctx.beginPath(); ctx.moveTo(0, midY); ctx.lineTo(w, midY); ctx.stroke();
|
||||
// response approximation for 4 cascaded bands (log-domain)
|
||||
const bands = [
|
||||
{ type: 'lowshelf', f0: 100, gain: ap.g1 || 0, q: 0.7 },
|
||||
{ type: 'peaking', f0: 800, gain: ap.g2 || 0, q: 0.7 },
|
||||
{ type: 'peaking', f0: 3200, gain: ap.g3 || 0, q: 1.2 },
|
||||
{ type: 'highshelf', f0: 10000, gain: ap.g4 || 0, q: 0.7 }
|
||||
];
|
||||
const pts = [];
|
||||
const N = 120;
|
||||
for (let i = 0; i <= N; i++) {
|
||||
const f = 20 * Math.pow(20000 / 20, i / N);
|
||||
let db = 0;
|
||||
bands.forEach(b => {
|
||||
const lf = Math.log(f / b.f0);
|
||||
if (b.type === 'peaking') {
|
||||
db += b.gain / (1 + Math.pow(lf * b.q, 2));
|
||||
} else if (b.type === 'highshelf') {
|
||||
db += b.gain / 2 * (1 + (2 / Math.PI) * Math.atan(lf / (1 / b.q)));
|
||||
} else {
|
||||
db += b.gain / 2 * (1 - (2 / Math.PI) * Math.atan(lf / (1 / b.q)));
|
||||
}
|
||||
});
|
||||
const x = (i / N) * w;
|
||||
const y = midY - (db / 12) * (h / 2);
|
||||
pts.push([x, y]);
|
||||
}
|
||||
// fill
|
||||
ctx.beginPath();
|
||||
pts.forEach(([x, y], i) => i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y));
|
||||
ctx.lineTo(w, h); ctx.lineTo(0, h); ctx.closePath();
|
||||
ctx.fillStyle = 'rgba(34, 211, 238, 0.10)';
|
||||
ctx.fill();
|
||||
// curve
|
||||
ctx.beginPath();
|
||||
pts.forEach(([x, y], i) => i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y));
|
||||
ctx.strokeStyle = '#22d3ee';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
// dB labels
|
||||
ctx.fillStyle = '#475569';
|
||||
ctx.font = '9px monospace';
|
||||
ctx.fillText('+12 dB', 4, midY - h / 2 + 10);
|
||||
ctx.fillText('0 dB', 4, midY + 3);
|
||||
ctx.fillText('-12 dB', 4, midY + h / 2 - 4);
|
||||
ctx.fillText('20Hz', 4, h - 2);
|
||||
ctx.fillText('20kHz', w - 38, h - 2);
|
||||
}, [ap.g1, ap.g2, ap.g3, ap.g4, activeMod]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[115] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div className="w-full max-w-4xl bg-slate-900 border border-slate-700 rounded-2xl p-5 space-y-4 shadow-2xl flex flex-col max-h-[90vh] text-slate-200" onClick={e => e.stopPropagation()}>
|
||||
{/* HEADER */}
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-cyan-400 animate-pulse"></div>
|
||||
<h3 className="text-xs font-bold text-white uppercase tracking-wider font-mono">
|
||||
{track.name} — FX RACK PANEL
|
||||
</h3>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-white text-base"><i data-lucide="x" className="w-4 h-4"></i></button>
|
||||
</div>
|
||||
|
||||
{/* CHAIN RACK STRIP */}
|
||||
<div className="h-16 bg-slate-950 border border-slate-800 rounded-xl px-3 flex items-center gap-2 overflow-x-auto shrink-0 select-none">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mr-1 font-mono shrink-0">CHAIN:</span>
|
||||
{chain.length === 0 && <span className="text-[10px] text-slate-500 font-mono">Chưa có FX — bấm [+] để thêm (signal đi thẳng: Source → Fader)</span>}
|
||||
{chain.map((m, idx) => {
|
||||
const meta = TRACK_FX_META[m.type] || { name: m.type, icon: 'circle', color: '#94a3b8', sub: '' };
|
||||
const on = m.active !== false;
|
||||
return (
|
||||
<div key={idx}
|
||||
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) { const next = [...chain]; const mv = next.splice(from, 1)[0]; next.splice(idx, 0, mv); setChain(next); } dragChainIndexRef.current = null; }}
|
||||
onClick={() => setActiveType(m.type)}
|
||||
className={`w-36 h-12 rounded-lg p-2 flex items-center justify-between cursor-pointer transition-all shrink-0 ${activeType === m.type ? 'border-2 border-cyan-400 bg-slate-800' : 'bg-slate-900 border border-slate-800 hover:border-slate-600'}`}>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<button onClick={e => { e.stopPropagation(); toggleMod(idx); }} className="w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold shrink-0" style={{ backgroundColor: on ? '#38bdf8' : '#334155', color: on ? '#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] font-mono truncate" style={{ color: meta.color }}>{idx + 1}. {meta.sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={e => { e.stopPropagation(); removeMod(idx); }} className="text-slate-600 hover:text-red-400 text-xs px-0.5 shrink-0" title="Xóa module">
|
||||
<i data-lucide="x" className="w-3 h-3"></i>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button onClick={() => setAddModuleOpen(true)} className="w-16 h-12 rounded-lg border border-dashed border-slate-700 hover:border-cyan-500 flex items-center justify-center text-slate-500 hover:text-cyan-400 cursor-pointer transition-all bg-slate-900/40 shrink-0" title="Thêm module">
|
||||
<i data-lucide="plus" className="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* WORKSPACE CONTROLS */}
|
||||
<div className="flex-1 bg-slate-950 border border-slate-800 rounded-xl p-4 flex flex-col gap-4 min-h-[240px] overflow-y-auto oz-scrollbar">
|
||||
<div className="h-8 bg-slate-900/80 border-b border-slate-800/80 px-3 flex items-center justify-between text-xs font-mono rounded-t-lg shrink-0">
|
||||
<span className="text-slate-400">Active Module: <strong className="text-cyan-400">{activeMod ? (TRACK_FX_META[activeMod.type]?.name || activeMod.type) : '—'}</strong></span>
|
||||
{activeMod && activeMod.type === 'eq' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-400">EQ Preset:</span>
|
||||
<select value="flat" onChange={e => applyPreset(e.target.value)} className="bg-slate-950 border border-slate-700 text-cyan-300 rounded px-2 py-0.5 text-xs outline-none focus:border-cyan-500">
|
||||
{Object.keys(TRACK_EQ_PRESETS).map(k => <option key={k} value={k}>{TRACK_EQ_PRESETS[k].name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!activeMod && <div className="text-xs text-slate-500 font-mono text-center py-10">Chưa có module. Bấm [+] để thêm EQ / Compressor / Limiter / Exciter / Rebalance.</div>}
|
||||
|
||||
{activeMod && activeMod.type === 'eq' && activeIdx >= 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[['g1', 'BAND 1 (LOW)', '100 Hz', '#22d3ee'], ['g2', 'BAND 2 (MID LOW)', '800 Hz', '#fbbf24'], ['g3', 'BAND 3 (MID HIGH)', '3.2 kHz', '#a855f7'], ['g4', 'BAND 4 (HIGH)', '10 kHz', '#34d399']].map(([key, label, freq, color]) => (
|
||||
<div key={key} className="bg-slate-900/80 border border-slate-800 p-3 rounded-lg space-y-2">
|
||||
{slider(label, ap[key] !== undefined ? ap[key] : 0, -12, 12, 0.1, color, v => setParams(activeIdx, { [key]: v }), v => `${v > 0 ? '+' : ''}${v.toFixed(1)} dB`)}
|
||||
<div className="text-[9px] text-slate-500 font-mono text-center">{freq}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Interactive Module Vector Display Canvas (EQ response curve) */}
|
||||
<div className="bg-slate-900/80 border border-slate-800 rounded-lg p-2">
|
||||
<div className="text-[9px] text-slate-500 font-mono mb-1 flex justify-between"><span>VECTOR DISPLAY — EQ RESPONSE</span><span>20Hz – 20kHz</span></div>
|
||||
<canvas ref={eqCurveRef} className="w-full h-[88px] block"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeMod && activeMod.type === 'compressor' && activeIdx >= 0 && (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-slate-900/80 border border-slate-800 p-3 rounded-lg">{slider('THRESHOLD', ap.threshold, -60, 0, 0.5, '#fbbf24', v => setParams(activeIdx, { threshold: v }), v => `${v.toFixed(1)} dB`)}</div>
|
||||
<div className="bg-slate-900/80 border border-slate-800 p-3 rounded-lg">{slider('RATIO', ap.ratio, 1, 20, 0.5, '#f59e0b', v => setParams(activeIdx, { ratio: v }), v => `${v.toFixed(1)} : 1`)}</div>
|
||||
<div className="bg-slate-900/80 border border-slate-800 p-3 rounded-lg">{slider('MAKE-UP', ap.makeup, 0, 12, 0.1, '#f59e0b', v => setParams(activeIdx, { makeup: v }), v => `${v.toFixed(1)} dB`)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeMod && activeMod.type === 'limiter' && activeIdx >= 0 && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-slate-900/80 border border-slate-800 p-3 rounded-lg">{slider('CEILING', ap.ceiling, -24, 0, 0.1, '#f43f5e', v => setParams(activeIdx, { ceiling: v }), v => `${v.toFixed(1)} dB`)}</div>
|
||||
<div className="bg-slate-900/80 border border-slate-800 p-3 rounded-lg flex items-center justify-center text-[10px] text-slate-500 font-mono text-center leading-snug">True-Peak limiting<br/>Ratio 20:1 · Knee 0dB</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeMod && activeMod.type === 'exciter' && activeIdx >= 0 && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-slate-900/80 border border-slate-800 p-3 rounded-lg">{slider('DRIVE / MIX', ap.drive, 0, 100, 1, '#c084fc', v => setParams(activeIdx, { drive: v }), v => `${v}%`)}</div>
|
||||
<div className="bg-slate-900/80 border border-slate-800 p-3 rounded-lg flex items-center justify-center text-[10px] text-slate-500 font-mono text-center leading-snug">WaveShaper saturation<br/>High-pass 2kHz · 4× oversampled</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeMod && activeMod.type === 'rebalance' && activeIdx >= 0 && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-slate-900/80 border border-slate-800 p-3 rounded-lg">{slider('MID GAIN', ap.mid, -12, 12, 0.1, '#38bdf8', v => setParams(activeIdx, { mid: v }), v => `${v > 0 ? '+' : ''}${v.toFixed(1)} dB`)}</div>
|
||||
<div className="bg-slate-900/80 border border-slate-800 p-3 rounded-lg">{slider('SIDE GAIN', ap.side, -12, 12, 0.1, '#22d3ee', v => setParams(activeIdx, { side: v }), v => `${v > 0 ? '+' : ''}${v.toFixed(1)} dB`)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* WAVE OBSERVER — REAL-TIME OSCILLOSCOPE (unified_fx_rack_panel_update.md §III.3) */}
|
||||
<div className="bg-slate-950 border border-slate-800 rounded-xl p-3 flex flex-col gap-2 shrink-0">
|
||||
<div className="flex items-center justify-between text-xs font-mono border-b border-slate-800/80 pb-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white tracking-wider">WAVE OBSERVER</span>
|
||||
<span className="text-[9px] bg-cyan-950 border border-cyan-800 text-cyan-400 px-1.5 py-0.5 rounded">Real-time Oscilloscope</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 truncate max-w-[200px]">Context: {track.name}</span>
|
||||
</div>
|
||||
<div className="relative w-full h-36 bg-slate-950 border border-slate-900 rounded-lg overflow-hidden">
|
||||
<canvas ref={scopeCanvasRef} className="w-full h-full block cursor-crosshair"></canvas>
|
||||
</div>
|
||||
<div className="flex items-center justify-between bg-slate-900/90 border border-slate-800 rounded-lg px-3 py-1.5 text-xs font-mono flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2 pr-3 border-r border-slate-800">
|
||||
<span className="text-[10px] text-slate-400">Input</span>
|
||||
<div className="flex flex-col gap-1 w-12">
|
||||
<div className="h-1.5 bg-slate-950 rounded overflow-hidden flex"><div ref={scopeMeterLRef} className="h-full bg-cyan-400 w-0 transition-all"></div></div>
|
||||
<div className="h-1.5 bg-slate-950 rounded overflow-hidden flex"><div ref={scopeMeterRRef} className="h-full bg-cyan-400 w-0 transition-all"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-slate-500 text-[10px]">Channel:</span>
|
||||
<select value={scopeChannel} onChange={e => setScopeChannel(e.target.value)} className="bg-slate-950 border border-slate-800 text-slate-300 rounded px-1.5 py-0.5 text-[11px] outline-none">
|
||||
<option value="stereo">Stereo</option><option value="left">Left</option><option value="right">Right</option><option value="mid">Mid</option><option value="side">Side</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-slate-500 text-[10px]">Mode:</span>
|
||||
<select value={scopeMode} onChange={e => setScopeMode(e.target.value)} className="bg-slate-950 border border-slate-800 text-slate-300 rounded px-1.5 py-0.5 text-[11px] outline-none">
|
||||
<option value="waveform">Waveform</option><option value="lissajous">Lissajous</option><option value="spectrum">Spectrum</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-slate-500 text-[10px]">Duration:</span>
|
||||
<span className="text-cyan-400 font-bold text-[11px] w-12">{scopeDuration.toFixed(2)}s</span>
|
||||
<input type="range" min="0.1" max="5.0" step="0.1" value={scopeDuration} onChange={e => setScopeDuration(parseFloat(e.target.value))} className="w-16 h-1 cursor-pointer" style={{ accentColor: '#38bdf8' }} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-slate-500 text-[10px]">V.Zoom:</span>
|
||||
<span className="text-cyan-400 font-bold text-[11px] w-12">{scopeZoom > 0 ? '+' : ''}{scopeZoom.toFixed(1)} dB</span>
|
||||
<input type="range" min="-12" max="20" step="0.5" value={scopeZoom} onChange={e => setScopeZoom(parseFloat(e.target.value))} className="w-16 h-1 cursor-pointer" style={{ accentColor: '#38bdf8' }} />
|
||||
</div>
|
||||
<button onClick={() => setScopePaused(p => !p)} className={`px-3 py-1 font-semibold rounded text-[11px] border transition-colors ${scopePaused ? 'bg-amber-600 border-amber-400 text-white' : 'bg-slate-800 hover:bg-slate-700 text-slate-300 border-slate-700'}`}>
|
||||
{scopePaused ? 'Resume' : 'Pause'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ADD MODULE POPUP */}
|
||||
{addModuleOpen && (
|
||||
<div className="fixed inset-0 z-[120] 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 font-mono">THÊM MODULE VÀO FX 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">
|
||||
{Object.keys(TRACK_FX_META).map(t => {
|
||||
const meta = TRACK_FX_META[t];
|
||||
return (
|
||||
<button key={t} onClick={() => addMod(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 flex items-center gap-1.5" style={{ color: meta.color }}><i data-lucide={meta.icon} className="w-3.5 h-3.5"></i> {meta.name}</div>
|
||||
<div className="text-[10px] text-slate-400">{meta.sub}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// MASTERING MODAL COMPONENT (from md/45_MASTERING_MODULE.md)
|
||||
// ──────────────────────────────────────────────
|
||||
@@ -12538,6 +12948,7 @@ const App = () => {
|
||||
console.warn('mute/solo resume error:', e);
|
||||
}
|
||||
}
|
||||
updateSfRouting();
|
||||
};
|
||||
window.__applyTrackMuteSolo = applyAllTrackMuteSolo;
|
||||
window.__toggleMediaExplorerRef = function() {
|
||||
@@ -13060,6 +13471,7 @@ const App = () => {
|
||||
}
|
||||
trackAudibleRef.current[t.id] = audible;
|
||||
});
|
||||
updateSfRouting();
|
||||
}, [tracks, sessionTabs]);
|
||||
|
||||
const [selectionCleared, setSelectionCleared] = useState(false); // LOOP_EDITOR_2.md §4.2
|
||||
@@ -13101,6 +13513,9 @@ const App = () => {
|
||||
const [aiPresetModalOpen, setAiPresetModalOpen] = useState(false);
|
||||
const [aiPresetVersion, setAiPresetVersion] = useState(0);
|
||||
const [showMasteringModal, setShowMasteringModal] = useState(false);
|
||||
// Unified FX Rack target context (unified_fx_rack_panel.md): { trackId, trackName } | null
|
||||
const [fxRackTarget, setFxRackTarget] = useState(null);
|
||||
window.__openFxRack = (trackId, trackName) => setFxRackTarget({ trackId, trackName });
|
||||
const [masteringSettings, setMasteringSettings] = useState({
|
||||
masterConnected: false,
|
||||
activeModule: 'eq',
|
||||
@@ -16427,8 +16842,19 @@ const App = () => {
|
||||
if (!masterBus) initMasterBus(context);
|
||||
|
||||
const analyserNode = context.createAnalyser();
|
||||
analyserNode.fftSize = 256;
|
||||
analyserNode.fftSize = 2048;
|
||||
pannerNode.connect(analyserNode);
|
||||
// Wave Observer scope analysers (unified_fx_rack_panel_update.md §III.3):
|
||||
// L/R split at the context OUTPUT (post-FX, after panner) so the rack's
|
||||
// oscilloscope can render Stereo/Left/Right/Mid/Side channels.
|
||||
const scopeSplitter = context.createChannelSplitter(2);
|
||||
const scopeAnalyserL = context.createAnalyser();
|
||||
const scopeAnalyserR = context.createAnalyser();
|
||||
scopeAnalyserL.fftSize = 2048;
|
||||
scopeAnalyserR.fftSize = 2048;
|
||||
pannerNode.connect(scopeSplitter);
|
||||
scopeSplitter.connect(scopeAnalyserL, 0);
|
||||
scopeSplitter.connect(scopeAnalyserR, 1);
|
||||
// Dual mastering route: routeGain -> mastering chain (normal, post-FX),
|
||||
// dryGain -> dry bus (bypass, tapped PRE-FX so the bypassed channel skips
|
||||
// BOTH the track FX chain and the mastering chain at Main out).
|
||||
@@ -16437,41 +16863,106 @@ 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;
|
||||
// Track FX chain (unified_fx_rack_panel.md §II): reusable module instances
|
||||
// in series between fxEntry (from gainNode) and fxLegacyIn (into the
|
||||
// legacy chorus/reverb path). fxEntry/fxLegacyIn let rebuildTrackFxGraph
|
||||
// re-route THIS track's chain without touching other tracks or the bus.
|
||||
const fxEntry = context.createGain();
|
||||
const fxLegacyIn = context.createGain();
|
||||
gainNode.connect(fxEntry);
|
||||
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;
|
||||
});
|
||||
}
|
||||
const chainMods = fxChain.filter(m => m && m.active !== false).map(m => {
|
||||
try { return createTrackFxModule(m.type || m, context, m.params); } catch (e) { return null; }
|
||||
}).filter(Boolean);
|
||||
let fxChainTail = fxEntry;
|
||||
chainMods.forEach(mod => {
|
||||
fxChainTail.connect(mod.input);
|
||||
fxChainTail = mod.output;
|
||||
});
|
||||
fxChainTail.connect(fxLegacyIn);
|
||||
if (track.fxType === 'chorus') {
|
||||
const fxInput = context.createGain();
|
||||
fxChainTail.connect(fxInput);
|
||||
fxLegacyIn.connect(fxInput);
|
||||
const chorus = createChorusNode(context, fxInput, pannerNode);
|
||||
fxStopFn = chorus.stop;
|
||||
} else if (track.fxType === 'reverb') {
|
||||
const fxInput = context.createGain();
|
||||
fxChainTail.connect(fxInput);
|
||||
fxLegacyIn.connect(fxInput);
|
||||
createReverbNode(context, fxInput, pannerNode);
|
||||
fxStopFn = null;
|
||||
} else {
|
||||
fxChainTail.connect(pannerNode);
|
||||
fxLegacyIn.connect(pannerNode);
|
||||
}
|
||||
node = { gainNode, pannerNode, fxStopFn, analyserNode, route };
|
||||
node = { gainNode, pannerNode, fxStopFn, analyserNode, route, fxEntry, fxLegacyIn, scopeAnalyserL, scopeAnalyserR };
|
||||
// Realtime mute/solo: apply the track's current mute/solo/volume state to
|
||||
// the fresh node so items of muted/soloed tracks start correctly.
|
||||
const trackList = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : [track];
|
||||
setTrackNodeGain(node, computeTrackAudibleGain(trackList, track));
|
||||
activeTrackNodesRef.current[track.id] = node;
|
||||
updateSfRouting();
|
||||
}
|
||||
return node.gainNode;
|
||||
};
|
||||
// Per-context audio graph reconstruction (unified_fx_rack_panel.md §III.2):
|
||||
// re-routes ONLY this track's fx chain (fxEntry → active modules → fxLegacyIn),
|
||||
// leaving other tracks and the master bus untouched. Called by the FX Rack
|
||||
// panel on add/remove/reorder/toggle/param change — live while playing.
|
||||
const rebuildTrackFxGraph = (trackId) => {
|
||||
const node = activeTrackNodesRef.current[trackId];
|
||||
if (!node || !node.fxEntry || !node.fxLegacyIn) return;
|
||||
try {
|
||||
const list = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : tracks;
|
||||
const track = list.find(t => t.id === trackId);
|
||||
if (!track) return;
|
||||
node.fxEntry.disconnect();
|
||||
const chainMods = (track.fxChain || []).filter(m => m && m.active !== false).map(m => {
|
||||
try { return createTrackFxModule(m.type || m, getAudioContext(), m.params); } catch (e) { return null; }
|
||||
}).filter(Boolean);
|
||||
let tail = node.fxEntry;
|
||||
chainMods.forEach(mod => {
|
||||
tail.connect(mod.input);
|
||||
tail = mod.output;
|
||||
});
|
||||
tail.connect(node.fxLegacyIn);
|
||||
} catch (e) {
|
||||
console.warn('rebuildTrackFxGraph error:', e);
|
||||
}
|
||||
};
|
||||
const rebuildTrackFxGraphRef = useRef(null);
|
||||
rebuildTrackFxGraphRef.current = rebuildTrackFxGraph;
|
||||
window.__rebuildTrackFxGraph = rebuildTrackFxGraph;
|
||||
|
||||
// Route the SHARED FluidSynth output through the gainNode of the single
|
||||
// audible MIDI track so its FX Rack chain / fader / pan affect the soundfont
|
||||
// instrument (unified_fx_rack_panel.md + user request). With more than one
|
||||
// audible MIDI track the shared worklet cannot split per channel, so we fall
|
||||
// back to the master bus (FX not applied — CC7 still mutes correctly).
|
||||
const updateSfRouting = () => {
|
||||
try {
|
||||
const list = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : tracks;
|
||||
const midiAudible = list.filter(t => (t.midiItems && t.midiItems.length > 0) && computeTrackAudibleGain(list, t) > 0);
|
||||
if (midiAudible.length === 1) {
|
||||
const node = activeTrackNodesRef.current[midiAudible[0].id];
|
||||
if (node && node.gainNode && window.SonicSF && window.SonicSF.setOutputDestination) {
|
||||
window.SonicSF.setOutputDestination(node.gainNode);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (window.SonicSF && window.SonicSF.setOutputDestination) {
|
||||
window.SonicSF.setOutputDestination(null);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('updateSfRouting error:', e);
|
||||
}
|
||||
};
|
||||
const updateSfRoutingRef = useRef(null);
|
||||
updateSfRoutingRef.current = updateSfRouting;
|
||||
window.__updateSfRouting = updateSfRouting;
|
||||
window.__getTrackScopeAnalysers = (trackId) => {
|
||||
const node = activeTrackNodesRef.current[trackId];
|
||||
if (!node || !node.scopeAnalyserL || !node.scopeAnalyserR) return null;
|
||||
return { L: node.scopeAnalyserL, R: node.scopeAnalyserR, sr: getAudioContext().sampleRate };
|
||||
};
|
||||
const getOrCreateSubTrackNode = (track, subTrack, context) => {
|
||||
if (!track || !subTrack) return null;
|
||||
const subKey = track.id + '_sub_' + subTrack.id;
|
||||
@@ -16727,6 +17218,7 @@ const App = () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
updateSfRouting();
|
||||
};
|
||||
|
||||
// Solo playback for Local Selection Loop (LOOP_MAKER.md §2.2)
|
||||
@@ -16975,6 +17467,7 @@ const App = () => {
|
||||
...s,
|
||||
isPlaying: false
|
||||
})));
|
||||
updateSfRouting();
|
||||
};
|
||||
const seekPlaybackTo = (time) => {
|
||||
const isSubTab = subTabs.some(sub => sub.id === activeTab);
|
||||
@@ -25038,6 +25531,10 @@ const App = () => {
|
||||
onClose: () => setShowMasteringModal(false),
|
||||
masteringSettings: masteringSettings,
|
||||
setMasteringSettings: setMasteringSettings
|
||||
}), fxRackTarget && /*#__PURE__*/React.createElement(FXRackModal, {
|
||||
track: tracks.find(t => t.id === fxRackTarget.trackId) || null,
|
||||
onUpdateTrack: updateTrackProp,
|
||||
onClose: () => setFxRackTarget(null)
|
||||
}), instrumentSelectorTrackId && /*#__PURE__*/React.createElement("div", {
|
||||
className: "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm",
|
||||
onClick: closeInstrumentSelector
|
||||
|
||||
Reference in New Issue
Block a user