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:
2026-08-21 21:48:56 +07:00
parent 4e3f2fd08a
commit bb6658e03d
17 changed files with 1813 additions and 142 deletions
+276 -41
View File
@@ -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: