D2: LimiterFx brickwall — lookahead 1-5ms + oversample 4x + hard clip, UI toggle SOFT/BRICKWALL + LOOKAHEAD ms
This commit is contained in:
@@ -320,7 +320,7 @@ _BUILTIN_PARAM_MAP = {
|
||||
("g3", "eqMid2Gain"), ("g4", "eqHighGain")],
|
||||
"compressor": [("threshold", "compThreshold"), ("ratio", "compRatio"),
|
||||
("makeup", "compMakeup")],
|
||||
"limiter": [("ceiling", "limThreshold")],
|
||||
"limiter": [("ceiling", "limThreshold"), ("mode", "limMode"), ("lookahead_ms", "limLookahead")],
|
||||
"exciter": [("drive", "excDrive")],
|
||||
"rebalance": [("mid", "rebalMid"), ("side", "rebalSide")],
|
||||
"imager": [("w1", "w1"), ("w2", "w2"), ("w3", "w3"), ("w4", "w4")],
|
||||
|
||||
+14
-8
@@ -819,7 +819,7 @@ function applyMasteringSettings(s) {
|
||||
// Re-applying identical values in rapid bursts is "fast parameter automation"
|
||||
// and makes Chromium flag the biquad EQ filters as unstable ("state is bad").
|
||||
// Only touch the graph when a value actually changed.
|
||||
const sig = [s.masterConnected, s.isBypassed, s.eqActive, s.eqLowGain, s.eqMid1Gain, s.eqMid2Gain, s.eqHighGain, s.imagerActive, s.w1, s.w2, s.w3, s.w4, s.maximizerActive, s.maxGain, s.maxSoftClip, s.maxUpward, s.ceiling, s.compActive, s.compThreshold, s.compRatio, s.compMakeup, s.limActive, s.limThreshold, s.excActive, s.excDrive, s.rebalActive, s.rebalMid, s.rebalSide].join('|');
|
||||
const sig = [s.masterConnected, s.isBypassed, s.eqActive, s.eqLowGain, s.eqMid1Gain, s.eqMid2Gain, s.eqHighGain, s.imagerActive, s.w1, s.w2, s.w3, s.w4, s.maximizerActive, s.maxGain, s.maxSoftClip, s.maxUpward, s.ceiling, s.compActive, s.compThreshold, s.compRatio, s.compMakeup, s.limActive, s.limThreshold, s.limMode, s.limLookahead, s.excActive, s.excDrive, s.rebalActive, s.rebalMid, s.rebalSide].join('|');
|
||||
if (sig === _lastMasteringSig) return;
|
||||
_lastMasteringSig = sig;
|
||||
|
||||
@@ -1607,7 +1607,7 @@ function fxRtChain() {
|
||||
const FX_RT_PARAM_MAP = {
|
||||
eq: [['g1', 'eqLowGain'], ['g2', 'eqMid1Gain'], ['g3', 'eqMid2Gain'], ['g4', 'eqHighGain']],
|
||||
compressor: [['threshold', 'compThreshold'], ['ratio', 'compRatio'], ['makeup', 'compMakeup']],
|
||||
limiter: [['ceiling', 'limThreshold']],
|
||||
limiter: [['ceiling', 'limThreshold'], ['mode', 'limMode'], ['lookahead_ms', 'limLookahead']],
|
||||
exciter: [['drive', 'excDrive']],
|
||||
rebalance: [['mid', 'rebalMid'], ['side', 'rebalSide']],
|
||||
imager: [['w1', 'w1'], ['w2', 'w2'], ['w3', 'w3'], ['w4', 'w4']],
|
||||
@@ -7057,7 +7057,7 @@ const ProfileModal = ({
|
||||
isBypassed: false,
|
||||
chain: [],
|
||||
compActive: false, compThreshold: -16, compRatio: 3, compMakeup: 0,
|
||||
limActive: false, limThreshold: -1.0,
|
||||
limActive: false, limThreshold: -1.0, limMode: 0, limLookahead: 2,
|
||||
excActive: false, excDrive: 40,
|
||||
rebalActive: false, rebalMid: 0, rebalSide: 0,
|
||||
});
|
||||
@@ -11473,6 +11473,8 @@ const deserializeProjectFromSchema = (schemaObj) => {
|
||||
if (migrated.compMakeup === undefined) migrated.compMakeup = 0;
|
||||
if (migrated.limActive === undefined) migrated.limActive = false;
|
||||
if (migrated.limThreshold === undefined) migrated.limThreshold = -1.0;
|
||||
if (migrated.limMode === undefined) migrated.limMode = 0;
|
||||
if (migrated.limLookahead === undefined) migrated.limLookahead = 2;
|
||||
if (migrated.excActive === undefined) migrated.excActive = false;
|
||||
if (migrated.excDrive === undefined) migrated.excDrive = 40;
|
||||
if (migrated.rebalActive === undefined) migrated.rebalActive = false;
|
||||
@@ -11607,7 +11609,7 @@ const TRACK_FX_DEFAULTS = {
|
||||
eq: { g1: 0, g2: 0, g3: 0, g4: 0 },
|
||||
eqpro: { amount: 100, bands: EQPRO_DEFAULT_BANDS },
|
||||
compressor: { threshold: -16, ratio: 3, makeup: 0 },
|
||||
limiter: { ceiling: -1.0 },
|
||||
limiter: { ceiling: -1.0, mode: 'soft', lookahead_ms: 2 },
|
||||
exciter: { drive: 40 },
|
||||
rebalance: { mid: 0, side: 0 }
|
||||
};
|
||||
@@ -13950,8 +13952,12 @@ const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.pat
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
|
||||
<MasteringKnob param="limThreshold" min={-24} max={0} value={ozState.limThreshold} unit="dB" label="CEILING" color="#f43f5e" onChange={(p, v) => setOzState(prev => ({ ...prev, [p]: v }))} />
|
||||
</div>
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full">
|
||||
<span className="text-[10px] text-slate-500 oz-font-mono text-center leading-snug px-2">True-Peak limiting<br/>Ratio 20:1 · Knee 0dB<br/>Attack 1ms · Release 50ms</span>
|
||||
<div className="bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center gap-2 h-full">
|
||||
<button onClick={() => setOzState(prev => ({ ...prev, limMode: prev.limMode ? 0 : 1 }))}
|
||||
className={`px-3 py-1 rounded text-[10px] font-bold border transition-colors ${ozState.limMode ? 'bg-rose-700 border-rose-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-400'}`}>
|
||||
{ozState.limMode ? 'BRICKWALL' : 'SOFT'}
|
||||
</button>
|
||||
<MasteringKnob param="limLookahead" min={1} max={5} value={ozState.limLookahead} unit="ms" label="LOOKAHEAD" color="#f43f5e" onChange={(p, v) => setOzState(prev => ({ ...prev, [p]: v }))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -18577,7 +18583,7 @@ const App = () => {
|
||||
isBypassed: false,
|
||||
chain: [],
|
||||
compActive: false, compThreshold: -16, compRatio: 3, compMakeup: 0,
|
||||
limActive: false, limThreshold: -1.0,
|
||||
limActive: false, limThreshold: -1.0, limMode: 0, limLookahead: 2,
|
||||
excActive: false, excDrive: 40,
|
||||
rebalActive: false, rebalMid: 0, rebalSide: 0,
|
||||
vstFxChain: [],
|
||||
@@ -19012,7 +19018,7 @@ const App = () => {
|
||||
isBypassed: false,
|
||||
chain: [],
|
||||
compActive: false, compThreshold: -16, compRatio: 3, compMakeup: 0,
|
||||
limActive: false, limThreshold: -1.0,
|
||||
limActive: false, limThreshold: -1.0, limMode: 0, limLookahead: 2,
|
||||
excActive: false, excDrive: 40,
|
||||
rebalActive: false, rebalMid: 0, rebalSide: 0,
|
||||
});
|
||||
|
||||
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=202608221703" defer></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608212130" defer></script>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||
<style>
|
||||
:root {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -293,32 +294,148 @@ private:
|
||||
double env_[2] = {0, 0};
|
||||
};
|
||||
|
||||
// ── Limiter: tanh brickwall — mirror Python `_apply_limiter` / JS limNode.
|
||||
// ── Limiter: soft = tanh brickwall (mặc định); brickwall = lookahead peak
|
||||
// limiter + oversample 4x + hard clip — mirror Python `_apply_limiter` / JS limNode.
|
||||
class LimiterFx : public BuiltinFx {
|
||||
public:
|
||||
explicit LimiterFx(const json_object_s* params) {
|
||||
if (params) ceilingDb_ = std::min(0.0, memberNumber(params, "ceiling", -1.0));
|
||||
explicit LimiterFx(const json_object_s* params, double sr) : sr_(sr) {
|
||||
if (params) {
|
||||
ceilingDb_ = std::min(0.0, memberNumber(params, "ceiling", -1.0));
|
||||
const double mnum = memberNumber(params, "mode", 0.0);
|
||||
const std::string mstr = memberString(params, "mode", "");
|
||||
if (mstr == "brickwall" || mnum == 1.0) modeBrick_ = true;
|
||||
setLookahead(memberNumber(params, "lookahead_ms", 2.0));
|
||||
} else {
|
||||
setLookahead(2.0);
|
||||
}
|
||||
rebuild();
|
||||
}
|
||||
void process(float* L, float* R, uint32_t n) override {
|
||||
for (uint32_t i = 0; i < n; ++i) {
|
||||
const float x = std::max(-1.f, std::min(1.f, L[i]));
|
||||
L[i] = (float)(std::tanh((double)x * k_) / tanhK_);
|
||||
const float y = std::max(-1.f, std::min(1.f, R[i]));
|
||||
R[i] = (float)(std::tanh((double)y * k_) / tanhK_);
|
||||
if (!modeBrick_) {
|
||||
for (uint32_t i = 0; i < n; ++i) {
|
||||
const float x = std::max(-1.f, std::min(1.f, L[i]));
|
||||
L[i] = (float)(std::tanh((double)x * k_) / tanhK_);
|
||||
const float y = std::max(-1.f, std::min(1.f, R[i]));
|
||||
R[i] = (float)(std::tanh((double)y * k_) / tanhK_);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Stage 1: lookahead peak limiter (linked stereo). Ring 2L lưu input;
|
||||
// deque giữ max |x| window [idx-L+1, idx]. Emit x[t-L] với t=idx-L+1,
|
||||
// gain = ceiling/peak (smoothing: attack 1ms, release 150ms) — transient
|
||||
// nằm trong window đủ L mẫu trước khi chính nó được emit nên bị bắt.
|
||||
const int Lk = lookSamples_;
|
||||
const int ringSize = 2 * Lk;
|
||||
const int64_t start = (int64_t)totalIn_;
|
||||
for (uint32_t j = 0; j < n; ++j) {
|
||||
const int64_t idx = start + j;
|
||||
const float xl = L[j], xr = R[j];
|
||||
const float pl = xl < 0.f ? -xl : xl;
|
||||
const float pr = xr < 0.f ? -xr : xr;
|
||||
const double peak = std::max((double)pl, (double)pr);
|
||||
ringL_[idx % ringSize] = xl;
|
||||
ringR_[idx % ringSize] = xr;
|
||||
while (!dq_.empty() && dq_.back().val <= peak) dq_.pop_back();
|
||||
dq_.push_back({(int)idx, peak});
|
||||
while (!dq_.empty() && dq_.front().idx < (int)idx - Lk + 1) dq_.pop_front();
|
||||
const double curPeak = dq_.empty() ? 0.0 : dq_.front().val;
|
||||
const double target = curPeak > ceilingLin_ ? ceilingLin_ / curPeak : 1.0;
|
||||
gain_ += (target < gain_ ? attackCoef_ : releaseCoef_) * (target - gain_);
|
||||
const int t = (int)idx - Lk + 1;
|
||||
const int audioSlot = modPos(t - Lk, ringSize);
|
||||
L[j] = (float)(ringL_[audioSlot] * gain_);
|
||||
R[j] = (float)(ringR_[audioSlot] * gain_);
|
||||
}
|
||||
totalIn_ += n;
|
||||
// Stage 2: oversample 4x (zero-stuff) → FIR33 (cutoff 0.25) → hard
|
||||
// clip ceiling → FIR33 → decimate (k&3==0). Out[j]=y2[4j], delay 8.
|
||||
for (uint32_t j = 0; j < n; ++j) {
|
||||
float outL = 0.f, outR = 0.f;
|
||||
for (int m = 0; m < 4; ++m) {
|
||||
const double xv = (m == 0) ? (double)L[j] : 0.0;
|
||||
const double y1 = fir1L_.step(xv);
|
||||
const double yc = std::max(-ceilingLin_, std::min(ceilingLin_, y1));
|
||||
if (m == 0) outL = (float)fir2L_.step(yc); else fir2L_.step(yc);
|
||||
}
|
||||
L[j] = outL;
|
||||
for (int m = 0; m < 4; ++m) {
|
||||
const double xv = (m == 0) ? (double)R[j] : 0.0;
|
||||
const double y1 = fir1R_.step(xv);
|
||||
const double yc = std::max(-ceilingLin_, std::min(ceilingLin_, y1));
|
||||
if (m == 0) outR = (float)fir2R_.step(yc); else fir2R_.step(yc);
|
||||
}
|
||||
R[j] = outR;
|
||||
}
|
||||
}
|
||||
bool setParam(const std::string& key, double value) override {
|
||||
if (key == "ceiling") { ceilingDb_ = std::min(0.0, value); rebuild(); return true; }
|
||||
if (key == "mode") { modeBrick_ = (value == 1.0); return true; }
|
||||
if (key == "lookahead_ms") { setLookahead(value); return true; }
|
||||
return false;
|
||||
}
|
||||
private:
|
||||
void rebuild() {
|
||||
const double th = std::pow(10.0, ceilingDb_ / 20.0);
|
||||
k_ = 1.0 / std::max(0.02, th);
|
||||
tanhK_ = std::tanh(k_);
|
||||
static int modPos(int a, int m) { int r = a % m; return r < 0 ? r + m : r; }
|
||||
void setLookahead(double ms) {
|
||||
lookaheadMs_ = std::min(5.0, std::max(1.0, ms));
|
||||
lookSamples_ = std::max(1, (int)std::lround(sr_ * lookaheadMs_ / 1000.0));
|
||||
ringL_.assign((size_t)(2 * lookSamples_), 0.0f);
|
||||
ringR_.assign((size_t)(2 * lookSamples_), 0.0f);
|
||||
dq_.clear();
|
||||
totalIn_ = 0;
|
||||
gain_ = 1.0;
|
||||
}
|
||||
double ceilingDb_ = -1.0, k_, tanhK_;
|
||||
void rebuild() {
|
||||
ceilingLin_ = std::pow(10.0, ceilingDb_ / 20.0);
|
||||
const double th = std::max(0.02, ceilingLin_);
|
||||
k_ = 1.0 / th;
|
||||
tanhK_ = std::tanh(k_);
|
||||
attackCoef_ = 1.0 - std::exp(-1.0 / (sr_ * 0.001));
|
||||
releaseCoef_ = 1.0 - std::exp(-1.0 / (sr_ * 0.150));
|
||||
buildFir(fir1L_.taps, 4.0); // upsampler: gain L=4 bù zero-stuff
|
||||
buildFir(fir2L_.taps, 1.0); // downsampler: gain 1
|
||||
buildFir(fir1R_.taps, 4.0);
|
||||
buildFir(fir2R_.taps, 1.0);
|
||||
fir1L_.reset(); fir2L_.reset(); fir1R_.reset(); fir2R_.reset();
|
||||
}
|
||||
// 33-tap Hamming-windowed sinc, 2*fc = 0.25 (fc = 0.125 cycles/sample của
|
||||
// stream 4x = Nyquist gốc), group delay 16, normalize DC gain = gain.
|
||||
static void buildFir(double* taps, double gain) {
|
||||
const double pi = 3.14159265358979323846;
|
||||
const double fc2 = 0.25;
|
||||
double sum = 0.0;
|
||||
for (int i = 0; i < 33; ++i) {
|
||||
const double t = i - 16.0;
|
||||
const double sincv = (t == 0.0) ? 1.0 : std::sin(pi * fc2 * t) / (pi * fc2 * t);
|
||||
const double w = 0.54 - 0.46 * std::cos(2.0 * pi * i / 32.0);
|
||||
taps[i] = w * fc2 * sincv;
|
||||
sum += taps[i];
|
||||
}
|
||||
for (int i = 0; i < 33; ++i) taps[i] *= gain / sum;
|
||||
}
|
||||
struct Fir33 {
|
||||
double buf[33] = {0};
|
||||
int pos = 0;
|
||||
double step(double x) {
|
||||
buf[pos] = x;
|
||||
pos = (pos + 1) % 33;
|
||||
double y = 0.0;
|
||||
for (int m = 0; m < 33; ++m) y += taps[m] * buf[(pos + 32 - m) % 33];
|
||||
return y;
|
||||
}
|
||||
void reset() { std::memset(buf, 0, sizeof(buf)); pos = 0; }
|
||||
double taps[33];
|
||||
};
|
||||
double sr_;
|
||||
double ceilingDb_ = -1.0, ceilingLin_ = 1.0, k_, tanhK_;
|
||||
double lookaheadMs_ = 2.0;
|
||||
int lookSamples_ = 0;
|
||||
bool modeBrick_ = false;
|
||||
uint64_t totalIn_ = 0;
|
||||
double gain_ = 1.0, attackCoef_, releaseCoef_;
|
||||
std::vector<float> ringL_, ringR_;
|
||||
struct Pk { int idx; double val; };
|
||||
std::deque<Pk> dq_;
|
||||
Fir33 fir1L_, fir2L_, fir1R_, fir2R_;
|
||||
};
|
||||
|
||||
// ── Exciter: highpass 2kHz Q0.7 + tanh — mirror Python `_apply_exciter`.
|
||||
@@ -538,7 +655,7 @@ std::unique_ptr<BuiltinFx> createBuiltinFx(const std::string& id,
|
||||
if (id == "eq") return std::make_unique<Eq4Fx>(params, sampleRate);
|
||||
if (id == "eqpro") return std::make_unique<EqProFx>(params, sampleRate);
|
||||
if (id == "compressor") return std::make_unique<CompressorFx>(params, sampleRate);
|
||||
if (id == "limiter") return std::make_unique<LimiterFx>(params);
|
||||
if (id == "limiter") return std::make_unique<LimiterFx>(params, sampleRate);
|
||||
if (id == "exciter") return std::make_unique<ExciterFx>(params, sampleRate);
|
||||
if (id == "rebalance") return std::make_unique<RebalanceFx>(params);
|
||||
if (id == "imager") return std::make_unique<ImagerFx>(params, sampleRate);
|
||||
|
||||
@@ -481,3 +481,50 @@ def test_57_master_chain_imager_maximizer_vst3(monkeypatch, tmp_path, fx_bridge,
|
||||
assert base > 0.001, "pan tạo side content"
|
||||
assert s0 < 0.05 * base, f"imager w=0 không giết side: {s0:.5f} vs {base:.5f}"
|
||||
assert s2 > 1.5 * base, f"imager w=200 không tăng side: {s2:.5f} vs {base:.5f}"
|
||||
|
||||
def test_59_limiter_brickwall_oversample(fx_bridge):
|
||||
"""D2: LimiterFx mode brickwall — lookahead + oversample 4x + hard clip.
|
||||
Impulse 0dB qua ceiling -1dB: peak <= ceiling + 0.005, length giu nguyen;
|
||||
soft mode giu tanh (peak ~1.0); low-level qua brickwall ~ unity."""
|
||||
n = 4096
|
||||
imp = np.zeros(n, dtype=np.float32)
|
||||
imp[512] = 1.0
|
||||
audio = np.stack([imp, imp * 0.8]).astype(np.float32)
|
||||
ceil_lin = 10.0 ** (-1.0 / 20.0)
|
||||
|
||||
brick = [{"type": "builtin", "id": "limiter",
|
||||
"params": {"ceiling": -1.0, "mode": 1, "lookahead_ms": 2.0}}]
|
||||
brick_str = [{"type": "builtin", "id": "limiter",
|
||||
"params": {"ceiling": -1.0, "mode": "brickwall", "lookahead_ms": 3.0}}]
|
||||
soft = [{"type": "builtin", "id": "limiter",
|
||||
"params": {"ceiling": -1.0}}]
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
wav_in = os.path.join(td, "in.wav")
|
||||
_write_wav(wav_in, audio, SR)
|
||||
out_brick = _render_bridge(fx_bridge, wav_in, SR, brick, block=512)
|
||||
out_brick_str = _render_bridge(fx_bridge, wav_in, SR, brick_str, block=512)
|
||||
out_soft = _render_bridge(fx_bridge, wav_in, SR, soft, block=512)
|
||||
|
||||
assert out_brick.shape == audio.shape, "brickwall length giu nguyen"
|
||||
pk = float(np.max(np.abs(out_brick)))
|
||||
assert pk <= ceil_lin + 0.005, f"brickwall peak {pk:.4f} > ceiling {ceil_lin:.4f}"
|
||||
assert pk >= 0.70, f"brickwall khong giam dinh? peak {pk:.4f}"
|
||||
|
||||
pk_str = float(np.max(np.abs(out_brick_str)))
|
||||
assert pk_str <= ceil_lin + 0.005, f"mode string peak {pk_str:.4f}"
|
||||
|
||||
pk_soft = float(np.max(np.abs(out_soft)))
|
||||
assert pk_soft > 0.95, f"soft peak {pk_soft:.4f} (tanh normalized ~1.0)"
|
||||
assert pk_soft <= 1.0005, f"soft peak {pk_soft:.4f} vuot 1.0"
|
||||
|
||||
imp2 = np.zeros(n, dtype=np.float32)
|
||||
imp2[2048] = 0.1
|
||||
audio2 = np.stack([imp2, imp2]).astype(np.float32)
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
wav_in2 = os.path.join(td, "in2.wav")
|
||||
_write_wav(wav_in2, audio2, SR)
|
||||
out_low = _render_bridge(fx_bridge, wav_in2, SR, brick, block=512)
|
||||
pk_low = float(np.max(np.abs(out_low)))
|
||||
assert 0.05 <= pk_low <= 0.15, f"low-level peak {pk_low:.4f} lech unity"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user