Gap 10: multiband compressor + de-esser (LR4 crossover, UI, test_62)
This commit is contained in:
@@ -249,7 +249,7 @@ def render_fx_chain(input_wav, fx_chain, sample_rate, out_path=None):
|
||||
"preset_b64": fx.get("preset_b64", "") or "",
|
||||
"bypass": bool(fx.get("bypass"))})
|
||||
elif typ in ("eq", "eqpro", "imager", "maximizer", "compressor",
|
||||
"limiter", "exciter", "rebalance"):
|
||||
"limiter", "exciter", "rebalance", "multiband", "deesser"):
|
||||
# Builtin DSP — chain hợp nhất (spec PLAN_DAW_A.md Phase 1). C++
|
||||
# RenderFxJob xử lý theo id/params (Phase 2); giữ NGUYÊN thứ tự.
|
||||
slots.append({"type": "builtin", "id": typ,
|
||||
|
||||
+90
-2
@@ -13506,12 +13506,16 @@ const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.pat
|
||||
maximizer: { name: 'Maximizer', sub: 'IRC IV True Peak', icon: 'gauge', color: '#34d399' },
|
||||
compressor: { name: 'Bus Compressor', sub: 'Glue & Punch', icon: 'compress', color: '#fbbf24' },
|
||||
limiter: { name: 'Brickwall Limiter', sub: 'True-Peak 20:1', icon: 'shield-half', color: '#f43f5e' },
|
||||
multiband: { name: 'Multiband Compressor', sub: '3-Band LR4', icon: 'layers', color: '#a3e635' },
|
||||
deesser: { name: 'De-Esser', sub: 'Sibilance tamer', icon: 'mic-2', color: '#fb923c' },
|
||||
exciter: { name: 'Harmonic Exciter', sub: 'Saturation & Air', icon: 'wand-2', color: '#c084fc' },
|
||||
rebalance: { name: 'Master Rebalance', sub: 'M/S Balance', icon: 'sliders-horizontal', color: '#38bdf8' },
|
||||
vst3: { name: 'VST FX', sub: 'VST3 plugin', icon: 'plug', color: '#2dd4bf' }
|
||||
};
|
||||
const chainFlag = (type) => type === 'eq' ? 'eqActive' : type === 'eqpro' ? 'eqproActive' : type === 'imager' ? 'imagerActive' : type === 'maximizer' ? 'maximizerActive' : type === 'compressor' ? 'compActive' : type === 'limiter' ? 'limActive' : type === 'exciter' ? 'excActive' : type === 'vst3' ? 'vstFxActive' : 'rebalActive';
|
||||
const chainFlag = (type) => type === 'eq' ? 'eqActive' : type === 'eqpro' ? 'eqproActive' : type === 'imager' ? 'imagerActive' : type === 'maximizer' ? 'maximizerActive' : type === 'compressor' ? 'compActive' : type === 'limiter' ? 'limActive' : type === 'exciter' ? 'excActive' : type === 'vst3' ? 'vstFxActive' : type === 'multiband' ? 'mbActive' : type === 'deesser' ? 'deessActive' : 'rebalActive';
|
||||
const chainActive = (type) => !!ozState[chainFlag(type)];
|
||||
const mb = (ozState.chain || []).find(m => m.type === 'multiband');
|
||||
const de = (ozState.chain || []).find(m => m.type === 'deesser');
|
||||
|
||||
const toggleChainModule = (modId) => {
|
||||
setOzState(prev => {
|
||||
@@ -13563,6 +13567,8 @@ const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.pat
|
||||
const id = 'mod_' + type + '_' + Date.now();
|
||||
const entry = { id, type, name: meta.name, active: true };
|
||||
if (type === 'eqpro') entry.params = { amount: 100, bands: JSON.parse(JSON.stringify(EQPRO_DEFAULT_BANDS)) };
|
||||
if (type === 'multiband') entry.params = { lf_cross: 200, hf_cross: 4000, low_thr: -24, low_ratio: 3, low_makeup: 0, mid_thr: -24, mid_ratio: 3, mid_makeup: 0, high_thr: -24, high_ratio: 3, high_makeup: 0 };
|
||||
if (type === 'deesser') entry.params = { threshold: -30, ratio: 40, freq: 6000, makeup: 0 };
|
||||
setOzState(prev => ({
|
||||
...prev,
|
||||
chain: [...(prev.chain || []), entry],
|
||||
@@ -13996,6 +14002,86 @@ const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.pat
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VIEW: MULTIBAND COMPRESSOR */}
|
||||
<div className={`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule === 'multiband' ? '' : 'hidden'}`}>
|
||||
<div className="oz-panel p-4 rounded-xl flex-1 grid grid-cols-12 gap-6 items-center">
|
||||
<div className="col-span-4 flex flex-col items-center justify-center border-r border-slate-800 pr-4">
|
||||
<span className="text-xs font-bold text-lime-400 uppercase oz-font-mono mb-3">Multiband Compressor</span>
|
||||
<div className="text-[9px] text-slate-500 oz-font-mono text-center mb-2">LR4 crossover · low/mid/high</div>
|
||||
<button onClick={() => toggleChainModule(mb?.id)}
|
||||
className={`px-3 py-1 rounded text-[10px] font-bold border transition-colors mt-2 ${ozState.mbActive ? 'bg-lime-700 border-lime-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300'}`}>
|
||||
{ozState.mbActive ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="col-span-8 grid grid-cols-3 gap-4">
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
|
||||
<MasteringKnob param="lf_cross" min={40} max={1000} value={mb?.params?.lf_cross ?? 200} unit="Hz" label="LF XOVER" color="#a3e635" onChange={(p, v) => mb && updateChainEntryParams(mb.id, { lf_cross: v })} />
|
||||
</div>
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
|
||||
<MasteringKnob param="hf_cross" min={1000} max={12000} value={mb?.params?.hf_cross ?? 4000} unit="Hz" label="HF XOVER" color="#a3e635" onChange={(p, v) => mb && updateChainEntryParams(mb.id, { hf_cross: v })} />
|
||||
</div>
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
|
||||
<span className="text-[10px] text-slate-500 oz-font-mono text-center leading-snug px-2">Low < LF<br/>Mid giữa 2 crossover<br/>High > HF · Release 250ms</span>
|
||||
</div>
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
|
||||
<MasteringKnob param="low_thr" min={-60} max={0} value={mb?.params?.low_thr ?? -24} unit="dB" label="LOW THRESHOLD" color="#84cc16" onChange={(p, v) => mb && updateChainEntryParams(mb.id, { low_thr: v })} />
|
||||
</div>
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
|
||||
<MasteringKnob param="low_ratio" min={1} max={20} value={mb?.params?.low_ratio ?? 3} unit=":1" label="LOW RATIO" color="#84cc16" onChange={(p, v) => mb && updateChainEntryParams(mb.id, { low_ratio: v })} />
|
||||
</div>
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
|
||||
<MasteringKnob param="low_makeup" min={0} max={12} value={mb?.params?.low_makeup ?? 0} unit="dB" label="LOW MAKE-UP" color="#84cc16" onChange={(p, v) => mb && updateChainEntryParams(mb.id, { low_makeup: v })} />
|
||||
</div>
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
|
||||
<MasteringKnob param="mid_thr" min={-60} max={0} value={mb?.params?.mid_thr ?? -24} unit="dB" label="MID THRESHOLD" color="#65a30d" onChange={(p, v) => mb && updateChainEntryParams(mb.id, { mid_thr: v })} />
|
||||
</div>
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
|
||||
<MasteringKnob param="mid_ratio" min={1} max={20} value={mb?.params?.mid_ratio ?? 3} unit=":1" label="MID RATIO" color="#65a30d" onChange={(p, v) => mb && updateChainEntryParams(mb.id, { mid_ratio: v })} />
|
||||
</div>
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
|
||||
<MasteringKnob param="mid_makeup" min={0} max={12} value={mb?.params?.mid_makeup ?? 0} unit="dB" label="MID MAKE-UP" color="#65a30d" onChange={(p, v) => mb && updateChainEntryParams(mb.id, { mid_makeup: v })} />
|
||||
</div>
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
|
||||
<MasteringKnob param="high_thr" min={-60} max={0} value={mb?.params?.high_thr ?? -24} unit="dB" label="HIGH THRESHOLD" color="#4d7c0f" onChange={(p, v) => mb && updateChainEntryParams(mb.id, { high_thr: v })} />
|
||||
</div>
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
|
||||
<MasteringKnob param="high_ratio" min={1} max={20} value={mb?.params?.high_ratio ?? 3} unit=":1" label="HIGH RATIO" color="#4d7c0f" onChange={(p, v) => mb && updateChainEntryParams(mb.id, { high_ratio: v })} />
|
||||
</div>
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
|
||||
<MasteringKnob param="high_makeup" min={0} max={12} value={mb?.params?.high_makeup ?? 0} unit="dB" label="HIGH MAKE-UP" color="#4d7c0f" onChange={(p, v) => mb && updateChainEntryParams(mb.id, { high_makeup: v })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VIEW: DE-ESSER */}
|
||||
<div className={`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule === 'deesser' ? '' : 'hidden'}`}>
|
||||
<div className="oz-panel p-4 rounded-xl flex-1 grid grid-cols-12 gap-6 items-center">
|
||||
<div className="col-span-4 flex flex-col items-center justify-center border-r border-slate-800 pr-4">
|
||||
<span className="text-xs font-bold text-orange-400 uppercase oz-font-mono mb-3">De-Esser</span>
|
||||
<div className="text-[9px] text-slate-500 oz-font-mono text-center mb-2">Detect sibilance @freq · envelope release 150ms</div>
|
||||
<button onClick={() => toggleChainModule(de?.id)}
|
||||
className={`px-3 py-1 rounded text-[10px] font-bold border transition-colors mt-2 ${ozState.deessActive ? 'bg-orange-700 border-orange-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300'}`}>
|
||||
{ozState.deessActive ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="col-span-8 grid grid-cols-4 gap-4">
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
|
||||
<MasteringKnob param="threshold" min={-60} max={0} value={de?.params?.threshold ?? -30} unit="dB" label="THRESHOLD" color="#fb923c" onChange={(p, v) => de && updateChainEntryParams(de.id, { threshold: v })} />
|
||||
</div>
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
|
||||
<MasteringKnob param="ratio" min={0} max={100} value={de?.params?.ratio ?? 40} unit="%" label="STRENGTH" color="#fb923c" onChange={(p, v) => de && updateChainEntryParams(de.id, { ratio: v })} />
|
||||
</div>
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
|
||||
<MasteringKnob param="freq" min={2000} max={12000} value={de?.params?.freq ?? 6000} unit="Hz" label="FREQ" color="#f97316" onChange={(p, v) => de && updateChainEntryParams(de.id, { freq: v })} />
|
||||
</div>
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
|
||||
<MasteringKnob param="makeup" min={0} max={12} value={de?.params?.makeup ?? 0} unit="dB" label="MAKE-UP" color="#f97316" onChange={(p, v) => de && updateChainEntryParams(de.id, { makeup: v })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VIEW: HARMONIC EXCITER */}
|
||||
<div className={`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule === 'exciter' ? '' : 'hidden'}`}>
|
||||
<div className="oz-panel p-4 rounded-xl flex-1 grid grid-cols-12 gap-6 items-center">
|
||||
@@ -14204,7 +14290,9 @@ const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.pat
|
||||
{ t: 'rebalance', c: 'text-sky-400', i: 'sliders-horizontal', d: 'Cân bằng Mid/Side (Vocal/Bass vs stereo width).' },
|
||||
{ t: 'eq', c: 'text-cyan-400', i: 'activity', d: 'EQ 4-band (lowshelf, 2× peaking, highshelf) + presets.' },
|
||||
{ t: 'imager', c: 'text-fuchsia-400', i: 'radio', d: 'Stereo width 4-band M/S + vectorscope/correlation.' },
|
||||
{ t: 'maximizer', c: 'text-emerald-400', i: 'gauge', d: 'Maximizer: boost, soft clip, upward comp, ceiling.' }
|
||||
{ t: 'maximizer', c: 'text-emerald-400', i: 'gauge', d: 'Maximizer: boost, soft clip, upward comp, ceiling.' },
|
||||
{ t: 'multiband', c: 'text-lime-400', i: 'layers', d: 'Compressor 3 băng tần low/mid/high với crossover LR4.' },
|
||||
{ t: 'deesser', c: 'text-orange-400', i: 'mic-2', d: 'Giảm sibilance (âm xuýt) theo tần số phát hiện.' }
|
||||
].map(m => (
|
||||
<button key={m.t} onClick={() => addModuleToChain(m.t)} className="p-3 bg-slate-950 hover:bg-slate-800 border border-slate-800 rounded-xl text-left space-y-1 transition-colors">
|
||||
<div className={`font-bold ${m.c} flex items-center gap-1.5`}><i data-lucide={m.i} className="w-3.5 h-3.5"></i> {MODULE_META[m.t].name}</div>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -51,7 +51,7 @@
|
||||
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
|
||||
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
||||
<script src="/static/js/services/chordTheory.js?v=202608211300"></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608212205" defer></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608212230" defer></script>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||
<style>
|
||||
:root {
|
||||
|
||||
@@ -689,6 +689,169 @@ private:
|
||||
Fir33Shared fir1L_, fir2L_, fir1R_, fir2R_;
|
||||
};
|
||||
|
||||
// ── Multiband compressor: 3 band (low/mid/high) qua crossover biquad
|
||||
// (LP f1, HP f1+LP f2, HP f2 — mirror ImagerFx 4-band split), mỗi band nén
|
||||
// block-envelope độc lập (threshold/ratio/makeup) rồi cộng lại. Gap 10.
|
||||
class MultibandFx : public BuiltinFx {
|
||||
public:
|
||||
explicit MultibandFx(const json_object_s* params, double sr) : sr_(sr) {
|
||||
if (params) {
|
||||
lfCross_ = std::max(40.0, std::min(1000.0, memberNumber(params, "lf_cross", 200.0)));
|
||||
hfCross_ = std::max(1000.0, std::min(12000.0, memberNumber(params, "hf_cross", 4000.0)));
|
||||
thr_[0] = memberNumber(params, "low_thr", -24.0);
|
||||
rat_[0] = std::max(1.0, memberNumber(params, "low_ratio", 3.0));
|
||||
mk_[0] = memberNumber(params, "low_makeup", 0.0);
|
||||
thr_[1] = memberNumber(params, "mid_thr", -24.0);
|
||||
rat_[1] = std::max(1.0, memberNumber(params, "mid_ratio", 3.0));
|
||||
mk_[1] = memberNumber(params, "mid_makeup", 0.0);
|
||||
thr_[2] = memberNumber(params, "high_thr", -24.0);
|
||||
rat_[2] = std::max(1.0, memberNumber(params, "high_ratio", 3.0));
|
||||
mk_[2] = memberNumber(params, "high_makeup", 0.0);
|
||||
}
|
||||
rel_ = std::exp(-1.0 / (sr_ * 0.25));
|
||||
for (int b = 0; b < 3; ++b) mkLin_[b] = std::pow(10.0, mk_[b] / 20.0);
|
||||
rebuild();
|
||||
}
|
||||
void process(float* L, float* R, uint32_t n) override {
|
||||
const uint32_t block = 256;
|
||||
std::vector<float> bl(n), br(n);
|
||||
std::vector<float> accL(n), accR(n);
|
||||
for (int b = 0; b < 3; ++b) {
|
||||
std::memcpy(bl.data(), L, n * sizeof(float));
|
||||
std::memcpy(br.data(), R, n * sizeof(float));
|
||||
for (int f = 0; f < 4 && band_[b][f].active; ++f)
|
||||
band_[b][f].bq.process(bl.data(), br.data(), n);
|
||||
// block-envelope compressor per band (mirror CompressorFx)
|
||||
for (int c = 0; c < 2; ++c) {
|
||||
float* x = (c == 0) ? bl.data() : br.data();
|
||||
double env = env_[b][c];
|
||||
for (uint32_t pos = 0; pos < n; pos += block) {
|
||||
const uint32_t nb = std::min<uint32_t>(block, n - pos);
|
||||
float peak = 0.f;
|
||||
for (uint32_t i = 0; i < nb; ++i) peak = std::max(peak, std::fabs(x[pos + i]));
|
||||
env = std::max((double)peak, env * rel_);
|
||||
float g = (float)mkLin_[b];
|
||||
if (env > 1e-9) {
|
||||
const double db = 20.0 * std::log10(env);
|
||||
const double over = db - thr_[b];
|
||||
if (over > 0.0) {
|
||||
const double gdb = -over * (1.0 - 1.0 / rat_[b]);
|
||||
g = (float)(std::pow(10.0, gdb / 20.0) * mkLin_[b]);
|
||||
}
|
||||
}
|
||||
for (uint32_t i = 0; i < nb; ++i) x[pos + i] *= g;
|
||||
}
|
||||
env_[b][c] = env;
|
||||
}
|
||||
for (uint32_t i = 0; i < n; ++i) { accL[i] += bl[i]; accR[i] += br[i]; }
|
||||
}
|
||||
std::memcpy(L, accL.data(), n * sizeof(float));
|
||||
std::memcpy(R, accR.data(), n * sizeof(float));
|
||||
}
|
||||
bool setParam(const std::string& key, double value) override {
|
||||
const char* k = key.c_str();
|
||||
if (!std::strcmp(k, "lf_cross") || !std::strcmp(k, "hf_cross")) {
|
||||
if (!std::strcmp(k, "lf_cross")) lfCross_ = std::max(40.0, std::min(1000.0, value));
|
||||
else hfCross_ = std::max(1000.0, std::min(12000.0, value));
|
||||
rebuild();
|
||||
return true;
|
||||
}
|
||||
const char* prefix[] = {"low_", "mid_", "high_"};
|
||||
for (int b = 0; b < 3; ++b) {
|
||||
if (key == std::string(prefix[b]) + "thr") { thr_[b] = value; return true; }
|
||||
if (key == std::string(prefix[b]) + "ratio") { rat_[b] = std::max(1.0, value); return true; }
|
||||
if (key == std::string(prefix[b]) + "makeup") {
|
||||
mk_[b] = value; mkLin_[b] = std::pow(10.0, mk_[b] / 20.0); return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
private:
|
||||
struct BandFilter { Biquad bq; bool active = true; };
|
||||
static void apply(const RBJ& r, BandFilter& f) {
|
||||
f.bq.setCoeffs(r.b0, r.b1, r.b2, r.a1, r.a2);
|
||||
f.active = true;
|
||||
}
|
||||
void rebuild() {
|
||||
for (int b = 0; b < 3; ++b) for (int f = 0; f < 4; ++f) band_[b][f].active = false;
|
||||
// LR4 cascade (two Butterworth Q=1/sqrt2 biquads per octave): LP^2 + HP^2
|
||||
// sums flat 0dB at every frequency (phase 0), so 3-band split is transparent.
|
||||
const RBJ lp1 = rbjLowpass(lfCross_, 0.7071, sr_), hp1 = rbjHighpass(lfCross_, 0.7071, sr_);
|
||||
const RBJ lp2 = rbjLowpass(hfCross_, 0.7071, sr_), hp2 = rbjHighpass(hfCross_, 0.7071, sr_);
|
||||
apply(lp1, band_[0][0]); apply(lp1, band_[0][1]); // low = LP(f1)^2
|
||||
apply(hp1, band_[1][0]); apply(hp1, band_[1][1]);
|
||||
apply(lp2, band_[1][2]); apply(lp2, band_[1][3]); // mid = HP(f1)^2 * LP(f2)^2
|
||||
apply(hp2, band_[2][0]); apply(hp2, band_[2][1]); // high = HP(f2)^2
|
||||
}
|
||||
double sr_, rel_;
|
||||
double lfCross_ = 200.0, hfCross_ = 4000.0;
|
||||
double thr_[3] = {-24, -24, -24}, rat_[3] = {3, 3, 3};
|
||||
double mk_[3] = {0, 0, 0}, mkLin_[3] = {1, 1, 1};
|
||||
double env_[3][2] = {{0, 0}, {0, 0}, {0, 0}};
|
||||
BandFilter band_[3][4] = {};
|
||||
};
|
||||
|
||||
// ── De-esser: detector highpass @freq (sibilance 5-8kHz) → envelope →
|
||||
// gain reduction (ratio = % max reduction) áp full band + makeup. Gap 10.
|
||||
class DeEssFx : public BuiltinFx {
|
||||
public:
|
||||
explicit DeEssFx(const json_object_s* params, double sr) : sr_(sr) {
|
||||
if (params) {
|
||||
threshold_ = memberNumber(params, "threshold", -30.0);
|
||||
ratio_ = std::max(0.0, std::min(100.0, memberNumber(params, "ratio", 40.0)));
|
||||
freq_ = std::max(2000.0, std::min(12000.0, memberNumber(params, "freq", 6000.0)));
|
||||
makeupDb_ = memberNumber(params, "makeup", 0.0);
|
||||
}
|
||||
rel_ = std::exp(-1.0 / (sr_ * 0.15));
|
||||
makeup_ = std::pow(10.0, makeupDb_ / 20.0);
|
||||
rebuild();
|
||||
}
|
||||
void process(float* L, float* R, uint32_t n) override {
|
||||
const uint32_t block = 256;
|
||||
float* chans[2] = {L, R};
|
||||
for (int c = 0; c < 2; ++c) {
|
||||
float* x = chans[c];
|
||||
double env = env_[c];
|
||||
for (uint32_t pos = 0; pos < n; pos += block) {
|
||||
const uint32_t nb = std::min<uint32_t>(block, n - pos);
|
||||
float peak = 0.f;
|
||||
for (uint32_t i = 0; i < nb; ++i) {
|
||||
const double d = hp_.step(c, x[pos + i]);
|
||||
peak = std::max(peak, (float)std::fabs(d));
|
||||
}
|
||||
env = std::max((double)peak, env * rel_);
|
||||
float g = (float)makeup_;
|
||||
if (env > 1e-9) {
|
||||
const double db = 20.0 * std::log10(env);
|
||||
const double over = db - threshold_;
|
||||
if (over > 0.0) {
|
||||
const double gdb = -over * (ratio_ / 100.0);
|
||||
g = (float)(std::pow(10.0, gdb / 20.0) * makeup_);
|
||||
}
|
||||
}
|
||||
for (uint32_t i = 0; i < nb; ++i) x[pos + i] *= g;
|
||||
}
|
||||
env_[c] = env;
|
||||
}
|
||||
}
|
||||
bool setParam(const std::string& key, double value) override {
|
||||
if (key == "threshold") { threshold_ = value; return true; }
|
||||
if (key == "ratio") { ratio_ = std::max(0.0, std::min(100.0, value)); return true; }
|
||||
if (key == "makeup") { makeupDb_ = value; makeup_ = std::pow(10.0, makeupDb_ / 20.0); return true; }
|
||||
if (key == "freq") { freq_ = std::max(2000.0, std::min(12000.0, value)); rebuild(); return true; }
|
||||
return false;
|
||||
}
|
||||
private:
|
||||
void rebuild() {
|
||||
const RBJ r = rbjHighpass(freq_, 0.7, sr_);
|
||||
hp_.setCoeffs(r.b0, r.b1, r.b2, r.a1, r.a2);
|
||||
}
|
||||
double sr_, threshold_ = -30.0, ratio_ = 40.0, freq_ = 6000.0, makeupDb_ = 0.0;
|
||||
double rel_, makeup_ = 1.0;
|
||||
double env_[2] = {0, 0};
|
||||
Biquad hp_;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// ── BuiltinFxChain ──────────────────────────────────────────────────────────
|
||||
@@ -718,5 +881,7 @@ std::unique_ptr<BuiltinFx> createBuiltinFx(const std::string& id,
|
||||
if (id == "rebalance") return std::make_unique<RebalanceFx>(params);
|
||||
if (id == "imager") return std::make_unique<ImagerFx>(params, sampleRate);
|
||||
if (id == "maximizer") return std::make_unique<MaximizerFx>(params, sampleRate);
|
||||
if (id == "multiband") return std::make_unique<MultibandFx>(params, sampleRate);
|
||||
if (id == "deesser") return std::make_unique<DeEssFx>(params, sampleRate);
|
||||
return nullptr; // gain/normalize = legacy, xử lý riêng ở RenderFxJob
|
||||
}
|
||||
|
||||
@@ -656,3 +656,49 @@ def test_60_vst3_setparam_live(fx_bridge, adelay, monkeypatch):
|
||||
e_old = 103 * 256 + SR # echo delay cũ 1s ≈ 70468 (restart/no-apply)
|
||||
assert _peak(e_old - 400, e_old + 400) < 0.3, "param không áp live — echo vẫn ở delay 1s"
|
||||
assert _peak(0, e2 - 700) < 0.3, "có tín hiệu lạ trước echo 0.25s"
|
||||
|
||||
# ── 5.9 Multiband compressor + De-Esser (PLAN_MASTER_ENHANCE B.10) ──────
|
||||
def test_62_multiband_deesser(fx_bridge):
|
||||
"""Multiband: GR on active band + unity transparent (LR4 flat).
|
||||
DeEsser: GR at 8kHz (sibilance), 500Hz untouched."""
|
||||
def peak_db(x):
|
||||
# steady-state: nua cuoi (bo transient khoi dau cua HP/crossover filters)
|
||||
m = float(np.max(np.abs(x[:, x.shape[1] // 2:])))
|
||||
return 20.0 * math.log10(max(m, 1e-12))
|
||||
|
||||
def render(chain, freq, amp):
|
||||
t = np.arange(SR * 2) / SR
|
||||
sine = (amp * np.sin(2 * np.pi * freq * t)).astype(np.float32)
|
||||
audio = np.stack([sine, sine])
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
wav_in = os.path.join(td, "in.wav")
|
||||
_write_wav(wav_in, audio, SR)
|
||||
return _render_bridge(fx_bridge, wav_in, SR, chain, block=512)
|
||||
|
||||
# 1) multiband: low band comp 100Hz (thr -20 ratio 10) -> GR
|
||||
mb_lo = [{"type": "builtin", "id": "multiband", "params": {"lf_cross": 200, "hf_cross": 4000,
|
||||
"low_thr": -20, "low_ratio": 10, "low_makeup": 0, "mid_thr": -80, "mid_ratio": 1,
|
||||
"mid_makeup": 0, "high_thr": -80, "high_ratio": 1, "high_makeup": 0}}]
|
||||
out = render(mb_lo, 100.0, 0.5)
|
||||
g = peak_db(out)
|
||||
assert g < -12.0, f"low comp GR weak: {g:.1f}dBFS (expect <-12)"
|
||||
|
||||
# 2) multiband: unity transparent +-0.5dB (LR4 crossover flat)
|
||||
mb_unity = [{"type": "builtin", "id": "multiband", "params": {"lf_cross": 200, "hf_cross": 4000,
|
||||
"low_thr": -10, "low_ratio": 1, "low_makeup": 0, "mid_thr": -10, "mid_ratio": 1,
|
||||
"mid_makeup": 0, "high_thr": -10, "high_ratio": 1, "high_makeup": 0}}]
|
||||
for freq in (100.0, 1000.0, 8000.0):
|
||||
out = render(mb_unity, freq, 0.5)
|
||||
g = peak_db(out)
|
||||
assert -6.5 <= g <= -5.5, f"multiband unity {freq}Hz: {g:.2f}dBFS (expect ~-6.02)"
|
||||
|
||||
# 3) deesser: 8kHz GR (thr -30 ratio 100 freq 6000)
|
||||
de = [{"type": "builtin", "id": "deesser", "params": {"threshold": -30, "ratio": 100, "freq": 6000, "makeup": 0}}]
|
||||
out = render(de, 8000.0, 0.7)
|
||||
g = peak_db(out)
|
||||
assert g < -15.0, f"deesser 8kHz GR weak: {g:.1f}dBFS (expect <-15)"
|
||||
|
||||
# 4) deesser: 500Hz unchanged +-0.5dB
|
||||
out = render(de, 500.0, 0.5)
|
||||
g = peak_db(out)
|
||||
assert -6.5 <= g <= -5.5, f"deesser 500Hz changed: {g:.2f}dBFS (expect ~-6.02)"
|
||||
|
||||
Reference in New Issue
Block a user