FEAT: thêm FX RACK PANEL cho track, áp dụng cho midi item và audioclip item

This commit is contained in:
2026-08-03 20:25:27 +07:00
parent ec5fedec33
commit e5b4321a55
5 changed files with 692 additions and 109 deletions
+576 -79
View File
@@ -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) mt panel dùng chung,
// bind đng vào track khi bm [FX]; mi thay đi rebuild graph RIÊNG ca 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 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 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; }
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
File diff suppressed because one or more lines are too long
+24 -4
View File
@@ -22,6 +22,7 @@
let _loadedFonts = {};
let _activeOscillators = {};
let _gainNode = null;
let _pendingOutputDestination = null;
let _scheduledNotes = [];
let _loadPromises = {};
let _sfloadSeq = 0;
@@ -31,7 +32,7 @@
if (!_gainNode) {
_gainNode = _audioCtx.createGain();
_gainNode.gain.value = 0.3;
_gainNode.connect(window.masterBus ? window.masterBus.input : _audioCtx.destination);
_gainNode.connect(_pendingOutputDestination || (window.masterBus ? window.masterBus.input : _audioCtx.destination));
}
return _audioCtx;
}
@@ -40,7 +41,7 @@
if (!_gainNode) {
_gainNode = ctx.createGain();
_gainNode.gain.value = 0.3;
_gainNode.connect(window.masterBus ? window.masterBus.input : ctx.destination);
_gainNode.connect(_pendingOutputDestination || (window.masterBus ? window.masterBus.input : ctx.destination));
}
return ctx;
}
@@ -53,7 +54,7 @@
if (!_gainNode) {
_gainNode = window.__sharedAudioCtx.createGain();
_gainNode.gain.value = 0.3;
_gainNode.connect(window.__sharedAudioCtx.destination);
_gainNode.connect(_pendingOutputDestination || window.__sharedAudioCtx.destination);
}
return window.__sharedAudioCtx;
};
@@ -61,6 +62,25 @@
const SonicSF = {
loadedFonts: _loadedFonts,
// Route the shared FluidSynth output through a per-track node (e.g. the
// track's gainNode) so the track's FX chain / fader / pan affect the
// soundfont instrument. Pass null to restore the default master-bus route.
setOutputDestination: function (node) {
try {
if (_gainNode) {
_gainNode.disconnect();
_gainNode.connect(node || (window.masterBus ? window.masterBus.input : ((_audioCtx || window.__sharedAudioCtx).destination)));
} else {
_pendingOutputDestination = node || null;
}
} catch (e) {
console.warn('[SonicSF] setOutputDestination error:', e);
}
},
getOutputNode: function () {
return _gainNode;
},
init: async function (audioContext) {
if (_initialized && _fluidModule) return;
if (_initPromise) return _initPromise;
@@ -81,7 +101,7 @@
if (!_gainNode) {
_gainNode = _audioCtx.createGain();
_gainNode.gain.value = 0.3;
_gainNode.connect(window.masterBus ? window.masterBus.input : _audioCtx.destination);
_gainNode.connect(_pendingOutputDestination || (window.masterBus ? window.masterBus.input : _audioCtx.destination));
}
console.log("[SonicSF] AudioCtx state:", _audioCtx.state, "sampleRate:", _audioCtx.sampleRate);
+2 -2
View File
@@ -16,7 +16,7 @@
<script src="/static/js/services/audioEngine.js?v=202607271016"></script>
<script src="/static/js/services/storage.js?v=202607271016"></script>
<script src="/static/js/services/soundfontStorage.js?v=202607271016"></script>
<script src="/static/js/services/soundfontPlayer.js?v=202608031340"></script>
<script src="/static/js/services/soundfontPlayer.js?v=202608031400"></script>
<script src="/static/js/services/aiGateway.js?v=202607271016"></script>
<script src="/static/js/services/dawCommandDispatcher.js?v=202607271016"></script>
<script src="/static/js/services/pianoRollTabService.js?v=202607272044"></script>
@@ -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=202608033200" defer></script>
<script src="/static/js/app.precompiled.js?v=202608033500" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style>
:root {
+30
View File
@@ -1,3 +1,33 @@
### [2026-08-03] Task: Wave Observer + Module Vector Display Canvas trong FX Rack Panel (unified_fx_rack_panel_update.md)
- **Tóm tắt thay đổi:** Tích hợp theo spec cập nhật:
1. **Scope analysers per-track (post-FX)**: `getOrCreateTrackNode` thêm `scopeSplitter` + `scopeAnalyserL/R` (fftSize 2048) nối từ `pannerNode` (output context, SAU FX chain — đúng sơ đồ spec §III.3); `analyserNode` chính tăng 256→2048. Expose `window.__getTrackScopeAnalysers(trackId)` → {L, R, sr}.
2. **Wave Observer UI trong FXRackModal**: canvas scope + toolbar đúng spec — Input L/R meters, **Channel** (Stereo/Left/Right/Mid/Side), **Mode** (Waveform/Lissajous/Spectrum), **Duration** slider 0.1-5s (ring buffer history 5s × sampleRate), **V.Zoom** slider -12..+20 dB, **Pause/Resume**. Render loop rAF: waveform vẽ L cyan + R amber (stereo), lissajous scatter L vs R, spectrum bar log 20Hz-20kHz.
3. **Interactive Module Vector Display Canvas**: canvas EQ response curve trong EQ view — vẽ |H(f)| xấp xỉ (4 band cascaded: lowshelf/peaking/highshelf log-domain) 20Hz-20kHz, cập nhật realtime khi kéo slider band.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608033500)
- **Ghi chú/Test (nếu có):** BUILD OK 927915 bytes, node --check OK, `pytest` 86 passed. Scope chỉ có tín hiệu khi track đang play (analyser tồn tại lúc playback); dừng → flatline + ghi chú.
---
### [2026-08-03] Task: FX Rack Panel tác dụng lên soundfont instrument trên track (route FluidSynth output qua track gainNode)
- **Tóm tắt thay đổi:** Trước đây MIDI/soundfont qua FluidSynth (ScriptProcessor → `_gainNode` gain 0.3 → masterBus.input) **bỏ qua hoàn toàn track gainNode + fxEntry** → FX Rack (EQ/Comp/Limiter/Exciter/Rebalance) không ảnh hưởng instrument. Fix:
1. **soundfontPlayer.js**: thêm `setOutputDestination(node)` (disconnect + nối `_gainNode` tới node chỉ định; `null` → về masterBus.input) + `getOutputNode()`; biến `_pendingOutputDestination` áp dụng khi `_gainNode` được tạo sau (4 nơi tạo gainNode đều tôn trọng).
2. **app.jsx `updateSfRouting()`**: nếu **đúng 1 track MIDI audible** (có midiItems + `computeTrackAudibleGain>0`) → `setOutputDestination(track.gainNode)` → toàn bộ chuỗi track tác dụng: **FX Rack chain → fader volume → pan → mute/solo gain → master**. Nhiều hơn 1 track MIDI audible → fallback masterBus.input (shared worklet không tách được; CC7 mute vẫn đúng). Expose `window.__updateSfRouting`.
3. **Gọi updateSfRouting ở 5 điểm**: getOrCreateTrackNode (node mới), startTrackPlayback (cuối schedule), applyAllTrackMuteSolo (đổi mute/solo), stopAllPlayback (về masterBus), effect sync `[tracks, sessionTabs]` (load/undo).
- **Các file ảnh hưởng:** `app/static/js/services/soundfontPlayer.js` (+`?v=202608031400` trong index.html), `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608033400)
- **Ghi chú/Test (nếu có):** BUILD OK 915831 bytes, balance 0, `pytest` 86 passed. Giới hạn: >1 track MIDI audible → FX không áp (cần multi-engine để tách, chưa làm).
---
### [2026-08-03] Task: Unified FX Rack Panel theo unified_fx_rack_panel.md (panel FX dùng chung cho track + rebuild graph độc lập)
- **Tóm tắt thay đổi:** Cài đặt cơ chế Unified FX Rack Panel (Hệ thống quản lý Module FX dùng chung):
1. **Module factory dùng chung**: `createTrackFxModule(type, ctx, params)` — factory DSP giống mastering, giờ nhận `params` (EQ g1-g4, Comp threshold/ratio/makeup, Lim ceiling, Exc drive, Rebal mid/side) lưu theo từng chain entry; thêm `TRACK_EQ_PRESETS` (flat/vocal_clarity/bass_punch/warm_tape/guitar_edge).
2. **Cấu trúc track FX có điểm rebuild**: `getOrCreateTrackNode` wire `gainNode → fxEntry → [modules active] → fxLegacyIn → chorus/reverb/direct → pannerNode` (lưu fxEntry/fxLegacyIn trên node).
3. **`rebuildTrackFxGraph(trackId)`** (đúng `rebuildContextAudioGraph` spec §III.2): disconnect `fxEntry`, nối lại qua modules ACTIVE theo thứ tự hiện tại — chỉ ảnh hưởng track đó, không đụng track khác/master bus; chạy LIVE khi đang play; expose `window.__rebuildTrackFxGraph`.
4. **FXRackModal** — panel unified cho track: chain rack (drag-drop reorder, power toggle, delete, nút [+] thêm 5 loại module), view tham số theo module (EQ 4 band + preset dropdown, Comp, Limiter, Exciter, Rebalance), header hiển thị tên track; mọi thay đổi → update track.fxChain + `__rebuildTrackFxGraph` ngay.
5. **Nút [FX]** trên TrackStripConsole → `window.__openFxRack(track.id, track.name)` — mở panel bind đúng track (bỏ modal inline cũ). App: state `fxRackTarget` + render FXRackModal (track lookup + updateTrackProp).
- **SỰ CỐ KHÔI PHỤC FILE:** trong lúc edit, app.jsx bị ghi đè nhầm (chỉ còn 2000 dòng). Đã khôi phục từ git HEAD `ec5fede` (commit chứa toàn bộ fix tới loop-fix — user đã commit) + replay lại các edit unified rack; xác minh bằng build + balance ngoặc.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608033300)
- **Ghi chú/Test (nếu có):** `node build.mjs` BUILD OK 914635 bytes, syntax OK, bracket balance 0, `pytest` 86 passed.
---
### [2026-08-03] Task: Loop vẫn giãn selection tới bar 18 — mở rộng hasSel + verify bundle serve
- **Tóm tắt thay đổi:** User báo vẫn lỗi sau fix trước. Kiểm tra toàn diện: (1) Không có path nào khác giãn selection khi loop bật (SubTabToolbar/onLoop là code chết; toggleLoop là Media Explorer preview; useEffect BPM chỉ chạy khi đổi tempo). (2) Mở rộng điều kiện `hasSel` trong handler nút loop: ngoài `selectionMode==='local'`, còn nhận selection qua `selectionStart/selectionEnd` hợp lệ (phòng trường hợp sweep không set 'local'). (3) **Verify bằng server thật** (uvicorn 9131): file `app.precompiled.js?v=202608033200` được serve có chứa `hasSel=selectionMode===` + comment fix — chứng minh bundle workspace đã đúng. Nếu user vẫn thấy lỗi → trình duyệt cache hoặc đang chạy deployment khác (không phải workspace).
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html` (bump v=202608033200; sửa double `</script>` phát sinh khi bump)