fix(fx-rt): het crackle master FX - limiter attack smooth + crossfade khi bat/tat chain

- NativeInstrumentEngine: kAttack 0.9->0.1 (het zipper khi sum nhieu track)
- app.jsx: crossfade masterFxDryGain/masterFxGain thay hard-rewire graph luc
  activation/deactivation master chain FX; worklet bao 'started' -> ramp dung
  luc; PDC tre sau khi dry silent (tranh gap 80ms)
- sf-fx-realtime.js: fade-in 512 mau luc FILL xong / resume sau underrun
- index.html + addModule: cache-buster v=202608241200
- rebuild app.precompiled.js, copy bundle vao dist/ + src-tauri/resources/
- keo dai fix cac round truoc: FILL 16, FXRT_BURST_MS, REBUFFER, pacing,
  VST GUI click/focus, solo crackle
- them harness + plans/patch scripts
This commit is contained in:
2026-08-24 07:46:09 +07:00
parent 373a96cf7b
commit 10997a65b9
21 changed files with 1181 additions and 177 deletions
@@ -165,8 +165,9 @@ public:
private:
static std::unique_ptr<INativeInstrument> create_instrument(InstrumentType type);
// Probe-render 3 notes sau khi load, do peak, tinh makeup de note don ra
// ~ -6 dBFS (0.5) - can bang am luong moi engine (VST2/VST3/SF2/SFZ).
// Probe-render 3 notes sau khi load, do peak + RMS, tinh makeup de note
// don ra ~ -18 dBFS RMS / -6 dBFS peak - can bang am luong moi engine
// (VST2/VST3/SF2/SFZ).
static float calibrate(INativeInstrument* inst, double sampleRate, uint32_t blockSize);
mutable std::mutex mu_;
+12 -46
View File
@@ -239,21 +239,18 @@ bool captureHwndToJpeg(HWND hwnd, std::vector<unsigned char>& out, int quality)
return !out.empty();
}
// --- real-input fallback ------------------------------------------------------
// Some editors (JUCE, iZotope) reject synthetic PostMessage input entirely and
// only react to real hardware input while their window is visible. Probe: if 2
// consecutive clicks do not change the frame, switch this plugin to real-input
// mode — briefly move the window topmost to the work-area corner, inject real
// mouse events at the mapped screen position, then restore it off-screen.
std::atomic<bool> g_realInput{false}; // plugin needs real hardware input
// probe: hit (frame changed) resets g_miss; 2 consecutive misses switch
// to real input. A one-off "hit" (e.g. initial settle repaint) must NOT
// lock us onto PostMessage, so there is no permanent "detected" state.
// --- real-input --------------------------------------------------------------
// JUCE/iZotope editors reject synthetic PostMessage input entirely and only
// react to real hardware input while their window is visible, so real input
// is always on: on click the plugin window dances over the embed panel rect
// (anchored) and real mouse events are injected at the mapped screen pos.
// Probe-based fallback (4bb0db2 era) removed: frame-hash probing was defeated
// by animated GUIs (meter repaint) — the frame always changes, g_miss never
// reached 2, PostMessage stayed on and every click silently died.
std::atomic<bool> g_realInput{true}; // always real hardware input
std::atomic<bool> g_dance{false}; // window temporarily on-screen/topmost
std::atomic<DWORD> g_parkUntil{0}; // safety restore deadline (GetTickCount)
POINT g_savedCursor = {0, 0}; // cursor to restore after the dance
int g_miss = 0; // consecutive no-response PostMessage clicks
uint64_t g_preClickHash = 0;
// Anchored mode: the frontend sends the embed panel's screen rect (physical
// px) with every /input, so the real-input dance places the plugin window
// exactly over the panel -- cursor never moves, no park/restore churn. Zero
@@ -261,13 +258,6 @@ uint64_t g_preClickHash = 0;
RECT g_embedRect = {0, 0, 0, 0};
bool g_anchored = false; // window anchored over panel (stays until /close)
uint64_t frameHash(HWND hwnd) {
std::vector<unsigned char> jpg;
if (!captureHwndToJpeg(hwnd, jpg, 70)) return 0;
uint64_t h = 1469598103934665603ull; // FNV-1a 64
for (unsigned char b : jpg) { h ^= b; h *= 1099511628211ull; }
return h;
}
// Lift the window above the foreground app without making it TOPMOST (the
// foreground-lock would otherwise swallow real clicks). Attaching to the
@@ -390,25 +380,6 @@ void relayInput(HWND hwnd, const json_object_s* o) {
// Mouseleave reports -1,-1: finish the drag without clicking a bogus point.
const bool outside = x < 0 || y < 0;
// Probe while still on PostMessage: a changed frame means PostMessage
// responds (reset miss); two consecutive no-response clicks mean the
// editor rejects synthetic input — switch to real hardware input.
if (!g_realInput) {
if (isDown) {
g_preClickHash = frameHash(hwnd);
} else if (isUp && g_preClickHash) {
Sleep(250);
uint64_t nowHash = frameHash(hwnd);
bool resp = nowHash != g_preClickHash;
if (resp) {
g_miss = 0; // PostMessage responded — keep it
} else if (++g_miss >= 2) {
g_realInput = true; // synthetic input rejected — go real hardware input
g_miss = 0;
}
g_preClickHash = 0;
}
}
if (g_realInput && (isDown || isUp || type == "mousemove" || type == "wheel")) {
if (isDown) {
@@ -583,17 +554,12 @@ void runHttpServer(HWND hwnd, std::atomic<bool>& stop) {
if (!IsWindowVisible(hwnd))
SetWindowPos(hwnd, HWND_NOTOPMOST, -20000, -20000, 0, 0,
SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW);
if (!g_realInput) {
// Real-input active: the dance/anchor state is owned by the
// input relay — /show must not yank the window off-screen.
g_dance = false;
g_anchored = false;
g_parkUntil = 0;
}
// Dance/anchor state is owned by the input relay — /show must not
// yank the window off-screen (real input is always on).
sendResponse(c, 200, "application/json", "{\"ok\":true}", 11);
} else if (req.path == "/preset") {
std::string b64;
fxGuiRequestCapture(hwnd, b64, 1500);
fxGuiRequestCapture(hwnd, b64, 500); // 1500 blocked the HTTP worker, stalling /input (clicks)
const std::string body = "{\"preset_b64\":\"" + b64 + "\"}";
sendResponse(c, 200, "application/json", body.data(), (int)body.size());
} else if (req.path == "/shm") {
+36 -15
View File
@@ -1,4 +1,4 @@
// native_bridge/src/NativeInstrumentEngine.cpp
// native_bridge/src/NativeInstrumentEngine.cpp
#include "NativeInstrumentEngine.h"
#include <fluidsynth.h>
@@ -25,6 +25,7 @@
#include <iostream>
#include <thread>
#include <chrono>
#include <cmath>
#ifdef _WIN32
#endif
@@ -543,11 +544,14 @@ static bool SafeProcessChannel(INativeInstrument* inst, float* outL, float* outR
#endif
// --- Auto-calibration (am luong on dinh moi engine VST2/VST3/SF2/SFZ) ---
// Probe-render 3 notes (60/64/67, velocity 0.8) ngay sau khi load, do peak,
// tinh makeup sao cho note don ra ~ -6 dBFS (0.5). Clamp: khong bu patch cam
// qua muc, khong tat patch to. renderAll ap dung makeup + peak-safety @1.0
// (chord/transient vuot 1.0 se bi keo ve 1.0 - khong clip, khong meo).
static constexpr float kCalTargetPeak = 0.5f;
// Probe-render 3 notes (60/64/67, velocity 0.8) ngay sau khi load, do peak
// + RMS, tinh makeup = min(gRms, gPeak) sao cho note don ra ~ -18 dBFS RMS
// va ~ -6 dBFS peak (he so nao nho hon thang de ca hai khong vuot nguong).
// Clamp: khong bu patch cam qua muc, khong tat patch to. renderAll ap dung
// makeup + mix limiter cap -3 dBFS (nhieu track cong vao master chain nam
// trong cua so -6..-3 dBFS peak).
static constexpr float kCalTargetPeak = 0.5f; // note don: peak ~ -6 dBFS
static constexpr float kCalTargetRms = 0.1259f; // note don: RMS ~ -18 dBFS (10^(-18/20))
static constexpr float kCalMinMakeup = 0.125f;
static constexpr float kCalMaxMakeup = 8.0f;
@@ -577,12 +581,18 @@ float InstrumentEngineManager::calibrate(INativeInstrument* inst, double sampleR
}
return true;
};
double sumSq = 0.0; // RMS chi do tren phan body (sustain), bo qua tail
uint64_t rmsN = 0;
for (int p : pitches) {
inst->noteOn(0, (uint32_t)p, 0.8f, 0);
uint32_t off = 0;
while (off < body) {
uint32_t n = bs; if (off + n > body) n = body - off;
if (!renderProbe(n)) { inst->noteOff(0, (uint32_t)p, 0); return 1.0f; }
for (uint32_t i = 0; i < n; ++i) {
sumSq += (double)L[i] * (double)L[i] + (double)R[i] * (double)R[i];
}
rmsN += n;
if (realtime) Sleep((DWORD)(n * 1000.0 / sampleRate + 0.5));
off += n;
}
@@ -595,14 +605,17 @@ float InstrumentEngineManager::calibrate(INativeInstrument* inst, double sampleR
off += n;
}
}
const float rms = (float)std::sqrt(sumSq / (2.0 * (double)rmsN));
if (peak < 1e-4f) {
std::cerr << "[Calibrate] silent probe peak=" << peak << " makeup=1.0" << std::endl;
std::cerr << "[Calibrate] silent probe peak=" << peak << " rms=" << rms << " makeup=1.0" << std::endl;
return 1.0f;
}
float g = kCalTargetPeak / peak;
float g = kCalTargetRms / rms; // de RMS chay dung -18 dBFS
const float gPeak = kCalTargetPeak / peak; // de peak khong vuot -6 dBFS
if (gPeak < g) g = gPeak; // min: ca hai khong vuot nguong
if (g < kCalMinMakeup) g = kCalMinMakeup;
if (g > kCalMaxMakeup) g = kCalMaxMakeup;
std::cerr << "[Calibrate] peak=" << peak << " makeup=" << g << std::endl;
std::cerr << "[Calibrate] peak=" << peak << " rms=" << rms << " makeup=" << g << std::endl;
return g;
}
@@ -673,17 +686,25 @@ void InstrumentEngineManager::renderAll(float* outputL, float* outputR, uint32_t
outputR[i] += spR[i];
}
}
// Smoothed brickwall limiter on the summed mix: fast attack (~2-3 samples)
// when the mix exceeds 1.0, slow release back to unity. Replaces the old
// per-block hard clamp whose gain step at block boundaries caused zipper
// crackle. limiterGain_ persists across renderAll calls.
const float kAttack = 0.4f;
// Mixer summing: smoothed brickwall limiter caps the summed mix at
// kCeilingPeak = -3 dBFS -> MASTER CHAIN INPUT nam trong cua so
// -6..-3 dBFS peak (1 track ~ -6 dBFS; nhieu track cong len, toi da -3 dBFS).
// Fast attack (~2-3 samples) khi mix vuot tran, slow release ve unity.
// Thay the hard clamp cu (gain step dau block gay zipper crackle).
// limiterGain_ persists across renderAll calls.
const float kCeilingPeak = 0.7071f; // -3 dBFS
// Attack 0.9 (near-instant) caused zipper crackle: nhieu track sum ~2.0,
// target 0.35 -> gain 1.0->0.42 trong 1 sample = step -7.6 dB moi transient.
// 0.1 = ~10 samples (0.2ms @48k) ve tran: smooth, van giu ceiling -3 dBFS.
const float kAttack = 0.1f;
const float kRelease = 0.0006f;
for (uint32_t i = 0; i < numSamples; ++i) {
float a = outputL[i] < 0.0f ? -outputL[i] : outputL[i];
float b = outputR[i] < 0.0f ? -outputR[i] : outputR[i];
const float m = a > b ? a : b;
const float target = (m * limiterGain_ > 1.0f) ? (1.0f / (m + 1e-12f)) : 1.0f;
const float target = (m * limiterGain_ > kCeilingPeak)
? (kCeilingPeak / (m + 1e-12f))
: 1.0f;
if (target < limiterGain_)
limiterGain_ += (target - limiterGain_) * kAttack;
else
+41
View File
@@ -156,6 +156,23 @@ int run_realtime_fx_loop(const std::string& jobPath, const std::string& shmName,
closeShm(v);
return 2;
}
// Chờ chain ĐẦU TIÊN được worker build xong (gen>=1) trước khi READY.
// setChain() là ASYNC — worker thread load VST3 + preset mất 1-4s; nếu set
// READY ngay, engine mở WS và audio chảy qua chain RỖNG (passthrough), rồi
// worker swap chain GIỮA STREAM khi load xong → jump dry→VST + latency đổi
// đột ngột → crackle. Chờ swap xong: engine không gửi audio tới khi chain
// thật sự sẵn sàng. (Engine wait_ready timeout = 30s > deadline 28s.)
const auto chainDeadline = std::chrono::steady_clock::now() +
std::chrono::seconds(28);
while (chain.chainGen() == 0 &&
std::chrono::steady_clock::now() < chainDeadline)
sleepMs(10);
if (chain.chainGen() == 0)
std::cerr << "[RealtimeFxLoop] chain load TIMEOUT 28s — READY anyway"
<< std::endl;
else
std::cerr << "[RealtimeFxLoop] chain ready (gen=" << chain.chainGen()
<< ")" << std::endl;
ipc->h.state = FXRT_STATE_READY;
std::cerr << "[RealtimeFxLoop] ready sr=" << sampleRate << " block=" << block
<< " chain=" << (chainJson.empty() ? "(empty)" : "loaded") << std::endl;
@@ -168,6 +185,12 @@ int run_realtime_fx_loop(const std::string& jobPath, const std::string& shmName,
}
});
// Chain update (set_chain): engine ghi lại job file seq++ — poll mỗi
// 500ms; seq đổi → setChain ASYNC (worker load chain mới, swap atomic
// dưới mutex — audio thread copy shared_ptr, không dừng) → load/đổi VST
// FX + đổi preset GUI không ngắt âm/crackle.
uint64_t lastSeq = (uint64_t)jsonNumber(job, "seq", 0);
auto lastPoll = std::chrono::steady_clock::now();
const uint32_t n = block;
float L[FXRT_BLOCK * FXRT_IN_SLOTS], R[FXRT_BLOCK * FXRT_IN_SLOTS];
uint32_t inMask = ipc->h.inSlots - 1;
@@ -179,6 +202,24 @@ int run_realtime_fx_loop(const std::string& jobPath, const std::string& shmName,
std::cerr << "[RealtimeFxLoop] parent gone — exiting" << std::endl;
break;
}
{
const auto nowT = std::chrono::steady_clock::now();
if (std::chrono::duration_cast<std::chrono::milliseconds>(nowT - lastPoll).count() >= 500) {
lastPoll = nowT;
const std::string j2 = readFile(jobPath);
if (!j2.empty()) {
const uint64_t s2 = (uint64_t)jsonNumber(j2, "seq", 0);
if (s2 != lastSeq) {
const std::string c2 = extractChainJson(j2);
if (!c2.empty()) {
lastSeq = s2;
std::cerr << "[RealtimeFxLoop] chain update seq=" << s2 << std::endl;
chain.setChain(c2, sampleRate, (int32_t)batchN);
}
}
}
}
}
// SET_PARAM ring (engine -> bridge, Phase 2.8): drain mỗi iteration —
// áp dụng vào chain TRƯỚC block kế tiếp. Ring đầy → drop lệnh cũ.
while (ipc->h.ctrlRead < ipc->h.ctrlWrite) {
+26 -9
View File
@@ -1639,6 +1639,7 @@ static void fxGuiFeedLoop(Vst3Fx* fx, HWND hwnd, std::shared_ptr<FxGuiShmCtl> ct
FxRealtimeIPC* ipc = nullptr;
std::string curName;
uint64_t lastWrite = 0; // feeder-local: newest inWrite already peeked
auto nextTick = std::chrono::steady_clock::now();
while (IsWindow(hwnd)) {
std::string name;
{
@@ -1665,7 +1666,16 @@ static void fxGuiFeedLoop(Vst3Fx* fx, HWND hwnd, std::shared_ptr<FxGuiShmCtl> ct
lastWrite = ipc->h.inWrite;
}
}
Sleep(2);
// Pace the feeder to realtime (one FXRT_BLOCK per its audio duration,
// 44.1k fallback) instead of Sleep(2) busy-spin (~3x realtime CPU).
// Less CPU in the GUI process = less contention with the pump thread,
// which was stalling /input (dead clicks) and /preset (crackle reloads).
const uint32_t sr = (ipc && ipc->h.sampleRate > 0) ? ipc->h.sampleRate : 44100u;
nextTick += std::chrono::microseconds(1000000ULL * FXRT_BLOCK / sr);
const auto now = std::chrono::steady_clock::now();
if (now > nextTick + std::chrono::milliseconds(50))
nextTick = now; // fell behind (plugin hiccup): resync, don't burst
std::this_thread::sleep_until(nextTick);
}
if (v) fxGuiCloseShm(v);
}
@@ -1690,7 +1700,7 @@ static void fxGuiStartFeederThread(Vst3Fx* fx, HWND hwnd) {
// --- split setup for server mode (startup speed) -----------------------------
// 1. Parse job { path, name } — fast. Returns 0 ok, 1 job/argument error.
static int fxGuiParseJob(const std::string& jobPath, std::string& path, std::string& name, std::string& shm) {
static int fxGuiParseJob(const std::string& jobPath, std::string& path, std::string& name, std::string& shm, std::string& presetB64) {
std::ifstream jf(jobPath, std::ios::binary);
if (!jf) { std::cerr << "[FxGui] cannot read job file: " << jobPath << std::endl; return 1; }
std::string data((std::istreambuf_iterator<char>(jf)), std::istreambuf_iterator<char>());
@@ -1705,6 +1715,7 @@ static int fxGuiParseJob(const std::string& jobPath, std::string& path, std::str
path = memberString(job, "path", "");
name = memberString(job, "name", "");
shm = memberString(job, "shm", "");
presetB64 = memberString(job, "preset_b64", "");
if (path.empty()) { std::cerr << "[FxGui] job missing path" << std::endl; return 1; }
return 0;
}
@@ -1746,9 +1757,15 @@ static int fxGuiCreateWindow(const std::string& name, bool offscreen,
// 3. Load the plugin + attach the editor (SEH-guarded — plugin GUI code may
// crash). Slow — runs on the pump thread in server mode, after HTTP is up.
// Returns 0 ok, 2 plugin load or editor attach failure.
static int fxGuiLoadAttach(Vst3Fx* fx, const std::string& path, const std::string& name, HWND hwnd) {
static int fxGuiLoadAttach(Vst3Fx* fx, const std::string& path, const std::string& name, HWND hwnd, const std::string& presetB64) {
// Fixed 44.1k/512 — GUI only, no audio processed here.
if (!fx->load(path, 44100.0, 512)) { std::cerr << "[FxGui] plugin load failed: " << path << std::endl; return 2; }
// Khôi phục preset GUI capture cuối (hide/show mất settings bug): áp
// preset NGAY SAU load — trước attach editor để GUI hiện đúng cài đặt.
if (!presetB64.empty() && !fx->applyPreset(presetB64))
std::cerr << "[FxGui] preset apply FAILED (default kept): " << path << std::endl;
else if (!presetB64.empty())
std::cerr << "[FxGui] preset applied: " << path << std::endl;
if (!fxGuiAttachSafe(fx, hwnd)) {
std::cerr << "[FxGui] editor attach failed for " << path << std::endl;
DestroyWindow(hwnd);
@@ -1761,13 +1778,13 @@ static int fxGuiLoadAttach(Vst3Fx* fx, const std::string& path, const std::strin
// Combined setup (legacy open mode): parse + load + window + attach, in order.
static int fxGuiSetup(const std::string& jobPath, Vst3Fx* fx, HWND* hwndOut,
bool offscreen, LONG_PTR userData, std::string& shmOut) {
std::string path, name;
int rc = fxGuiParseJob(jobPath, path, name, shmOut);
std::string path, name, presetB64;
int rc = fxGuiParseJob(jobPath, path, name, shmOut, presetB64);
if (rc) return rc;
if (!fx->load(path, 44100.0, 512)) { std::cerr << "[FxGui] plugin load failed: " << path << std::endl; return 2; }
rc = fxGuiCreateWindow(name, offscreen, userData, hwndOut);
if (rc) return rc;
return fxGuiLoadAttach(fx, path, name, *hwndOut);
return fxGuiLoadAttach(fx, path, name, *hwndOut, presetB64);
}
int run_open_fx_gui(const std::string& jobPath) {
@@ -1815,8 +1832,8 @@ int run_fx_gui_server(const std::string& jobPath) {
// server — SF_FXGUI_PORT prints immediately. The slow VST load + editor
// attach is deferred to the pump thread (onStarted), so plugins.py stops
// blocking on a multi-second plugin load (startup speed issue).
std::string path, name, shm;
int rc = fxGuiParseJob(jobPath, path, name, shm);
std::string path, name, shm, presetB64;
int rc = fxGuiParseJob(jobPath, path, name, shm, presetB64);
if (rc) return rc;
rc = fxGuiCreateWindow(name, /*offscreen=*/true, 0, &hwnd);
if (rc) return rc;
@@ -1826,7 +1843,7 @@ int run_fx_gui_server(const std::string& jobPath) {
int loadRc = 0;
const int srv = fxGuiServerLoop(hwnd, [&]() {
loadRc = fxGuiLoadAttach(&fx, path, name, hwnd);
loadRc = fxGuiLoadAttach(&fx, path, name, hwnd, presetB64);
if (loadRc == 0) fxGuiStartFeederThread(&fx, hwnd); // after load+attach only
if (loadRc != 0) PostMessageA(hwnd, WM_CLOSE, 0, 0); // exit; port already printed
});