fix(fx): realtime VST ngắt quãng khi mở MASTERING PANEL + PWR master không ảnh hưởng âm; pacing WS input/output, gate fxRt theo masterConnected, worklet cap 32
This commit is contained in:
+276
-41
@@ -63,7 +63,235 @@ def _find_default_sf2() -> str:
|
||||
return p
|
||||
return ""
|
||||
|
||||
# ── Builtin custom FX (spec fx_chain_architecture §2 Step 2: Custom FX) ─────
|
||||
# Track FX chain slots: eq / eqpro / compressor / limiter / exciter / rebalance
|
||||
# (khớp tham số WebAudio track FX modules + EQ Pro bands) + legacy chorus/reverb
|
||||
# (fx_type cũ). Chạy numpy/scipy thuần — thay pedalboard GPL; lazy import scipy
|
||||
# chỉ khi thực sự có FX (không tăng boot time).
|
||||
|
||||
def _rbj_peaking(f0, gain_db, q, sr):
|
||||
A = 10 ** (gain_db / 40.0)
|
||||
w0 = 2 * math.pi * f0 / sr
|
||||
alpha = math.sin(w0) / (2 * q)
|
||||
cw = math.cos(w0)
|
||||
a0 = 1 + alpha / A
|
||||
b0 = 1 + alpha * A
|
||||
b1 = -2 * cw
|
||||
b2 = 1 - alpha * A
|
||||
a1 = -2 * cw
|
||||
a2 = 1 - alpha / A
|
||||
return [b0 / a0, b1 / a0, b2 / a0], [1.0, a1 / a0, a2 / a0]
|
||||
|
||||
def _rbj_shelf(f0, gain_db, q, sr, low):
|
||||
A = 10 ** (gain_db / 40.0)
|
||||
w0 = 2 * math.pi * f0 / sr
|
||||
alpha = math.sin(w0) / (2 * q)
|
||||
cw = math.cos(w0)
|
||||
sA = 2 * math.sqrt(A) * alpha
|
||||
if low:
|
||||
b0 = A * ((A + 1) - (A - 1) * cw + sA)
|
||||
b1 = 2 * A * ((A - 1) - (A + 1) * cw)
|
||||
b2 = A * ((A + 1) - (A - 1) * cw - sA)
|
||||
a0 = (A + 1) + (A - 1) * cw + sA
|
||||
a1 = -2 * ((A - 1) + (A + 1) * cw)
|
||||
a2 = (A + 1) + (A - 1) * cw - sA
|
||||
else:
|
||||
b0 = A * ((A + 1) + (A - 1) * cw + sA)
|
||||
b1 = -2 * A * ((A - 1) + (A + 1) * cw)
|
||||
b2 = A * ((A + 1) + (A - 1) * cw - sA)
|
||||
a0 = (A + 1) - (A - 1) * cw + sA
|
||||
a1 = 2 * ((A - 1) - (A + 1) * cw)
|
||||
a2 = (A + 1) - (A - 1) * cw - sA
|
||||
return [b0 / a0, b1 / a0, b2 / a0], [1.0, a1 / a0, a2 / a0]
|
||||
|
||||
def _rbj_highpass(f0, q, sr):
|
||||
w0 = 2 * math.pi * f0 / sr
|
||||
alpha = math.sin(w0) / (2 * q)
|
||||
cw = math.cos(w0)
|
||||
a0 = 1 + alpha
|
||||
b0 = (1 + cw) / 2
|
||||
b1 = -(1 + cw)
|
||||
b2 = (1 + cw) / 2
|
||||
a1 = -2 * cw
|
||||
a2 = 1 - alpha
|
||||
return [b0 / a0, b1 / a0, b2 / a0], [1.0, a1 / a0, a2 / a0]
|
||||
|
||||
def _apply_biquad(buf, b, a):
|
||||
from scipy.signal import lfilter
|
||||
out = np.empty_like(buf)
|
||||
for ch in range(buf.shape[0]):
|
||||
out[ch] = lfilter(b, a, buf[ch])
|
||||
return out
|
||||
|
||||
def _apply_eq4(buf, params, sr):
|
||||
params = params or {}
|
||||
gains = [float(params.get(k, 0) or 0) for k in ("g1", "g2", "g3", "g4")]
|
||||
if not any(gains):
|
||||
return buf
|
||||
# WebAudio track 'eq': lowshelf 100Hz, peaking 800Hz Q0.7, peaking 3200Hz
|
||||
# Q1.2, highshelf 10kHz — cùng thứ tự/đáp ứng.
|
||||
for (f0, gain, q, low) in ((100, gains[0], 0.707, True),
|
||||
(800, gains[1], 0.7, False),
|
||||
(3200, gains[2], 1.2, False),
|
||||
(10000, gains[3], 0.707, False)):
|
||||
if gain == 0:
|
||||
continue
|
||||
b, a = (_rbj_shelf(f0, gain, q, sr, True) if low
|
||||
else _rbj_peaking(f0, gain, q, sr))
|
||||
buf = _apply_biquad(buf, b, a)
|
||||
return buf
|
||||
|
||||
def _apply_eqpro(buf, params, sr):
|
||||
params = params or {}
|
||||
bands = params.get("bands") or []
|
||||
if not bands:
|
||||
return buf
|
||||
amount = float(params.get("amount", 100) or 100) / 100.0
|
||||
for b in bands:
|
||||
if b.get("active") is False:
|
||||
continue
|
||||
gain = (float(b.get("gain", 0) or 0)) * amount
|
||||
if gain == 0:
|
||||
continue
|
||||
f0 = float(b.get("freq", 1000))
|
||||
q = float(b.get("q", 1.0) or 1.0)
|
||||
typ = b.get("type", "peaking")
|
||||
if typ in ("lowshelf", "highshelf"):
|
||||
bq, aq = _rbj_shelf(f0, gain, q, sr, typ == "lowshelf")
|
||||
elif typ == "highpass":
|
||||
bq, aq = _rbj_highpass(f0, q, sr)
|
||||
else: # peaking / lowpass / notch / bandpass → peaking (biquad gần đúng)
|
||||
bq, aq = _rbj_peaking(f0, gain, q, sr)
|
||||
buf = _apply_biquad(buf, bq, aq)
|
||||
return buf
|
||||
|
||||
def _apply_compressor(buf, params, sr):
|
||||
params = params or {}
|
||||
threshold_db = float(params.get("threshold", -16))
|
||||
ratio = max(1.0, float(params.get("ratio", 3) or 3))
|
||||
makeup_db = float(params.get("makeup", 0) or 0)
|
||||
makeup = 10 ** (makeup_db / 20.0)
|
||||
block = 256
|
||||
out = np.empty_like(buf)
|
||||
rel = math.exp(-1.0 / (sr * 0.25)) # release 250ms, khớp WebAudio default
|
||||
for ch in range(buf.shape[0]):
|
||||
x = buf[ch]
|
||||
n = x.shape[0]
|
||||
nblocks = (n + block - 1) // block
|
||||
gr = np.ones(n, dtype=np.float32)
|
||||
env = 0.0
|
||||
for bi in range(nblocks):
|
||||
seg = x[bi * block:(bi + 1) * block]
|
||||
peak = float(np.max(np.abs(seg))) if seg.size else 0.0
|
||||
env = max(peak, env * rel) # release smoothing giữa block
|
||||
if env > 1e-9:
|
||||
db = 20 * math.log10(env)
|
||||
over = db - threshold_db
|
||||
if over > 0:
|
||||
gain_db = -over * (1.0 - 1.0 / ratio)
|
||||
gr[bi * block:(bi + 1) * block] = (10 ** (gain_db / 20.0)) * makeup
|
||||
else:
|
||||
gr[bi * block:(bi + 1) * block] = makeup
|
||||
out[ch] = x * gr
|
||||
return out
|
||||
|
||||
def _apply_limiter(buf, params, sr):
|
||||
params = params or {}
|
||||
ceiling_db = min(0.0, float(params.get("ceiling", -1.0)))
|
||||
th = 10 ** (ceiling_db / 20.0)
|
||||
k = 1.0 / max(0.02, th)
|
||||
tanh_k = math.tanh(k)
|
||||
# Brickwall tanh soft-clip — mirror WebAudio limiter (tránh NaN của
|
||||
# DynamicsCompressor Chromium; offline dùng cùng curve). WaveShaper
|
||||
# curve chỉ định nghĩa trên [-1,1] → clamp đầu vào như WebAudio.
|
||||
x = np.clip(buf, -1.0, 1.0)
|
||||
return np.tanh(x * k) / tanh_k
|
||||
|
||||
def _apply_exciter(buf, params, sr):
|
||||
params = params or {}
|
||||
drive = float(params.get("drive", 40) or 40)
|
||||
b, a = _rbj_highpass(2000.0, 0.7, sr)
|
||||
hp = _apply_biquad(buf, b, a)
|
||||
wet = (drive / 100.0) * 0.6
|
||||
return buf + np.tanh(hp * 3.0) * wet
|
||||
|
||||
def _apply_rebalance(buf, params, sr):
|
||||
params = params or {}
|
||||
mid = 10 ** (float(params.get("mid", 0) or 0) / 20.0)
|
||||
side = 10 ** (float(params.get("side", 0) or 0) / 20.0)
|
||||
a = (mid + side) / 2.0
|
||||
b = (mid - side) / 2.0
|
||||
L = buf[0]
|
||||
R = buf[1]
|
||||
return np.stack([a * L + b * R, b * L + a * R]).astype(np.float32)
|
||||
|
||||
def _apply_builtin_fx_chain(buf, chain, sr):
|
||||
"""Serial pipeline qua các builtin custom FX slots (spec §2 Step 2: Slot 1..n
|
||||
in-place). Slot inactive/bypass → bỏ qua. vst3/carla slots = xử lý ngoài
|
||||
(bridge) → không thêm DSP ở đây."""
|
||||
for slot in chain or []:
|
||||
if isinstance(slot, str):
|
||||
slot = {"type": slot}
|
||||
if not isinstance(slot, dict):
|
||||
continue
|
||||
if slot.get("active") is False or slot.get("bypass"):
|
||||
continue
|
||||
typ = slot.get("type") or ""
|
||||
params = slot.get("params") or {}
|
||||
try:
|
||||
if typ == "eq":
|
||||
buf = _apply_eq4(buf, params, sr)
|
||||
elif typ == "eqpro":
|
||||
buf = _apply_eqpro(buf, params, sr)
|
||||
elif typ == "compressor":
|
||||
buf = _apply_compressor(buf, params, sr)
|
||||
elif typ == "limiter":
|
||||
buf = _apply_limiter(buf, params, sr)
|
||||
elif typ == "exciter":
|
||||
buf = _apply_exciter(buf, params, sr)
|
||||
elif typ == "rebalance":
|
||||
buf = _apply_rebalance(buf, params, sr)
|
||||
except Exception as e:
|
||||
logger.warning("[RenderEngine] Builtin FX slot %s failed: %s", typ, e)
|
||||
return buf
|
||||
|
||||
def _apply_legacy_chorus(buf, sr):
|
||||
total_samples = buf.shape[1]
|
||||
lfo = 0.020 + 0.005 * np.sin(2 * np.pi * 1.5 * np.arange(total_samples) / sr)
|
||||
dry = buf * 0.6
|
||||
wet = np.zeros_like(buf)
|
||||
for ch in range(2):
|
||||
indices = np.arange(total_samples) - (lfo * sr)
|
||||
indices = np.clip(indices, 0, total_samples - 1).astype(np.int32)
|
||||
wet[ch, :] = buf[ch, indices]
|
||||
return dry + wet * 0.5
|
||||
|
||||
def _apply_legacy_reverb(buf, sr):
|
||||
total_samples = buf.shape[1]
|
||||
len_ir = int(sr * 2.0)
|
||||
t_ir = np.arange(len_ir) / sr
|
||||
decay = np.exp(-t_ir / 0.5)
|
||||
ir_l = (np.random.rand(len_ir) * 2 - 1) * decay
|
||||
ir_r = (np.random.rand(len_ir) * 2 - 1) * decay
|
||||
dry = buf * 0.6
|
||||
wet = np.zeros_like(buf)
|
||||
for ch in range(2):
|
||||
ir = ir_l if ch == 0 else ir_r
|
||||
from scipy.signal import convolve
|
||||
conv = convolve(buf[ch, :], ir, mode='full')[:total_samples]
|
||||
wet[ch, :] = conv
|
||||
return dry + wet * 0.4
|
||||
|
||||
def _track_latency_samples(track):
|
||||
"""PDC (spec §3): tổng latency (samples) của track từ chain slots. Plugin
|
||||
khai qua field `latency_samples` (bridge hiện chưa báo → 0); khi có
|
||||
getLatencySamples() native, slot điền field này và engine tự align."""
|
||||
total = 0
|
||||
for chain_key in ("fx_chain", "vst_fx_chain"):
|
||||
for slot in (track.get(chain_key) or []):
|
||||
if isinstance(slot, dict) and slot.get("latency_samples"):
|
||||
total += int(slot["latency_samples"])
|
||||
return total
|
||||
|
||||
class PythonRenderEngine:
|
||||
def __init__(self, sample_rate=44100):
|
||||
@@ -102,6 +330,17 @@ class PythonRenderEngine:
|
||||
tracks = session.get("tracks", [])
|
||||
solo_ids = {t.get("id") for t in tracks if t.get("solo")}
|
||||
|
||||
# PDC pre-scan (spec §3): latency mỗi track từ chain slots; track ngắn
|
||||
# hơn bị delay khi mix để mọi track sum sample-accurate phase-aligned.
|
||||
_track_lat = {}
|
||||
_max_lat = 0
|
||||
for _t in tracks:
|
||||
if solo_ids and _t.get("id") not in solo_ids:
|
||||
continue
|
||||
_lat = _track_latency_samples(_t)
|
||||
_track_lat[_t.get("id")] = _lat
|
||||
_max_lat = max(_max_lat, _lat)
|
||||
|
||||
_channel_counter = 0
|
||||
|
||||
for track in tracks:
|
||||
@@ -347,7 +586,7 @@ class PythonRenderEngine:
|
||||
if actual_len > 0:
|
||||
track_buffer[:, start_sample:write_end] += sliced_sec[:, :actual_len]
|
||||
|
||||
# Apply Track Gain (via Pedalboard or fallback)
|
||||
# Track volume / pan / mute (spec §2 Step 3: summing gains)
|
||||
vol_db = track.get("volume_db", 0.0)
|
||||
pan = track.get("pan", 0.0)
|
||||
mute = track.get("mute", False)
|
||||
@@ -355,45 +594,29 @@ class PythonRenderEngine:
|
||||
if mute:
|
||||
continue
|
||||
|
||||
# Apply Track FX (Chorus or Reverb)
|
||||
fx_type = track.get("fx_type")
|
||||
if fx_type == "chorus":
|
||||
# Chorus: LFO delay modulation (scipy/numpy fallback — thay
|
||||
# pedalboard GPL-3.0; chất lượng thấp hơn, upgrade native DSP sau).
|
||||
# ── Track FX chain (spec §2 Step 2): serial custom FX → VST FX ──
|
||||
# Builtin DSP slots (fx_chain) → legacy fx_type (chorus/reverb) →
|
||||
# VST FX (vst_fx_chain) qua native_bridge. Mỗi slot sửa buffer
|
||||
# in-place trước khi chuyển tới slot kế — đúng Buffer_out =
|
||||
# Plugin_n(...Plugin_1(Buffer_in)).
|
||||
if track.get("fx_active", True):
|
||||
try:
|
||||
lfo = 0.020 + 0.005 * np.sin(2 * np.pi * 1.5 * np.arange(total_samples) / self.sample_rate)
|
||||
dry = track_buffer * 0.6
|
||||
wet = np.zeros_like(track_buffer)
|
||||
for ch in range(2):
|
||||
indices = np.arange(total_samples) - (lfo * self.sample_rate)
|
||||
indices = np.clip(indices, 0, total_samples - 1).astype(np.int32)
|
||||
wet[ch, :] = track_buffer[ch, indices]
|
||||
track_buffer = dry + wet * 0.5
|
||||
track_buffer = _apply_builtin_fx_chain(
|
||||
track_buffer, track.get("fx_chain"), self.sample_rate)
|
||||
except Exception as e:
|
||||
logger.warning("[RenderEngine] Fallback Chorus failed: %s", e)
|
||||
elif fx_type == "reverb":
|
||||
# Reverb: decaying-noise IR convolution (scipy — thay pedalboard GPL).
|
||||
try:
|
||||
len_ir = int(self.sample_rate * 2.0)
|
||||
t_ir = np.arange(len_ir) / self.sample_rate
|
||||
decay = np.exp(-t_ir / 0.5)
|
||||
ir_l = (np.random.rand(len_ir) * 2 - 1) * decay
|
||||
ir_r = (np.random.rand(len_ir) * 2 - 1) * decay
|
||||
logger.warning("[RenderEngine] Builtin FX chain failed: %s", e)
|
||||
fx_type = track.get("fx_type")
|
||||
if fx_type == "chorus":
|
||||
try:
|
||||
track_buffer = _apply_legacy_chorus(track_buffer, self.sample_rate)
|
||||
except Exception as e:
|
||||
logger.warning("[RenderEngine] Fallback Chorus failed: %s", e)
|
||||
elif fx_type == "reverb":
|
||||
try:
|
||||
track_buffer = _apply_legacy_reverb(track_buffer, self.sample_rate)
|
||||
except Exception as e:
|
||||
logger.warning("[RenderEngine] Fallback Reverb failed: %s", e)
|
||||
|
||||
dry = track_buffer * 0.6
|
||||
wet = np.zeros_like(track_buffer)
|
||||
for ch in range(2):
|
||||
ir = ir_l if ch == 0 else ir_r
|
||||
from scipy.signal import convolve
|
||||
conv = convolve(track_buffer[ch, :], ir, mode='full')[:total_samples]
|
||||
wet[ch, :] = conv
|
||||
track_buffer = dry + wet * 0.4
|
||||
except Exception as e:
|
||||
logger.warning("[RenderEngine] Fallback Reverb failed: %s", e)
|
||||
|
||||
# Track FX chain (VST3/builtin) — FX Rack per-track: render track
|
||||
# buffer qua native_bridge --render-fx. Ưu tiên fx_chain; fx_type
|
||||
# cũ (chorus/reverb scipy) giữ làm fallback khi không có fx_chain.
|
||||
track_fx_chain = track.get("vst_fx_chain") or []
|
||||
if track_fx_chain and not track.get("vst_fx_bypass"):
|
||||
if settings.RENDER_ENGINE == "bridge":
|
||||
@@ -424,18 +647,25 @@ class PythonRenderEngine:
|
||||
else:
|
||||
logger.warning("[RenderEngine] Track FX chain cần RENDER_ENGINE=bridge — bỏ qua")
|
||||
|
||||
# Process track volume
|
||||
# Process track volume (spec §2 Step 3: Gain_i scaling)
|
||||
gain_linear = 10 ** (vol_db / 20.0)
|
||||
processed_track = track_buffer * gain_linear
|
||||
|
||||
# Apply Track Pan
|
||||
# Apply Track Pan (spec §2 Step 3: constant-power pan law)
|
||||
if pan != 0.0:
|
||||
# Constant power panning
|
||||
theta = ((np.clip(pan, -1.0, 1.0) + 1.0) / 2.0) * (np.pi / 2.0)
|
||||
processed_track[0, :] *= np.cos(theta)
|
||||
processed_track[1, :] *= np.sin(theta)
|
||||
|
||||
# Mix track to session
|
||||
# PDC (spec §3): delay track ngắn hơn (max_lat - own_lat) để mọi
|
||||
# track sum sample-accurate. latency 0 hiện tại → no-op.
|
||||
delay = _max_lat - _track_lat.get(track.get("id"), 0)
|
||||
if delay > 0:
|
||||
shifted = np.zeros_like(processed_track)
|
||||
shifted[:, delay:] = processed_track[:, :total_samples - delay]
|
||||
processed_track = shifted
|
||||
|
||||
# Mix track to session (spec §2 Step 3: linear summing)
|
||||
session_buffer += processed_track
|
||||
|
||||
return session_buffer
|
||||
@@ -494,6 +724,11 @@ class PythonRenderEngine:
|
||||
else:
|
||||
logger.warning("[RenderEngine] Masterbus FX cần RENDER_ENGINE=bridge — bỏ qua")
|
||||
|
||||
# Master bus volume (spec §2 Step 4: master gain stage after chain)
|
||||
master_vol_db = master.get("volume_db", 0.0)
|
||||
if master_vol_db:
|
||||
master_buffer *= 10 ** (master_vol_db / 20.0)
|
||||
|
||||
# Normalization to prevent clipping
|
||||
max_peak = np.max(np.abs(master_buffer))
|
||||
if max_peak > 1.0:
|
||||
|
||||
Reference in New Issue
Block a user