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
+26
View File
@@ -631,6 +631,11 @@ class FxGuiRequest(BaseModel):
# mở mapping này để PEEK input ring và chạy processAudio() trên instance
# GUI → meter trong plugin nhảy theo âm thật (GUI không consume ring).
shm: str = ""
# preset_b64: preset GUI capture mới nhất (bridge state blob hoặc raw
# .vstpreset b64) — bridge áp sau fx->load() để mở GUI với ĐÚNG cài đặt
# đã chỉnh (trước đây GUI mở lại load default → mất settings khi
# hide/show).
preset_b64: str = ""
class FxGuiCloseRequest(BaseModel):
@@ -682,6 +687,8 @@ async def open_fx_gui(req: FxGuiRequest, current_user: dict = Depends(get_curren
job = {"path": plugin_path, "name": req.name or os.path.basename(plugin_path)}
if req.shm:
job["shm"] = req.shm
if req.preset_b64:
job["preset_b64"] = req.preset_b64
fd, job_path = tempfile.mkstemp(prefix="sf_fx_gui_", suffix=".json", dir=tempfile.gettempdir())
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
@@ -1003,6 +1010,10 @@ class FxRealtimeStartRequest(BaseModel):
class FxRealtimeStopRequest(BaseModel):
session_id: str = ""
class FxRealtimeSetChainRequest(BaseModel):
session_id: str = ""
fx_chain: list = []
@router.post("/fx-realtime/start")
async def fx_realtime_start(req: FxRealtimeStartRequest,
current_user: dict = Depends(get_current_user)):
@@ -1033,6 +1044,21 @@ async def fx_realtime_stop(req: FxRealtimeStopRequest,
ok = fx_realtime.stop_session(req.session_id or "")
return {"success": True, "stopped": ok}
@router.post("/fx-realtime/set-chain")
async def fx_realtime_set_chain(req: FxRealtimeSetChainRequest,
current_user: dict = Depends(get_current_user)):
"""Cập nhật chain trên session realtime ĐANG CHẠY — KHÔNG restart session.
Engine ghi lại job file (seq++); bridge poll seq → RealtimeFxChain::setChain
(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. Session/WS/SHM giữ nguyên."""
enforce_password_changed(current_user)
sess = fx_realtime.get_session(req.session_id or "")
if not sess:
raise HTTPException(status_code=404, detail="Session realtime không tồn tại")
ok = sess.set_chain(req.fx_chain or [])
return {"success": True, "updated": ok}
@router.websocket("/ws/fx-realtime/{session_id}")
async def ws_fx_realtime(websocket: WebSocket, session_id: str):
"""Vòng lặp FX realtime: receive block input → SHM → bridge → SHM → send
+24
View File
@@ -120,6 +120,7 @@ class FxRealtimeSession:
self._in_pending_t0 = None
self.proc = None
self._job_path = ""
self._chain_seq = 0
self._started_at = time.time()
# Chain hợp nhất (spec PLAN_DAW_A.md Phase 1): giữ đúng thứ tự slot.
self.chain = list(self.fx_chain or [])
@@ -163,6 +164,7 @@ class FxRealtimeSession:
job = {
"sample_rate": self.sample_rate,
"block_size": self.block_size,
"seq": self._chain_seq,
"fx_chain": self._chain_json_for_bridge(),
}
fd, self._job_path = tempfile.mkstemp(suffix=".json", prefix="fxrt_")
@@ -177,6 +179,28 @@ class FxRealtimeSession:
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
def set_chain(self, fx_chain):
"""Cập nhật chain trên session ĐANG CHẠY (không restart session):
ghi lại job file seq++ — bridge poll seq mỗi 500ms → setChain async
swap (worker load chain mới, audio giữ chain cũ tới khi swap) → load/
đổi VST FX + đổi preset GUI không ngắt âm/crackle."""
self.fx_chain = list(fx_chain or [])
self.chain = self.fx_chain
self._chain_seq += 1
if not self._job_path or not os.path.exists(self._job_path):
return False
try:
with open(self._job_path, "w", encoding="utf-8") as f:
json.dump({
"sample_rate": self.sample_rate,
"block_size": self.block_size,
"seq": self._chain_seq,
"fx_chain": self._chain_json_for_bridge(),
}, f)
except Exception:
return False
return True
def wait_ready(self, timeout=30.0):
deadline = time.time() + timeout
while time.time() < deadline:
+263 -72
View File
@@ -1054,6 +1054,16 @@ function initMasterBus(ctx) {
const output = ctx.createGain();
output.gain.value = 1.0;
// Crossfade master-FX activation (het click khi bat/tat master chain FX):
// path mac dinh outputAnalyser -> masterFxDryGain(1) -> pdcDelay -> output;
// nhanh FX noi vao masterFxGain(0) -> output. fxRtStart/fxRtStop ramp 2 gain
// nay thay vi hard-rewire graph (dry path luon noi san — no click, no gap
// trong luc worklet FILL ~86ms).
const masterFxDryGain = ctx.createGain();
masterFxDryGain.gain.value = 1.0;
const masterFxGain = ctx.createGain();
masterFxGain.gain.value = 0.0;
// Per-track mastering-bypass dry bus: tracks with bypass ON feed into
// dryInput -> dryOutput -> output, skipping the mastering modules
// (EQ / Imager / Maximizer) while still passing the master volume fader
@@ -1104,6 +1114,8 @@ function initMasterBus(ctx) {
compressor: ctx.createDynamicsCompressor(),
analyser,
output,
masterFxDryGain,
masterFxGain,
masteringActive: false,
dryInput,
dryOutput,
@@ -1151,7 +1163,10 @@ function initMasterBus(ctx) {
// "state is bad" → CÂM + stuck. Mastering chain có comp/lim module riêng khi bật.
masterBus.input.connect(masterBus.inputAnalyser);
masterBus.inputAnalyser.connect(masterBus.outputAnalyser);
masterBus.outputAnalyser.connect(masterBus.output);
masterBus.outputAnalyser.connect(masterBus.masterFxDryGain);
masterBus.masterFxDryGain.connect(masterBus.pdcDelay);
masterBus.outputAnalyser.connect(masterBus.masterFxGain);
masterBus.masterFxGain.connect(masterBus.output);
masterBus.muteGain.connect(masterBus.analyser);
masterBus.muteGain.connect(masterBus.splitter);
masterBus.analyser.connect(ctx.destination);
@@ -1631,7 +1646,13 @@ function encodeFloatWavBlob(pcm, sampleRate, channels) {
// worklet → analyser (sau master fader, trước metering/destination — khớp thứ
// tự offline export). Track: mỗi track 1 session/worklet splice vào CẢ 2 đường
// chain (audio-clips + MIDI/SF) — mọi luồng track qua bridge trước main out.
const fxRt = { active: false, sessionId: null, ws: null, node: null, inQueue: [], pumpTimer: null, chainKey: null, latencyTotal: 0 };
const fxRt = { active: false, starting: false, startToken: 0, sessionId: null, ws: null, node: null, inQueue: [], pumpTimer: null, lastInSent: 0, chainKey: null, latencyTotal: 0, fadedIn: false };
// Pace input realtime: worklet sản xuất đúng realtime, nhưng sau khi main
// thread bị block (solo restart, GUI decode, rAF) các frame 'in' dồn lại —
// gửi 1 loạt làm SHM ring 4-slot tràn → mất block → ngắt quãng. Gửi tối đa 1
// burst (4 block ~21ms) mỗi 21ms → ring luôn kịp tiêu thụ, audio hồi phục
// trơn sau stall (FILL hấp thụ phần đầu).
const FXRT_BURST_MS = 21;
// Signature của chain VST realtime — phát hiện chain ĐỔI (insert/remove/bypass/
// reorder VST) → restart session với chain mới (fxRtStart early-return khi
// active, nếu không so chainKey thì VST mới insert không bao giờ được áp).
@@ -1688,20 +1709,41 @@ function fxRtPushAllParams() {
});
});
}
async function fxRtSetChain(chain) {
const sid = fxRt.sessionId;
if (!sid || !fxRt.ws || fxRt.ws.readyState !== 1) return;
const chainPayload = chain.map(s => s.type === 'vst3'
? { type: 'vst3', path: s.path, name: s.name || s.path, preset_b64: s.preset_b64 || '', bypass: false }
: { type: 'builtin', id: s.type, params: (s.params && Object.keys(s.params).length ? s.params : {}), bypass: false });
try {
await window.SonicAPI.fxRealtimeSetChain({ session_id: sid, fx_chain: chainPayload });
try { fxRtPushAllParams(); } catch (e) {}
} catch (e) {
console.warn('[FX RT] set-chain fail:', e);
}
}
async function fxRtStart() {
const chain = fxRtChain();
const ck = fxRtChainKey(chain);
if (fxRt.active) {
// masterBus bị rebuild (recovery/export) → node thuộc graph cũ → restart.
if (!fxRt.node || fxRt.node.context !== getAudioContext()) fxRtStop(true);
// Chain VST thay đổi (insert/remove/bypass/reorder trong MASTER FX CHAIN)
// → stop + start lại để bridge load đúng chuỗi FX mới.
else if (fxRt.chainKey !== ck) fxRtStop(true);
// Chain thay đổi (insert/remove/bypass/reorder/preset GUI) — KHÔNG restart
// (ngắt âm + crackle): set_chain lên session ĐANG CHẠY → bridge worker
// load chain mới (audio giữ chain cũ tới khi swap) → swap atomic mượt.
else if (fxRt.chainKey !== ck) { fxRtSetChain(chain); fxRt.chainKey = ck; return; }
else return;
}
if (!chain.length) return;
// Token chống race bypass: user bấm PWR/Bypass khi fxRtStart đang chờ
// fxRealtimeStart (~4s load VST). Lúc đó fxRt.active vẫn false → effect
// `else if (fxRt.active) fxRtStop()` KHÔNG chạy → start in-flight vẫn nối
// node → bypass vô hiệu. Mỗi fxRtStop() bump startToken → mọi checkpoint
// dưới đây thấy mismatch → hủy session vừa tạo, không nối node.
const token = ++fxRt.startToken;
fxRt.starting = true;
const ctx = getAudioContext();
if (!ctx || !ctx.audioWorklet || !masterBus || !masterBus.output || !masterBus.analyser) return;
if (!ctx || !ctx.audioWorklet || !masterBus || !masterBus.output || !masterBus.analyser || !masterBus.masterFxGain || !masterBus.masterFxDryGain) return;
const chainPayload = chain.map(s => s.type === 'vst3'
? { type: 'vst3', path: s.path, name: s.name || s.path, preset_b64: s.preset_b64 || '', bypass: false }
: { type: 'builtin', id: s.type, params: (s.params && Object.keys(s.params).length ? s.params : {}), bypass: false });
@@ -1711,12 +1753,21 @@ async function fxRtStart() {
} catch (e) {
console.warn('[FX RT] start fail:', e);
showToast('Không khởi động VST FX realtime: ' + (e.message || e), 'warning');
fxRt.starting = false;
return;
}
if (token !== fxRt.startToken) {
// Bypass/stop giữa lúc engine đang load chain — hủy session vừa tạo.
console.warn('[FX RT] start cancelled mid-flight — session stopped');
try { window.SonicAPI.fxRealtimeStop({ session_id: st.session_id }).catch(() => {}); } catch (e2) {}
fxRt.starting = false;
return;
}
fxRt.sessionId = st.session_id;
fxRtGuiPushShm();
try {
await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608212401`);
await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608241200`);
if (token !== fxRt.startToken) { fxRt.starting = false; return; }
fxRt.node = new AudioWorkletNode(ctx, 'sf-fx-realtime', { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [2] });
const u = new URL(API_BASE_URL);
const wsProto = u.protocol === 'https:' ? 'wss://' : 'ws://';
@@ -1726,7 +1777,9 @@ async function fxRtStart() {
fxRt.ws = ws;
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
if (token !== fxRt.startToken) { try { ws.close(); } catch (e) {} fxRt.starting = false; return; }
fxRt.active = true;
fxRt.starting = false;
fxRt.inQueue = []; // bỏ audio stale sản xuất trước khi WS open (~4s load VST) — hết lag khởi đầu
try { fxRtPushAllParams(); } catch (e) {}
};
@@ -1738,7 +1791,9 @@ async function fxRtStart() {
const d = JSON.parse(ev.data);
if (d && d.cmd === 'latency' && typeof d.total === 'number') {
fxRt.latencyTotal = d.total;
fxRtApplyPdc();
// Chi delay dry path khi crossfade da xong (dry silent) — ap som
// trong luc dry con la path nghe duoc se tao gap ~80ms.
if (fxRt.fadedIn) fxRtApplyPdc();
}
} catch (e) {}
return;
@@ -1753,29 +1808,61 @@ async function fxRtStart() {
};
ws.onclose = () => fxRtStop(true);
ws.onerror = () => { try { ws.close(); } catch (e) {} };
// Worklet gom 4 block (1024 mẫu) vào 1 frame → tách 4 WS frame 512 floats
// ở đây. Bridge VST3 nhận input dồn → take=4 → batch 1 call process 1024
// mẫu (~3.5-4.75ms/block) → đủ 187.5/s (take=1: 5.5ms > budget 5.33ms →
// drop → crackle). KHÔNG dùng f.subarray().buffer — nó gửi cả backing
// buffer 8192B; slice() copy đúng 2048B.
const fxRtSendFrames = (d) => {
if (!fxRt.ws || fxRt.ws.readyState !== 1) return;
try {
const f = new Float32Array(d.data);
if (f.length > 512) {
for (let i = 0; i < f.length; i += 512) fxRt.ws.send(f.slice(i, i + 512).buffer);
} else {
fxRt.ws.send(d.data);
}
} catch (e) {}
};
const fxRtInPump = () => {
if (!fxRt.inQueue.length) return;
const now = Date.now();
while (fxRt.inQueue.length && now - fxRt.lastInSent >= FXRT_BURST_MS) {
fxRt.lastInSent = now;
fxRtSendFrames(fxRt.inQueue.shift());
}
if (fxRt.inQueue.length) {
if (fxRt.pumpTimer) clearTimeout(fxRt.pumpTimer);
fxRt.pumpTimer = setTimeout(fxRtInPump, Math.max(1, FXRT_BURST_MS - (now - fxRt.lastInSent)));
} else if (fxRt.pumpTimer) {
clearTimeout(fxRt.pumpTimer);
fxRt.pumpTimer = null;
}
};
fxRt.node.port.onmessage = (ev) => {
const d = ev.data;
if (!d || !fxRt.ws) return;
if (d.type === 'started') {
// Worklet bat dau phat (sau FILL) -> crossfade dry->fx muot. Token guard
// chong race: neu stop/restart xay ra trong luc cho, bo qua.
if (fxRt.fadedIn) return;
setTimeout(() => {
if (token !== fxRt.startToken) return;
fxRt.fadedIn = true;
fxRtCrossfade(true, token);
// Dry gan silent (tc 0.025) roi moi tre pdcDelay — tranh gap khi delay
// dang lon dan.
setTimeout(() => fxRtApplyPdc(), 250);
}, 30);
return;
}
if (d.type === 'set_param') {
try { fxRt.ws.send(JSON.stringify({ cmd: 'set_param', slot: d.slot, key: d.key, value: d.value })); } catch (e) {}
return;
}
if (d.type !== 'in') return;
// Worklet gom 4 block (1024 mẫu) vào 1 frame → tách 4 WS frame 512 floats
// ở đây. Bridge VST3 nhận input dồn → take=4 → batch 1 call process 1024
// mẫu (~3.5-4.75ms/block) → đủ 187.5/s (take=1: 5.5ms > budget 5.33ms →
// drop → crackle). KHÔNG dùng f.subarray().buffer — nó gửi cả backing
// buffer 8192B; slice() copy đúng 2048B.
if (ws.readyState === 1) {
try {
const f = new Float32Array(d.data);
if (f.length > 512) {
for (let i = 0; i < f.length; i += 512) ws.send(f.slice(i, i + 512).buffer);
} else {
ws.send(d.data);
}
} catch (e) {}
}
fxRt.inQueue.push(d);
fxRtInPump();
};
// Pace input: tối đa 1 block/kỳ (~5.8ms). Gửi dồn theo burst (main thread bị
// rAF renderFrame block khi mở MASTERING PANEL) → SHM ring 4-slot tràn → mất
@@ -1783,9 +1870,13 @@ async function fxRtStart() {
// Phase 4.2: fader SAU worklet — worklet chèn giữa outputAnalyser (chain
// output) và output (fader/destination). output → analyser/splitter giữ
// nguyên (meter đo âm SAU fader).
// Crossfade activation: KHONG disconnect dry path — outputAnalyser van noi
// masterFxDryGain -> pdcDelay -> output (gain 1); nhanh fx noi masterFxGain
// (gain 0) -> output. Tin hieu 'started' tu worklet -> ramp 2 gain -> switch
// muot, het click hard-rewire.
try { masterBus.outputAnalyser.disconnect(masterBus.output); } catch (e) {}
masterBus.outputAnalyser.connect(fxRt.node);
fxRt.node.connect(masterBus.output);
fxRt.node.connect(masterBus.masterFxGain);
fxRt.chainKey = ck;
} catch (e) {
console.warn('[FX RT] setup fail:', e);
@@ -1793,18 +1884,40 @@ async function fxRtStart() {
}
}
function fxRtStop(silent) {
// Phải bump token TRƯỚC guard early-return: lúc fxRtStart đang in-flight thì
// active/ws/node/sessionId đều chưa có → guard sẽ return, nhưng start vẫn
// phải bị hủy (token mismatch ở checkpoint).
fxRt.startToken++;
fxRt.starting = false;
if (fxRt.pumpTimer) { clearInterval(fxRt.pumpTimer); fxRt.pumpTimer = null; }
fxRt.inQueue = [];
if (!fxRt.active && !fxRt.ws && !fxRt.node && !fxRt.sessionId) return;
let _fxStopNode = null;
try {
if (fxRt.node) {
fxRt.node.disconnect();
if (masterBus && masterBus.outputAnalyser && masterBus.output) {
try { masterBus.outputAnalyser.disconnect(masterBus.output); } catch (e) {}
try { masterBus.outputAnalyser.connect(masterBus.output); } catch (e) {}
_fxStopNode = fxRt.node;
if (masterBus && masterBus.masterFxGain && masterBus.masterFxDryGain) {
// Crossfade deactivation: ramp dry 0->1 / fx 1->0 roi MOI disconnect
// (tranh click hard-switch nguoc). Dry path da noi san qua
// masterFxDryGain — khong can reconnect outputAnalyser->output. PDC giam
// ve 0 sau khi dry chiem cho (fx silent).
try { fxRtCrossfade(false); } catch (e) {}
setTimeout(() => {
try { _fxStopNode.disconnect(); } catch (e) {}
_fxStopNode.port.onmessage = null;
if (fxRt.node === _fxStopNode) fxRt.node = null;
fxRt.fadedIn = false;
fxRtApplyPdc();
}, 180);
} else {
// Fallback graph cu (khong co crossfade gains) — hard switch nhu cu.
try { _fxStopNode.disconnect(); } catch (e) {}
if (masterBus && masterBus.outputAnalyser && masterBus.output) {
try { masterBus.outputAnalyser.connect(masterBus.output); } catch (e) {}
}
_fxStopNode.port.onmessage = null;
if (fxRt.node === _fxStopNode) fxRt.node = null;
}
fxRt.node.port.onmessage = null;
fxRt.node = null;
}
} catch (e) {}
try { if (fxRt.ws) { fxRt.ws.onclose = null; fxRt.ws.onmessage = null; try { fxRt.ws.close(); } catch (e) {} fxRt.ws = null; } } catch (e) {}
@@ -1814,7 +1927,9 @@ function fxRtStop(silent) {
fxRt.active = false;
fxRt.chainKey = null;
fxRt.latencyTotal = 0;
fxRtApplyPdc();
// PDC giam ve 0 sau khi dry chiem cho (trong setTimeout cua nhanh crossfade)
// hoac ngay neu khong co node — khong goi tai day de tranh gap 80ms.
if (!_fxStopNode) fxRtApplyPdc();
if (sid) {
try { window.SonicAPI.fxRealtimeStop({ session_id: sid }).catch(() => {}); } catch (e) {}
}
@@ -1837,6 +1952,20 @@ function fxRtApplyPdc() {
try { bus.pdcDelay.delayTime.setTargetAtTime(l / (ctx.sampleRate || 44100), ctx.currentTime, 0.05); } catch (e) {}
}
// Crossfade master-FX switch: ramp masterFxGain/masterFxDryGain thay vi
// hard-rewire (click). Hai gain nay duoc tao trong initMasterBus.
function fxRtCrossfade(toFx, token) {
const ctx = getAudioContext();
const bus = masterBus;
if (!ctx || !bus || !bus.masterFxGain || !bus.masterFxDryGain) return;
if (token !== undefined && token !== fxRt.startToken) return;
const tc = 0.025;
try {
bus.masterFxGain.gain.setTargetAtTime(toFx ? 1 : 0, ctx.currentTime, tc);
bus.masterFxDryGain.gain.setTargetAtTime(toFx ? 0 : 1, ctx.currentTime, tc);
} catch (e) {}
}
// ── Realtime VST FX per-track (SHM bridge) — mọi luồng track qua bridge ─────
// Mỗi track: session riêng (1 WS/SHM) cho TỪNG đường chain có âm — audio-clips
// (path 'audio', splice fxEntry→mods→worklet→fxLegacyIn) và MIDI/SF (path 'sf',
@@ -2743,22 +2872,6 @@ const MasterStripConsole = ({ masterVolume, setMasterVolume, masterVolL, setMast
React.createElement("div", {
className: "flex flex-col items-stretch w-[300px] shrink-0 strip-bg rounded-lg p-2 text-slate-300 select-none shadow-2xl relative overflow-hidden"
},
React.createElement("div", { className: "space-y-1.5 mb-2" },
React.createElement("button", {
onClick: () => setShowMasteringModal(true),
className: "w-full bg-slate-800 hover:bg-slate-700 text-[10px] font-semibold py-1 rounded border border-slate-700 text-slate-300 tracking-tight"
}, "FX CHAIN"),
React.createElement("button", {
onClick: () => setRefOpen(true),
className: "w-full bg-slate-800 hover:bg-slate-700 text-[10px] font-semibold py-1 rounded border border-slate-700 text-cyan-300 tracking-tight"
}, "A/B REF"),
React.createElement("div", {
className: "flex items-center justify-between bg-slate-950 border border-slate-800 rounded px-1.5 py-0.5 text-[10px] font-mono"
},
React.createElement("span", { className: "text-slate-400 truncate" }, "Output 1 / Output 2"),
React.createElement("i", { className: "fa-solid fa-circle-notch text-[9px] text-slate-500" })
)
),
React.createElement("div", { className: "flex flex-col items-center my-0.5" },
React.createElement("span", { className: "text-[9px] text-slate-400 font-mono" }, panText || 'center'),
React.createElement("div", { className: "flex items-center gap-1" },
@@ -12443,7 +12556,7 @@ const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExpor
React.useEffect(() => {
deadRef.current = false;
setDead(false);
const tick = setInterval(() => { const img = imgRef.current; if (img) img.src = url + '/frame?t=' + Date.now(); }, 100);
const tick = setInterval(() => { const img = imgRef.current; if (img && img.complete) img.src = url + '/frame?t=' + Date.now(); }, 100);
const ping = setInterval(() => {
fetch(url + '/ping').then(r => { if (!r.ok) throw new Error('bad'); }).catch(() => {
if (!deadRef.current) { deadRef.current = true; setDead(true); onDead && onDead(); }
@@ -12501,7 +12614,7 @@ const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExpor
} catch (e) {}
};
refresh();
const iv = setInterval(refresh, 500);
const iv = setInterval(refresh, 250);
return () => { alive = false; clearInterval(iv); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
@@ -12611,6 +12724,25 @@ let _persistFxGuiTrackId = null;
// Latest preset_b64 captured from each plugin's embedded GUI (poll /preset)
// - merged into the project on save so knob tweaks survive reload.
let _capturedPresetByPath = {};
// Baseline of each plugin's GUI state at embed-open time. /preset round-trips
// through getState/applyPreset and the blob can differ from the value the
// session started with; restarting the chain on that first difference reloads
// the VST and crackles. First capture after open = baseline (no restart); only
// a CHANGE vs baseline schedules the ~3s restart that syncs realtime to GUI.
let _fxGuiBaselineByPath = {};
// Debounced restart khi preset GUI đổi (preset poll) — _capturedPresetByPath
// đổi làm chainKey đổi nhưng không ai gọi fxRtStart/reconcile → session
// realtime GIỮ preset cũ (xoay knob trong GUI không đổi âm realtime). Restart
// ~3s sau khi knob DỪNG xoay (không restart mỗi poll 1.5s giữa lúc kéo — load
// VST ~4s ngắt âm). fn tự early-return khi chainKey không đổi.
let _presetRestartTimers = {};
function _schedulePresetRestart(key, fn, ms) {
if (_presetRestartTimers[key]) clearTimeout(_presetRestartTimers[key]);
_presetRestartTimers[key] = setTimeout(() => {
delete _presetRestartTimers[key];
try { fn(); } catch (e) {}
}, ms);
}
const FXRackModal = ({ track, onUpdateTrack, onClose }) => {
const [activeType, setActiveType] = React.useState('eq');
const [addModuleOpen, setAddModuleOpen] = React.useState(false);
@@ -12661,16 +12793,25 @@ const FXRackModal = ({ track, onUpdateTrack, onClose }) => {
fxGuiOpenRef.current = fxGuiOpen;
const closeFxGui = () => {
const g = fxGuiOpenRef.current;
if (g) { try { fetch(g.url + '/close', { method: 'POST' }).catch(() => {}); } catch (e) {} }
if (g) {
// Capture preset CUỐI trước khi đóng (poll 1.5s có thể chưa kịp khi chỉnh
// xong đóng ngay) — gọi /preset TRƯỚC /close (close kill process bridge).
try { fetch(g.url + '/preset').then(r => r.json()).then(d => { if (d && d.preset_b64 && g.path) _capturedPresetByPath[g.path] = d.preset_b64; }).catch(() => {}).then(() => { try { fetch(g.url + '/close', { method: 'POST' }).catch(() => {}); } catch (e) {} }); } catch (e) { try { fetch(g.url + '/close', { method: 'POST' }).catch(() => {}); } catch (e2) {} }
}
setFxGuiOpen(null);
if (g && g.path) {
const _tk = 'track:' + ((track && track.id) || '') + ':' + g.path;
if (_presetRestartTimers[_tk]) { clearTimeout(_presetRestartTimers[_tk]); delete _presetRestartTimers[_tk]; }
delete _fxGuiBaselineByPath[g.path];
}
if (onUpdateTrack && track) onUpdateTrack(track.id, { vstGuiOpenPath: null, vstGuiOpenName: null });
};
React.useEffect(() => () => { const g = fxGuiOpenRef.current; if (g) { try { fetch(g.url + '/hide', { method: 'POST' }).catch(() => {}); } catch (e) {} } }, []);
React.useEffect(() => () => { const g = fxGuiOpenRef.current; if (g) { try { fetch(g.url + '/hide', { method: 'POST' }).catch(() => {}); } catch (e) {} const _tk = 'track:' + ((track && track.id) || '') + ':' + g.path; if (_presetRestartTimers[_tk]) { clearTimeout(_presetRestartTimers[_tk]); delete _presetRestartTimers[_tk]; } if (g.path) delete _fxGuiBaselineByPath[g.path]; } }, []);
// Capture GUI knob tweaks while the embedded GUI is open (bridge /preset).
React.useEffect(() => {
if (!fxGuiOpen || !fxGuiOpen.url) return;
const iv = setInterval(() => {
fetch(fxGuiOpen.url + '/preset').then(r => r.json()).then(d => { if (d && d.preset_b64) _capturedPresetByPath[fxGuiOpen.path] = d.preset_b64; }).catch(() => {});
fetch(fxGuiOpen.url + '/preset').then(r => r.json()).then(d => { if (d && d.preset_b64) { const _p = fxGuiOpen.path; const _b = _fxGuiBaselineByPath[_p]; if (_b === undefined) { _fxGuiBaselineByPath[_p] = d.preset_b64; _capturedPresetByPath[_p] = d.preset_b64; return; } if (d.preset_b64 !== _b) { _fxGuiBaselineByPath[_p] = d.preset_b64; _capturedPresetByPath[_p] = d.preset_b64; const _tid = track && track.id; _schedulePresetRestart('track:' + (_tid || '') + ':' + _p, () => { if (_tid && window.__rebuildTrackFxGraph) window.__rebuildTrackFxGraph(_tid, {}); }, 3000); } } }).catch(() => {});
}, 1500);
return () => clearInterval(iv);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -12680,7 +12821,7 @@ const FXRackModal = ({ track, onUpdateTrack, onClose }) => {
if (!track || !track.vstGuiOpenPath || fxGuiOpen) return;
const s = (track.fxChain || []).find(x => x.path === track.vstGuiOpenPath);
if (!s || !window.SonicAPI || !window.SonicAPI.openFxGui) return;
window.SonicAPI.openFxGui({ path: s.path, name: s.name || s.path, embed: true, shm: fxRtTrackShmName(track && track.id) }).then(r => {
window.SonicAPI.openFxGui({ path: s.path, name: s.name || s.path, embed: true, shm: fxRtTrackShmName(track && track.id), preset_b64: _capturedPresetByPath[s.path] || s.preset_b64 || '' }).then(r => {
if (r && r.embed_url) setFxGuiOpen({ path: s.path, url: r.embed_url });
}).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -12688,7 +12829,7 @@ const FXRackModal = ({ track, onUpdateTrack, onClose }) => {
const toggleVst = (idx) => setVstChain(vstChain.map((s, i) => i === idx ? { ...s, bypass: !s.bypass } : s));
const removeVst = (idx) => { const rem = vstChain[idx]; if (rem && fxGuiOpen && fxGuiOpen.path === rem.path) closeFxGui(); if (rem) delete _capturedPresetByPath[rem.path]; if (idx === activeVstIdx) setActiveVstIdx(-1); setVstChain(vstChain.filter((_, i) => i !== idx)); };
const moveVst = (idx, dir) => { const j = idx + dir; if (j < 0 || j >= vstChain.length) return; const next = [...vstChain]; const mv = next.splice(idx, 1)[0]; next.splice(j, 0, mv); setVstChain(next); };
const openVstGui = (s, idx) => { if (!s || !s.path) { window.showToast && window.showToast('Chọn VST FX trước', 'warning'); return; } if (fxGuiOpen) closeFxGui(); if (idx !== undefined) setActiveVstIdx(idx); if (!window.SonicAPI || !window.SonicAPI.openFxGui) { window.showToast && window.showToast('Máy này không hỗ trợ mở GUI VST', 'warning'); return; } window.SonicAPI.openFxGui({ path: s.path, name: s.name || s.path, embed: true, shm: fxRtTrackShmName(track && track.id) }).then(r => { if (r && r.embed_url) { setFxGuiOpen({ path: s.path, url: r.embed_url, name: s.name || s.path }); if (onUpdateTrack && track) onUpdateTrack(track.id, { vstGuiOpenPath: s.path, vstGuiOpenName: s.name || s.path }); } else if (r && r.already_running && r.embed_url) { setFxGuiOpen({ path: s.path, url: r.embed_url, name: s.name || s.path }); if (onUpdateTrack && track) onUpdateTrack(track.id, { vstGuiOpenPath: s.path, vstGuiOpenName: s.name || s.path }); } else if (r && r.already_running) window.showToast && window.showToast('GUI của plugin này đang mở sẵn (cửa sổ ngoài)', 'info'); else window.showToast && window.showToast('Không mở được GUI embed', 'error'); }).catch(err => window.showToast && window.showToast('Lỗi mở GUI: ' + (err.message || err), 'error')); };
const openVstGui = (s, idx) => { if (!s || !s.path) { window.showToast && window.showToast('Chọn VST FX trước', 'warning'); return; } if (fxGuiOpen) closeFxGui(); if (idx !== undefined) setActiveVstIdx(idx); if (!window.SonicAPI || !window.SonicAPI.openFxGui) { window.showToast && window.showToast('Máy này không hỗ trợ mở GUI VST', 'warning'); return; } window.SonicAPI.openFxGui({ path: s.path, name: s.name || s.path, embed: true, shm: fxRtTrackShmName(track && track.id), preset_b64: _capturedPresetByPath[s.path] || s.preset_b64 || '' }).then(r => { if (r && r.embed_url) { setFxGuiOpen({ path: s.path, url: r.embed_url, name: s.name || s.path }); if (onUpdateTrack && track) onUpdateTrack(track.id, { vstGuiOpenPath: s.path, vstGuiOpenName: s.name || s.path }); } else if (r && r.already_running && r.embed_url) { setFxGuiOpen({ path: s.path, url: r.embed_url, name: s.name || s.path }); if (onUpdateTrack && track) onUpdateTrack(track.id, { vstGuiOpenPath: s.path, vstGuiOpenName: s.name || s.path }); } else if (r && r.already_running) window.showToast && window.showToast('GUI của plugin này đang mở sẵn (cửa sổ ngoài)', 'info'); else window.showToast && window.showToast('Không mở được GUI embed', 'error'); }).catch(err => window.showToast && window.showToast('Lỗi mở GUI: ' + (err.message || err), 'error')); };
const addVst = (path) => { const fx = (fxList || []).find(f => f.path === path); if (!fx) return; const next = [...vstChain, { type: 'vst3', path: fx.path, name: fx.name || fx.id, preset_b64: '', bypass: false }]; setVstChain(next); setActiveVstIdx(next.length - 1); };
const changeVstPlugin = (idx, path) => { const fx = (fxList || []).find(f => f.path === path); delete _capturedPresetByPath[path]; setVstChain(vstChain.map((s, i) => i === idx ? { ...s, path, name: fx ? (fx.name || fx.id) : s.name, preset_b64: '' } : s)); };
const uploadVstPreset = (idx) => {
@@ -13244,17 +13385,25 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
masterGuiOpenRef.current = masterGuiOpen;
const closeMasterGui = () => {
const g = masterGuiOpenRef.current;
if (g) { try { fetch(g.url + '/close', { method: 'POST' }).catch(() => {}); } catch (e) {} }
if (g) {
// Capture preset CUỐI trước khi đóng (giống closeFxGui) — /preset rồi /close.
try { fetch(g.url + '/preset').then(r => r.json()).then(d => { if (d && d.preset_b64 && g.path) _capturedPresetByPath[g.path] = d.preset_b64; }).catch(() => {}).then(() => { try { fetch(g.url + '/close', { method: 'POST' }).catch(() => {}); } catch (e) {} }); } catch (e) { try { fetch(g.url + '/close', { method: 'POST' }).catch(() => {}); } catch (e2) {} }
}
setMasterGuiOpen(null);
if (g && g.path) {
const _mk = 'master:' + g.path;
if (_presetRestartTimers[_mk]) { clearTimeout(_presetRestartTimers[_mk]); delete _presetRestartTimers[_mk]; }
delete _fxGuiBaselineByPath[g.path];
}
setActiveMasterVstPath(null);
setOzState(prev => { const n = { ...(prev || {}) }; delete n.gui_open_path; delete n.gui_open_name; return n; });
};
React.useEffect(() => () => { const g = masterGuiOpenRef.current; if (g) { try { fetch(g.url + '/hide', { method: 'POST' }).catch(() => {}); } catch (e) {} } }, []);
React.useEffect(() => () => { const g = masterGuiOpenRef.current; if (g) { try { fetch(g.url + '/hide', { method: 'POST' }).catch(() => {}); } catch (e) {} const _mk = 'master:' + g.path; if (_presetRestartTimers[_mk]) { clearTimeout(_presetRestartTimers[_mk]); delete _presetRestartTimers[_mk]; } if (g.path) delete _fxGuiBaselineByPath[g.path]; } }, []);
// Capture GUI knob tweaks while the embedded GUI is open (bridge /preset).
React.useEffect(() => {
if (!masterGuiOpen || !masterGuiOpen.url) return;
const iv = setInterval(() => {
fetch(masterGuiOpen.url + '/preset').then(r => r.json()).then(d => { if (d && d.preset_b64) _capturedPresetByPath[masterGuiOpen.path] = d.preset_b64; }).catch(() => {});
fetch(masterGuiOpen.url + '/preset').then(r => r.json()).then(d => { if (d && d.preset_b64) { const _p = masterGuiOpen.path; const _b = _fxGuiBaselineByPath[_p]; if (_b === undefined) { _fxGuiBaselineByPath[_p] = d.preset_b64; _capturedPresetByPath[_p] = d.preset_b64; return; } if (d.preset_b64 !== _b) { _fxGuiBaselineByPath[_p] = d.preset_b64; _capturedPresetByPath[_p] = d.preset_b64; _schedulePresetRestart('master:' + _p, () => fxRtStart(), 3000); } } }).catch(() => {});
}, 1500);
return () => clearInterval(iv);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -13264,7 +13413,7 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
if (!isOpen || !ozState || !ozState.gui_open_path || masterGuiOpen) return;
const s = (ozState.vstFxChain || []).find(x => x.path === ozState.gui_open_path);
if (!s || !window.SonicAPI || !window.SonicAPI.openFxGui) return;
window.SonicAPI.openFxGui({ path: s.path, name: ozState.gui_open_name || s.name || s.path, embed: true, shm: fxRt.sessionId || '' }).then(r => {
window.SonicAPI.openFxGui({ path: s.path, name: ozState.gui_open_name || s.name || s.path, embed: true, shm: fxRt.sessionId || '', preset_b64: _capturedPresetByPath[s.path] || s.preset_b64 || '' }).then(r => {
if (r && r.embed_url) setMasterGuiOpen({ path: s.path, url: r.embed_url });
}).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -13290,7 +13439,7 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
const toggleMasterVst = (idx) => setMasterVstChain(masterVstChain.map((s, i) => i === idx ? { ...s, bypass: !s.bypass } : s));
const removeMasterVst = (idx) => { const rem = masterVstChain[idx]; if (rem) delete _capturedPresetByPath[rem.path]; setMasterVstChain(masterVstChain.filter((_, i) => i !== idx)); };
const moveMasterVst = (idx, dir) => { const j = idx + dir; if (j < 0 || j >= masterVstChain.length) return; const next = [...masterVstChain]; const mv = next.splice(idx, 1)[0]; next.splice(j, 0, mv); setMasterVstChain(next); };
const openMasterVstGui = (s) => { if (!s || !s.path) { window.showToast && window.showToast('Chọn VST FX trước', 'warning'); return; } if (masterGuiOpen) closeMasterGui(); if (s.path) setActiveMasterVstPath(s.path); if (!window.SonicAPI || !window.SonicAPI.openFxGui) { window.showToast && window.showToast('Máy này không hỗ trợ mở GUI VST', 'warning'); return; } window.SonicAPI.openFxGui({ path: s.path, name: s.name || s.path, embed: true, shm: fxRt.sessionId || '' }).then(r => { if (r && r.embed_url) { setMasterGuiOpen({ path: s.path, url: r.embed_url, name: s.name || s.path }); setOzState(prev => ({ ...(prev || {}), gui_open_path: s.path, gui_open_name: s.name || s.path })); } else if (r && r.already_running && r.embed_url) { setMasterGuiOpen({ path: s.path, url: r.embed_url, name: s.name || s.path }); setOzState(prev => ({ ...(prev || {}), gui_open_path: s.path, gui_open_name: s.name || s.path })); } else if (r && r.already_running) window.showToast && window.showToast('GUI của plugin này đang mở sẵn (cửa sổ ngoài)', 'info'); else window.showToast && window.showToast('Không mở được GUI embed', 'error'); }).catch(err => window.showToast && window.showToast('Lỗi mở GUI: ' + (err.message || err), 'error')); };
const openMasterVstGui = (s) => { if (!s || !s.path) { window.showToast && window.showToast('Chọn VST FX trước', 'warning'); return; } if (masterGuiOpen) closeMasterGui(); if (s.path) setActiveMasterVstPath(s.path); if (!window.SonicAPI || !window.SonicAPI.openFxGui) { window.showToast && window.showToast('Máy này không hỗ trợ mở GUI VST', 'warning'); return; } window.SonicAPI.openFxGui({ path: s.path, name: s.name || s.path, embed: true, shm: fxRt.sessionId || '', preset_b64: _capturedPresetByPath[s.path] || s.preset_b64 || '' }).then(r => { if (r && r.embed_url) { setMasterGuiOpen({ path: s.path, url: r.embed_url, name: s.name || s.path }); setOzState(prev => ({ ...(prev || {}), gui_open_path: s.path, gui_open_name: s.name || s.path })); } else if (r && r.already_running && r.embed_url) { setMasterGuiOpen({ path: s.path, url: r.embed_url, name: s.name || s.path }); setOzState(prev => ({ ...(prev || {}), gui_open_path: s.path, gui_open_name: s.name || s.path })); } else if (r && r.already_running) window.showToast && window.showToast('GUI của plugin này đang mở sẵn (cửa sổ ngoài)', 'info'); else window.showToast && window.showToast('Không mở được GUI embed', 'error'); }).catch(err => window.showToast && window.showToast('Lỗi mở GUI: ' + (err.message || err), 'error')); };
const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.path === path); if (!fx) return; setMasterVstChain([...masterVstChain, { type: 'vst3', path: fx.path, name: fx.name || fx.id, preset_b64: '', bypass: false }]);
openMasterVstGui({ path: fx.path, name: fx.name || fx.id }); };
const changeMasterVstPlugin = (idx, path) => { const fx = (masterFxList || []).find(f => f.path === path); delete _capturedPresetByPath[path]; setMasterVstChain(masterVstChain.map((s, i) => i === idx ? { ...s, path, name: fx ? (fx.name || fx.id) : s.name, preset_b64: '' } : s)); };
@@ -19052,7 +19201,7 @@ const App = () => {
// masterConnected nhưng fxRt không tự dừng → tắt PWR mà âm không đổi.
try {
if (fxRtChain().length) { fxRtStart(); }
else if (fxRt.active) { fxRtStop(); }
else if (fxRt.active || fxRt.starting) { fxRtStop(); }
} catch (e) { console.warn('[FX RT] resync fail:', e); }
// Re-sync live track routes: mastering ON → mọi track qua chain (♪ bị
// override bởi effMidiBypass/effAudioBypass) — nút PWR bật/tắt phải áp
@@ -23684,11 +23833,11 @@ const App = () => {
console.warn('[FX RT track] start fail', trackId, path, e);
return;
}
const entry = { sessionId: st.session_id, ws: null, node: null, ready: false, path, spliced: null, chainKey: fxRtTrackChainKey(chain), paramsBySlot: chain.map(s => fxRtEntryParams(s, null)), inQueue: [], pumpTimer: null };
const entry = { sessionId: st.session_id, ws: null, node: null, ready: false, path, spliced: null, chainKey: fxRtTrackChainKey(chain), paramsBySlot: chain.map(s => fxRtEntryParams(s, null)), inQueue: [], pumpTimer: null, lastInSent: 0 };
fxRtTracks[key] = entry;
fxRtGuiPushShm();
try {
await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608212401`);
await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608241200`);
const node = new AudioWorkletNode(ctx, 'sf-fx-realtime', { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [2] });
entry.node = node;
const u = new URL(API_BASE_URL);
@@ -23725,16 +23874,34 @@ const App = () => {
}
if (d.type !== 'in') return;
// Burst 4 block (1024 mẫu) → tách 4 WS frame 512 floats → bridge batch 4.
if (ws.readyState === 1) {
try {
const f = new Float32Array(d.data);
if (f.length > 512) {
for (let i = 0; i < f.length; i += 512) ws.send(f.slice(i, i + 512).buffer);
} else {
ws.send(d.data);
// Pace 1 burst/21ms (giống master) — tránh dồn frame sau stall làm tràn ring.
entry.inQueue.push(d);
const pump = () => {
if (!entry.inQueue.length) return;
const now = Date.now();
while (entry.inQueue.length && now - entry.lastInSent >= FXRT_BURST_MS) {
entry.lastInSent = now;
const dd = entry.inQueue.shift();
if (entry.ws && entry.ws.readyState === 1) {
try {
const f = new Float32Array(dd.data);
if (f.length > 512) {
for (let i = 0; i < f.length; i += 512) entry.ws.send(f.slice(i, i + 512).buffer);
} else {
entry.ws.send(dd.data);
}
} catch (e) {}
}
} catch (e) {}
}
}
if (entry.inQueue.length) {
if (entry.pumpTimer) clearTimeout(entry.pumpTimer);
entry.pumpTimer = setTimeout(pump, Math.max(1, FXRT_BURST_MS - (now - entry.lastInSent)));
} else if (entry.pumpTimer) {
clearTimeout(entry.pumpTimer);
entry.pumpTimer = null;
}
};
pump();
};
} catch (e) {
console.warn('[FX RT track] setup fail', trackId, path, e);
@@ -23750,6 +23917,23 @@ const App = () => {
entry.spliced = null;
} catch (e) { console.warn('[FX RT track] unsplice fail', trackId, path, e); }
};
// Chain đổi trên session ĐANG CHẠY — set_chain KHÔNG restart (bridge swap
// atomic khi worker load xong) → load VST FX / đổi preset GUI không ngắt
// âm/crackle. entry.chainKey cập nhật ngay — reconcile sau đó thấy khớp.
const fxRtTrackSetChain = (trackId, path, track, chainKey) => {
const key = trackId + ':' + path;
const entry = fxRtTracks[key];
if (!entry || !entry.ws || entry.ws.readyState !== 1) return;
const chain = fxRtTrackChain(track);
if (!chain.length) return;
const chainPayload = chain.map(s => s.type === 'vst3'
? { type: 'vst3', path: s.path, name: s.name || s.path, preset_b64: s.preset_b64 || '', bypass: false }
: { type: 'builtin', id: s.type, params: (s.params && Object.keys(s.params).length ? s.params : {}), bypass: false });
entry.chainKey = chainKey;
window.SonicAPI.fxRealtimeSetChain({ session_id: entry.sessionId, fx_chain: chainPayload }).then(() => {
try { (entry.paramsBySlot || []).forEach((pl, slot) => pl.forEach(p => { if (entry.ws && entry.ws.readyState === 1) entry.ws.send(JSON.stringify({ cmd: 'set_param', slot, key: p.key, value: p.value })); })); } catch (e) {}
}).catch(e => console.warn('[FX RT track] set-chain fail', trackId, path, e));
};
const fxRtTrackSplice = (trackId, path) => {
const key = trackId + ':' + path;
const entry = fxRtTracks[key];
@@ -23800,7 +23984,14 @@ const App = () => {
const key = trackId + ':' + p;
const entry = fxRtTracks[key];
if (!entry) fxRtTrackStart(trackId, track, p);
else if (entry.chainKey !== chainKey) { fxRtTrackStop(trackId, p, true); fxRtTrackStart(trackId, track, p); }
else if (entry.chainKey !== chainKey) {
// Chain đổi (load/đổi VST FX, preset GUI, bypass slot) — session
// đang chạy → set_chain KHÔNG restart (bridge swap atomic khi load
// xong) → hết crackle khi load VST / chỉnh thông số. Session chưa
// ready (start in-flight) → stop+start lại cho đúng chain.
if (entry.ready) fxRtTrackSetChain(trackId, track, p, chainKey);
else { fxRtTrackStop(trackId, p, true); fxRtTrackStart(trackId, track, p); }
}
else if (entry.ready && node) {
fxRtTrackUnsplice(trackId, p); // chain mới (sau rebuild) — splice lại đúng mods
fxRtTrackSplice(trackId, p);
File diff suppressed because one or more lines are too long
+1
View File
@@ -87,6 +87,7 @@ window.API_BASE_URL = (window.API_BASE_URL || window.location.origin).replace(/\
// /ws/fx-realtime/{id} đẩy/kéo block audio; stop → tắt bridge.
fxRealtimeStart: (payload) => apiRequest('/api/v1/plugins/fx-realtime/start', { method: 'POST', body: JSON.stringify(payload) }),
fxRealtimeStop: (payload) => apiRequest('/api/v1/plugins/fx-realtime/stop', { method: 'POST', body: JSON.stringify(payload) }),
fxRealtimeSetChain: (payload) => apiRequest('/api/v1/plugins/fx-realtime/set-chain', { method: 'POST', body: JSON.stringify(payload) }),
// Mở native GUI của VST FX (mastering chain / FX rack) qua bridge
openFxGui: (payload) => apiRequest('/api/v1/plugins/fx-gui', { method: 'POST', body: JSON.stringify(payload) }),
soundfontRender: (payload) => apiRequest('/api/v1/plugins/soundfont-render', { method: 'POST', body: JSON.stringify(payload) }),
+24 -5
View File
@@ -24,7 +24,8 @@ class SfFxRealtimeProcessor extends AudioWorkletProcessor {
super();
this.BLOCK = 256; // FXRT_BLOCK
this.BURST = 4; // số block gom 1 frame (bridge batch)
this.FILL = 8; // block buffer tối thiểu trước khi phát
this.FILL = 16; // 16 block ~86ms: buffer khởi đầu — hấp thụ jitter main-thread (GUI decode, rAF, solo restart)
this.REBUFFER = 2; // 2 block ~11ms: sau underrun chỉ cần bấy nhiêu để phát lại — gap ngắn, không còn 86ms im lặng
this.CAP = 32; // cap queue chặn overflow
this.bufLen = this.BLOCK * this.BURST;
this.inL = new Float32Array(this.bufLen);
@@ -33,6 +34,10 @@ class SfFxRealtimeProcessor extends AudioWorkletProcessor {
this.outQueue = []; // [{l: Float32Array, r: Float32Array}]
this.outPos = 0;
this.playing = false; // false → im lặng (đang chờ đủ FILL)
this.hasStarted = false; // đã phát block đầu chưa — phân biệt FILL khởi đầu vs REBUFFER sau underrun
this.FADE = 512; // fade-in 512 mẫu (~11ms @48k) khi bắt đầu phát/resume — hết click
this.outGain = 0; // gain phát hiện tại (0→1 trong fade)
this.fadeLeft = 0; // số mẫu còn lại của fade
this.port.onmessage = (e) => {
const d = e.data;
if (d && d.type === 'set_param') { this.port.postMessage(d); return; }
@@ -76,16 +81,30 @@ class SfFxRealtimeProcessor extends AudioWorkletProcessor {
for (let i = 0; i < n; i++) {
if (this.playing && this.outQueue.length === 0) this.playing = false;
if (!this.playing) {
if (this.outQueue.length >= this.FILL) this.playing = true;
else {
// Khởi đầu: chờ đủ FILL (hấp thụ jitter). Sau underrun: chỉ cần
// REBUFFER (2 block ~11ms) — gap ngắn thay vì 86ms im lặng.
const need = this.hasStarted ? this.REBUFFER : this.FILL;
if (this.outQueue.length >= need) {
this.playing = true;
this.hasStarted = true;
// Fade-in mượt (hết click lúc FILL xong / resume sau underrun); báo
// main thread để crossfade dry→fx đúng thời điểm.
this.outGain = 0;
this.fadeLeft = this.FADE;
this.port.postMessage({ type: 'started' });
} else {
outL[i] = 0;
if (outR) outR[i] = 0;
continue;
}
}
const blk = this.outQueue[0];
outL[i] = blk.l[this.outPos];
if (outR) outR[i] = blk.r[this.outPos];
if (this.fadeLeft > 0) {
this.outGain = Math.min(1, this.outGain + 1 / this.FADE);
this.fadeLeft--;
}
outL[i] = blk.l[this.outPos] * this.outGain;
if (outR) outR[i] = blk.r[this.outPos] * this.outGain;
this.outPos++;
if (this.outPos >= this.BLOCK) {
this.outQueue.shift();
+1 -1
View File
@@ -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=202608212402" defer></script>
<script src="/static/js/app.precompiled.js?v=202608241200" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style>
:root {
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Diagnostic harness: spawn realtime + GUI bridge, feed audio, poll /preset.
Checks: (1) blob drift over time (would trigger chainKey change -> set_chain
-> crackle), (2) /preset latency (HTTP worker block -> dead clicks)."""
import ctypes, json, os, subprocess, tempfile, threading, time, uuid, urllib.request, sys
import numpy as np
from multiprocessing import shared_memory
FXRT_MAGIC = 0x46585254
FXRT_BLOCK = 256
FXRT_IN_SLOTS = 4
FXRT_OUT_SLOTS = 8
FXRT_CTRL_SLOTS = 8
FXRT_LAT_SLOTS = 8
FXRT_STATE_STARTING = 0
FXRT_STATE_READY = 1
class FxRTHeader(ctypes.Structure):
_fields_ = [("magic", ctypes.c_uint32), ("state", ctypes.c_uint32),
("sample_rate", ctypes.c_uint32), ("block_size", ctypes.c_uint32),
("running", ctypes.c_uint32), ("heartbeat", ctypes.c_uint32),
("in_write", ctypes.c_uint32), ("in_read", ctypes.c_uint32),
("in_slots", ctypes.c_uint32), ("out_write", ctypes.c_uint32),
("out_read", ctypes.c_uint32), ("out_slots", ctypes.c_uint32),
("ctrl_write", ctypes.c_uint32), ("ctrl_read", ctypes.c_uint32),
("ctrl_slots", ctypes.c_uint32), ("lat_write", ctypes.c_uint32),
("lat_read", ctypes.c_uint32), ("lat_slots", ctypes.c_uint32)]
HEADER_SIZE = ctypes.sizeof(FxRTHeader)
IN_L_OFF = HEADER_SIZE
IN_R_OFF = IN_L_OFF + FXRT_IN_SLOTS * FXRT_BLOCK * 4
OUT_L_OFF = IN_R_OFF + FXRT_IN_SLOTS * FXRT_BLOCK * 4
OUT_R_OFF = OUT_L_OFF + FXRT_OUT_SLOTS * FXRT_BLOCK * 4
TOTAL_SIZE = OUT_R_OFF + FXRT_OUT_SLOTS * FXRT_BLOCK * 4
EXE = r"C:\Users\locpham\SonicForgeStudio\src-tauri\binaries\fx_vst_bridge.exe"
PLUGIN = sys.argv[1] if len(sys.argv) > 1 else r"C:\Program Files\Common Files\VST3\WaveObserver.vst3"
DURATION = float(sys.argv[2]) if len(sys.argv) > 2 else 30.0
def http_get(url, timeout=10):
t0 = time.time()
try:
with urllib.request.urlopen(url, timeout=timeout) as r:
body = r.read().decode()
return time.time() - t0, body
except Exception as e:
return time.time() - t0, "ERR:" + str(e)
def main():
name = "SonicForge_FXRT_" + uuid.uuid4().hex[:12]
print("plugin:", PLUGIN, "shm:", name, flush=True)
shm = shared_memory.SharedMemory(name=name, create=True, size=TOTAL_SIZE)
shm.buf[:TOTAL_SIZE] = b"\x00" * TOTAL_SIZE
h = FxRTHeader.from_buffer(shm.buf)
h.magic = FXRT_MAGIC; h.state = FXRT_STATE_STARTING
h.sample_rate = 44100; h.block_size = FXRT_BLOCK; h.running = 1
h.in_slots = FXRT_IN_SLOTS; h.out_slots = FXRT_OUT_SLOTS
h.ctrl_slots = FXRT_CTRL_SLOTS; h.lat_slots = FXRT_LAT_SLOTS
npbuf = np.frombuffer(shm.buf, dtype=np.float32)
stop = threading.Event()
procs = []
try:
job1 = {"sample_rate": 44100, "block_size": 256, "seq": 0,
"fx_chain": [{"type": "vst3", "path": PLUGIN, "name": "probe", "preset_b64": "", "bypass": False}]}
fd, jp1 = tempfile.mkstemp(suffix=".json", prefix="fxrt_probe_")
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(job1, f)
p1 = subprocess.Popen([EXE, "--realtime-fx", jp1, "--shm", name, "--parent", str(os.getpid())],
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
procs.append(p1)
t0 = time.time()
while time.time() - t0 < 30 and h.state != FXRT_STATE_READY:
time.sleep(0.05)
if p1.poll() is not None:
print("REALTIME BRIDGE EXITED rc=", p1.returncode, flush=True); return
print("realtime state:", h.state, "took %.2fs" % (time.time() - t0), flush=True)
if h.state != FXRT_STATE_READY:
print("realtime not ready — aborting", flush=True); return
job2 = {"path": PLUGIN, "name": "probe", "shm": name, "preset_b64": ""}
fd, jp2 = tempfile.mkstemp(suffix=".json", prefix="fxgui_probe_")
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(job2, f)
p2 = subprocess.Popen([EXE, "--fx-gui", jp2],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
procs.append(p2)
port = None
t0 = time.time()
while time.time() - t0 < 20:
line = p2.stdout.readline()
if not line:
if p2.poll() is not None: break
continue
line = line.strip()
if line.startswith("SF_FXGUI_PORT="):
port = int(line.split("=", 1)[1]); break
print("[gui]", line, flush=True)
if not port:
print("GUI bridge: no port — rc=", p2.poll(), flush=True); return
url = "http://127.0.0.1:%d" % port
print("gui port:", port, flush=True)
def feed():
phase = 0.0
while not stop.is_set():
if h.in_write - h.in_read < FXRT_IN_SLOTS:
slot = h.in_write & (FXRT_IN_SLOTS - 1)
off = IN_L_OFF + slot * FXRT_BLOCK
t = np.arange(FXRT_BLOCK) / 44100.0 + phase
sine = 0.1 * np.sin(2 * np.pi * 440 * t).astype(np.float32)
npbuf[off:off + FXRT_BLOCK] = sine
npbuf[off + FXRT_IN_SLOTS * FXRT_BLOCK:off + FXRT_IN_SLOTS * FXRT_BLOCK + FXRT_BLOCK] = sine
h.in_write += 1
phase += FXRT_BLOCK / 44100.0
time.sleep(0.003)
th = threading.Thread(target=feed, daemon=True)
th.start()
# Poll /preset every 1.5s — same as frontend — measure drift + latency.
last = None
last16 = None
drift_changes = 0
t0 = time.time()
n = 0
while time.time() - t0 < DURATION:
time.sleep(1.5)
lat, body = http_get(url + "/preset")
n += 1
import json as _j
try:
d = _j.loads(body)
b64 = d.get("preset_b64", "")
except Exception:
print(" poll %d: bad json (%s)" % (n, body[:80]), flush=True)
continue
b16 = b64[:16]
first_diff = (last16 is not None and b16 != last16)
full_diff = (last is not None and b64 != last)
if first_diff: drift_changes += 1
print(" poll %d lat=%.0fms blob_len=%d first16=%s %s%s%s" % (
n, lat * 1000, len(b64), b16,
"FIRST16_CHANGED" if first_diff else "",
" full_changed" if (full_diff and not first_diff) else "",
" (NEW)" if last is None else ""), flush=True)
last = b64; last16 = b16
print("RESULT: %d polls, %d first16 changes (would trigger set_chain -> crackle)" % (n, drift_changes), flush=True)
finally:
stop.set()
for p in procs:
try: p.terminate()
except Exception: pass
time.sleep(0.5)
for p in procs:
try: p.kill()
except Exception: pass
shm.close()
try: shm.unlink()
except Exception: pass
if __name__ == "__main__":
main()
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Measure out-ring glitches while GUI bridge runs with /frame + /preset load.
Feeds sine into in ring (realtime pace), reads out ring continuously, detects
discontinuities (sample jumps) in the output signal."""
import ctypes, json, os, subprocess, tempfile, threading, time, uuid, urllib.request, sys
import numpy as np
from multiprocessing import shared_memory
FXRT_MAGIC = 0x46585254
FXRT_BLOCK = 256
FXRT_IN_SLOTS = 4
FXRT_OUT_SLOTS = 8
FXRT_CTRL_SLOTS = 8
FXRT_LAT_SLOTS = 8
FXRT_STATE_STARTING = 0
FXRT_STATE_READY = 1
class FxRTHeader(ctypes.Structure):
_fields_ = [("magic", ctypes.c_uint32), ("state", ctypes.c_uint32),
("sample_rate", ctypes.c_uint32), ("block_size", ctypes.c_uint32),
("running", ctypes.c_uint32), ("heartbeat", ctypes.c_uint32),
("in_write", ctypes.c_uint32), ("in_read", ctypes.c_uint32),
("in_slots", ctypes.c_uint32), ("out_write", ctypes.c_uint32),
("out_read", ctypes.c_uint32), ("out_slots", ctypes.c_uint32),
("ctrl_write", ctypes.c_uint32), ("ctrl_read", ctypes.c_uint32),
("ctrl_slots", ctypes.c_uint32), ("lat_write", ctypes.c_uint32),
("lat_read", ctypes.c_uint32), ("lat_slots", ctypes.c_uint32)]
HEADER_SIZE = ctypes.sizeof(FxRTHeader)
IN_L_OFF = HEADER_SIZE
IN_R_OFF = IN_L_OFF + FXRT_IN_SLOTS * FXRT_BLOCK * 4
OUT_L_OFF = IN_R_OFF + FXRT_IN_SLOTS * FXRT_BLOCK * 4
OUT_R_OFF = OUT_L_OFF + FXRT_OUT_SLOTS * FXRT_BLOCK * 4
TOTAL_SIZE = OUT_R_OFF + FXRT_OUT_SLOTS * FXRT_BLOCK * 4
EXE = r"C:\Users\locpham\SonicForgeStudio\src-tauri\binaries\fx_vst_bridge.exe"
PLUGIN = sys.argv[1] if len(sys.argv) > 1 else r"C:\Program Files\Common Files\VST3\WaveObserver.vst3"
DURATION = float(sys.argv[2]) if len(sys.argv) > 2 else 30.0
FRAME = sys.argv[3] if len(sys.argv) > 3 else "on" # "on" = poll /frame 30fps
def http_get(url, timeout=5):
try:
with urllib.request.urlopen(url, timeout=timeout) as r:
return r.read()
except Exception as e:
return b"ERR:" + str(e).encode()
def main():
name = "SonicForge_FXRT_" + uuid.uuid4().hex[:12]
print("plugin:", PLUGIN, "shm:", name, "frame:", FRAME, flush=True)
shm = shared_memory.SharedMemory(name=name, create=True, size=TOTAL_SIZE)
shm.buf[:TOTAL_SIZE] = b"\x00" * TOTAL_SIZE
h = FxRTHeader.from_buffer(shm.buf)
h.magic = FXRT_MAGIC; h.state = FXRT_STATE_STARTING
h.sample_rate = 44100; h.block_size = FXRT_BLOCK; h.running = 1
h.in_slots = FXRT_IN_SLOTS; h.out_slots = FXRT_OUT_SLOTS
h.ctrl_slots = FXRT_CTRL_SLOTS; h.lat_slots = FXRT_LAT_SLOTS
npbuf = np.frombuffer(shm.buf, dtype=np.float32)
stop = threading.Event()
procs = []
glitches = []
out_total = [0]
try:
job1 = {"sample_rate": 44100, "block_size": 256, "seq": 0,
"fx_chain": [{"type": "vst3", "path": PLUGIN, "name": "probe", "preset_b64": "", "bypass": False}]}
fd, jp1 = tempfile.mkstemp(suffix=".json", prefix="fxrt_probe_")
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(job1, f)
p1 = subprocess.Popen([EXE, "--realtime-fx", jp1, "--shm", name, "--parent", str(os.getpid())],
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
procs.append(p1)
t0 = time.time()
while time.time() - t0 < 30 and h.state != FXRT_STATE_READY:
time.sleep(0.05)
if p1.poll() is not None:
print("REALTIME BRIDGE EXITED rc=", p1.returncode, flush=True); return
if h.state != FXRT_STATE_READY:
print("realtime not ready — aborting", flush=True); return
print("realtime ready in %.2fs" % (time.time() - t0), flush=True)
job2 = {"path": PLUGIN, "name": "probe", "shm": name, "preset_b64": ""}
fd, jp2 = tempfile.mkstemp(suffix=".json", prefix="fxgui_probe_")
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(job2, f)
p2 = subprocess.Popen([EXE, "--fx-gui", jp2],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
procs.append(p2)
port = None
t0 = time.time()
while time.time() - t0 < 20:
line = p2.stdout.readline()
if not line:
if p2.poll() is not None: break
continue
line = line.strip()
if line.startswith("SF_FXGUI_PORT="):
port = int(line.split("=", 1)[1]); break
if not port:
print("GUI bridge: no port — rc=", p2.poll(), flush=True); return
url = "http://127.0.0.1:%d" % port
print("gui port:", port, flush=True)
# Feed sine at realtime pace; read out ring continuously; detect glitches.
phase = [0.0]
def feed():
while not stop.is_set():
if h.in_write - h.in_read < FXRT_IN_SLOTS:
slot = h.in_write & (FXRT_IN_SLOTS - 1)
off = IN_L_OFF + slot * FXRT_BLOCK
t = np.arange(FXRT_BLOCK) / 44100.0 + phase[0]
sine = 0.1 * np.sin(2 * np.pi * 440 * t).astype(np.float32)
npbuf[off // 4:(off // 4) + FXRT_BLOCK] = sine
npbuf[(off + FXRT_IN_SLOTS * FXRT_BLOCK) // 4:(off + FXRT_IN_SLOTS * FXRT_BLOCK) // 4 + FXRT_BLOCK] = sine
h.in_write += 1
phase[0] += FXRT_BLOCK / 44100.0
time.sleep(0.003)
threading.Thread(target=feed, daemon=True).start()
def frame_loop():
while not stop.is_set():
http_get(url + "/frame")
time.sleep(1.0 / 30.0)
if FRAME == "on":
threading.Thread(target=frame_loop, daemon=True).start()
def reader():
last = h.out_write
prev_samp = 0.0
prev_ok = False
expected_next = None
while not stop.is_set():
w = h.out_write
while last < w:
slot = last & (FXRT_OUT_SLOTS - 1)
off = OUT_L_OFF + slot * FXRT_BLOCK
blk = npbuf[off // 4:(off // 4) + FXRT_BLOCK].copy()
# detect discontinuity: jump vs previous sample
if prev_ok:
for i in range(FXRT_BLOCK):
if abs(blk[i] - prev_samp) > 0.25:
glitches.append((time.time(), i))
break
prev_samp = blk[-1]
prev_ok = True
out_total[0] += FXRT_BLOCK
last += 1
time.sleep(0.001)
threading.Thread(target=reader, daemon=True).start()
# /preset poll like frontend
t0 = time.time()
n = 0
while time.time() - t0 < DURATION:
time.sleep(1.5)
http_get(url + "/preset")
n += 1
stop.set()
time.sleep(0.3)
print("RESULT: %d polls, out blocks=%d, glitches=%d" % (n, out_total[0], len(glitches)), flush=True)
for g in glitches[:10]:
print(" glitch t=%.2f idx=%d" % (g[0] - (time.time() - t0), g[1]), flush=True)
finally:
stop.set()
for p in procs:
try: p.terminate()
except Exception: pass
time.sleep(0.5)
for p in procs:
try: p.kill()
except Exception: pass
shm.close()
if __name__ == "__main__":
main()
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""Measure out-ring glitches while set_chain fires every 3s (preset apply),
with GUI bridge + frame + preset poll running. set_chain is the only path
that reloads a VST3 in the realtime process."""
import ctypes, json, os, subprocess, tempfile, threading, time, uuid, urllib.request, sys
import numpy as np
from multiprocessing import shared_memory
FXRT_MAGIC = 0x46585254
FXRT_BLOCK = 256
FXRT_IN_SLOTS = 4
FXRT_OUT_SLOTS = 8
FXRT_CTRL_SLOTS = 8
FXRT_LAT_SLOTS = 8
FXRT_STATE_STARTING = 0
FXRT_STATE_READY = 1
class FxRTHeader(ctypes.Structure):
_fields_ = [("magic", ctypes.c_uint32), ("state", ctypes.c_uint32),
("sample_rate", ctypes.c_uint32), ("block_size", ctypes.c_uint32),
("running", ctypes.c_uint32), ("heartbeat", ctypes.c_uint32),
("in_write", ctypes.c_uint32), ("in_read", ctypes.c_uint32),
("in_slots", ctypes.c_uint32), ("out_write", ctypes.c_uint32),
("out_read", ctypes.c_uint32), ("out_slots", ctypes.c_uint32),
("ctrl_write", ctypes.c_uint32), ("ctrl_read", ctypes.c_uint32),
("ctrl_slots", ctypes.c_uint32), ("lat_write", ctypes.c_uint32),
("lat_read", ctypes.c_uint32), ("lat_slots", ctypes.c_uint32)]
HEADER_SIZE = ctypes.sizeof(FxRTHeader)
IN_L_OFF = HEADER_SIZE
IN_R_OFF = IN_L_OFF + FXRT_IN_SLOTS * FXRT_BLOCK * 4
OUT_L_OFF = IN_R_OFF + FXRT_IN_SLOTS * FXRT_BLOCK * 4
OUT_R_OFF = OUT_L_OFF + FXRT_OUT_SLOTS * FXRT_BLOCK * 4
TOTAL_SIZE = OUT_R_OFF + FXRT_OUT_SLOTS * FXRT_BLOCK * 4
EXE = r"C:\Users\locpham\SonicForgeStudio\src-tauri\binaries\fx_vst_bridge.exe"
PLUGIN = sys.argv[1] if len(sys.argv) > 1 else r"C:\Program Files\Common Files\VST3\WaveObserver.vst3"
DURATION = float(sys.argv[2]) if len(sys.argv) > 2 else 40.0
def http_get(url, timeout=5):
try:
with urllib.request.urlopen(url, timeout=timeout) as r:
return r.read()
except Exception as e:
return b"ERR:" + str(e).encode()
def main():
name = "SonicForge_FXRT_" + uuid.uuid4().hex[:12]
print("plugin:", PLUGIN, "shm:", name, flush=True)
shm = shared_memory.SharedMemory(name=name, create=True, size=TOTAL_SIZE)
shm.buf[:TOTAL_SIZE] = b"\x00" * TOTAL_SIZE
h = FxRTHeader.from_buffer(shm.buf)
h.magic = FXRT_MAGIC; h.state = FXRT_STATE_STARTING
h.sample_rate = 44100; h.block_size = FXRT_BLOCK; h.running = 1
h.in_slots = FXRT_IN_SLOTS; h.out_slots = FXRT_OUT_SLOTS
h.ctrl_slots = FXRT_CTRL_SLOTS; h.lat_slots = FXRT_LAT_SLOTS
npbuf = np.frombuffer(shm.buf, dtype=np.float32)
stop = threading.Event()
procs = []
glitches = []
out_total = [0]
jp1 = None
try:
job1 = {"sample_rate": 44100, "block_size": 256, "seq": 0,
"fx_chain": [{"type": "vst3", "path": PLUGIN, "name": "probe", "preset_b64": "", "bypass": False}]}
fd, jp1 = tempfile.mkstemp(suffix=".json", prefix="fxrt_probe_")
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(job1, f)
p1 = subprocess.Popen([EXE, "--realtime-fx", jp1, "--shm", name, "--parent", str(os.getpid())],
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
procs.append(p1)
t0 = time.time()
while time.time() - t0 < 30 and h.state != FXRT_STATE_READY:
time.sleep(0.05)
if p1.poll() is not None:
print("REALTIME BRIDGE EXITED rc=", p1.returncode, flush=True); return
if h.state != FXRT_STATE_READY:
print("realtime not ready", flush=True); return
print("realtime ready in %.2fs" % (time.time() - t0), flush=True)
job2 = {"path": PLUGIN, "name": "probe", "shm": name, "preset_b64": ""}
fd, jp2 = tempfile.mkstemp(suffix=".json", prefix="fxgui_probe_")
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(job2, f)
p2 = subprocess.Popen([EXE, "--fx-gui", jp2],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
procs.append(p2)
port = None
t0 = time.time()
while time.time() - t0 < 20:
line = p2.stdout.readline()
if not line:
if p2.poll() is not None: break
continue
line = line.strip()
if line.startswith("SF_FXGUI_PORT="):
port = int(line.split("=", 1)[1]); break
if not port:
print("GUI bridge: no port", flush=True); return
url = "http://127.0.0.1:%d" % port
print("gui port:", port, flush=True)
phase = [0.0]
def feed():
while not stop.is_set():
if h.in_write - h.in_read < FXRT_IN_SLOTS:
s = h.in_write & (FXRT_IN_SLOTS - 1)
off = IN_L_OFF + s * FXRT_BLOCK
t = np.arange(FXRT_BLOCK) / 44100.0 + phase[0]
sine = (0.1 * np.sin(2 * np.pi * 440 * t)).astype(np.float32)
npbuf[off // 4:(off // 4) + FXRT_BLOCK] = sine
npbuf[(off + FXRT_IN_SLOTS * FXRT_BLOCK) // 4:(off + FXRT_IN_SLOTS * FXRT_BLOCK) // 4 + FXRT_BLOCK] = sine
h.in_write += 1
phase[0] += FXRT_BLOCK / 44100.0
time.sleep(0.003)
threading.Thread(target=feed, daemon=True).start()
def frame_loop():
while not stop.is_set():
http_get(url + "/frame")
time.sleep(1.0 / 30.0)
threading.Thread(target=frame_loop, daemon=True).start()
seq = [0]
def setchain_loop():
while not stop.is_set():
time.sleep(3.0)
try:
with urllib.request.urlopen(url + "/preset", timeout=5) as r:
d = json.loads(r.read().decode())
pb = d.get("preset_b64", "")
except Exception:
pb = ""
seq[0] += 1
job = {"sample_rate": 44100, "block_size": 256, "seq": seq[0],
"fx_chain": [{"type": "vst3", "path": PLUGIN, "name": "probe", "preset_b64": pb, "bypass": False}]}
with open(jp1, "w", encoding="utf-8") as f:
json.dump(job, f)
print(" set_chain seq=%d preset_len=%d at %.2fs" % (seq[0], len(pb), time.time() - t0), flush=True)
threading.Thread(target=setchain_loop, daemon=True).start()
def reader():
last = h.out_write
prev_samp = 0.0
prev_ok = False
while not stop.is_set():
w = h.out_write
while last < w:
s = last & (FXRT_OUT_SLOTS - 1)
off = OUT_L_OFF + s * FXRT_BLOCK
blk = npbuf[off // 4:(off // 4) + FXRT_BLOCK].copy()
if prev_ok:
for i in range(FXRT_BLOCK):
if abs(blk[i] - prev_samp) > 0.25:
glitches.append((time.time(), i, last))
break
prev_samp = blk[-1]
prev_ok = True
out_total[0] += FXRT_BLOCK
last += 1
time.sleep(0.001)
threading.Thread(target=reader, daemon=True).start()
t0 = time.time()
n = 0
while time.time() - t0 < DURATION:
time.sleep(1.5)
http_get(url + "/preset")
n += 1
stop.set()
time.sleep(0.3)
print("RESULT: %d polls, out blocks=%d, glitches=%d" % (n, out_total[0], len(glitches)), flush=True)
for g in glitches[:10]:
print(" glitch block=%d idx=%d" % (g[2], g[1]), flush=True)
finally:
stop.set()
for p in procs:
try: p.terminate()
except Exception: pass
time.sleep(0.5)
for p in procs:
try: p.kill()
except Exception: pass
shm.close()
if __name__ == "__main__":
main()
@@ -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
});
+22
View File
@@ -0,0 +1,22 @@
import io
p = r"C:/Users/locpham/SonicForgeStudio/native_bridge/src/NativeInstrumentEngine.cpp"
with io.open(p, "r", encoding="utf-8-sig", newline="") as f:
s = f.read()
old = """ const float kCeilingPeak = 0.7071f; // -3 dBFS
const float kAttack = 0.9f; // chặt: sample dau keo ve tran ~90% (tran 0.4 de lo qua -3 dBFS)
const float kRelease = 0.0006f;"""
new = """ 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;"""
assert s.count(old) == 1, "pattern not found: %d" % s.count(old)
s = s.replace(old, new)
with io.open(p, "w", encoding="utf-8-sig", newline="") as f:
f.write(s)
print("patched OK")
+90
View File
@@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
# Patch: crossfade activation/deactivation master chain FX (het crackle click)
# Applies to app/static/js/app.jsx + app/static/js/sf-fx-realtime.js (CRLF).
import io, sys
def patch(path, repls, expect_all_multi=False):
with io.open(path, 'r', encoding='utf-8', newline='') as f:
src = f.read()
for old, new, count in repls:
n = src.count(old)
if count is None:
if n < 1:
print('MISS (expected >=1):', old[:80]); sys.exit(1)
src = src.replace(old, new)
else:
if n != count:
print('BAD COUNT %d (want %d): %s' % (n, count, old[:80])); sys.exit(1)
src = src.replace(old, new)
with io.open(path, 'w', encoding='utf-8', newline='') as f:
f.write(src)
print('PATCHED', path)
# ── app.jsx ─────────────────────────────────────────────────────────────────
appjsx = 'app/static/js/app.jsx'
A = [
# 1. fxRt state: them fadedIn flag
("const fxRt = { active: false, starting: false, startToken: 0, sessionId: null, ws: null, node: null, inQueue: [], pumpTimer: null, lastInSent: 0, chainKey: null, latencyTotal: 0 };",
"const fxRt = { active: false, starting: false, startToken: 0, sessionId: null, ws: null, node: null, inQueue: [], pumpTimer: null, lastInSent: 0, chainKey: null, latencyTotal: 0, fadedIn: false };",
1),
# 2a. initMasterBus: tao 2 gain crossfade
(" // Global fader / output\n const output = ctx.createGain();\n output.gain.value = 1.0;",
" // Global fader / output\n const output = ctx.createGain();\n output.gain.value = 1.0;\n\n // Crossfade master-FX activation (het click khi bat/tat master chain FX):\n // path mac dinh outputAnalyser -> masterFxDryGain(1) -> pdcDelay -> output;\n // nhanh FX noi vao masterFxGain(0) -> output. fxRtStart/fxRtStop ramp 2 gain\n // nay thay vi hard-rewire graph (dry path luon noi san — no click, no gap\n // trong luc worklet FILL ~86ms).\n const masterFxDryGain = ctx.createGain();\n masterFxDryGain.gain.value = 1.0;\n const masterFxGain = ctx.createGain();\n masterFxGain.gain.value = 0.0;",
1),
# 2b. masterBus object literal
(" analyser,\n output,\n masteringActive: false,",
" analyser,\n output,\n masterFxDryGain,\n masterFxGain,\n masteringActive: false,",
1),
# 2c. wire graph: dry path qua masterFxDryGain -> pdcDelay -> output
(" masterBus.input.connect(masterBus.inputAnalyser);\n masterBus.inputAnalyser.connect(masterBus.outputAnalyser);\n masterBus.outputAnalyser.connect(masterBus.output);",
" masterBus.input.connect(masterBus.inputAnalyser);\n masterBus.inputAnalyser.connect(masterBus.outputAnalyser);\n masterBus.outputAnalyser.connect(masterBus.masterFxDryGain);\n masterBus.masterFxDryGain.connect(masterBus.pdcDelay);\n masterBus.outputAnalyser.connect(masterBus.masterFxGain);\n masterBus.masterFxGain.connect(masterBus.output);",
1),
# 3. latency: chi ap PDC sau khi crossfade xong (dry silent) — ap som = gap 80ms
(" if (d && d.cmd === 'latency' && typeof d.total === 'number') {\n fxRt.latencyTotal = d.total;\n fxRtApplyPdc();\n }",
" if (d && d.cmd === 'latency' && typeof d.total === 'number') {\n fxRt.latencyTotal = d.total;\n // Chi delay dry path khi crossfade da xong (dry silent) — ap som\n // trong luc dry con la path nghe duoc se tao gap ~80ms.\n if (fxRt.fadedIn) fxRtApplyPdc();\n }",
1),
# 4. guard start khi masterBus moi thieu gains (recovery)
(" if (!ctx || !ctx.audioWorklet || !masterBus || !masterBus.output || !masterBus.analyser) return;",
" if (!ctx || !ctx.audioWorklet || !masterBus || !masterBus.output || !masterBus.analyser || !masterBus.masterFxGain || !masterBus.masterFxDryGain) return;",
1),
# 5. onmessage: xu ly 'started' -> crossfade(true) + PDC sau khi dry silent
(" fxRt.node.port.onmessage = (ev) => {\n const d = ev.data;\n if (!d || !fxRt.ws) return;\n if (d.type === 'set_param') {\n try { fxRt.ws.send(JSON.stringify({ cmd: 'set_param', slot: d.slot, key: d.key, value: d.value })); } catch (e) {}\n return;\n }\n if (d.type !== 'in') return;",
" fxRt.node.port.onmessage = (ev) => {\n const d = ev.data;\n if (!d || !fxRt.ws) return;\n if (d.type === 'started') {\n // Worklet bat dau phat (sau FILL) -> crossfade dry->fx muot. Token guard\n // chong race: neu stop/restart xay ra trong luc cho, bo qua.\n if (fxRt.fadedIn) return;\n setTimeout(() => {\n if (token !== fxRt.startToken) return;\n fxRt.fadedIn = true;\n fxRtCrossfade(true, token);\n // Dry gan silent (tc 0.025) roi moi tre pdcDelay — tranh gap khi delay\n // dang lon dan.\n setTimeout(() => fxRtApplyPdc(), 250);\n }, 30);\n return;\n }\n if (d.type === 'set_param') {\n try { fxRt.ws.send(JSON.stringify({ cmd: 'set_param', slot: d.slot, key: d.key, value: d.value })); } catch (e) {}\n return;\n }\n if (d.type !== 'in') return;",
1),
# 6. rewire start: noi fx vao masterFxGain (khong disconnect dry)
(" try { masterBus.outputAnalyser.disconnect(masterBus.output); } catch (e) {}\n masterBus.outputAnalyser.connect(fxRt.node);\n fxRt.node.connect(masterBus.output);\n fxRt.chainKey = ck;",
" // Crossfade activation: KHONG disconnect dry path — outputAnalyser van noi\n // masterFxDryGain -> pdcDelay -> output (gain 1); nhanh fx noi masterFxGain\n // (gain 0) -> output. Tin hieu 'started' tu worklet -> ramp 2 gain -> switch\n // muot, het click hard-rewire.\n try { masterBus.outputAnalyser.disconnect(masterBus.output); } catch (e) {}\n masterBus.outputAnalyser.connect(fxRt.node);\n fxRt.node.connect(masterBus.masterFxGain);\n fxRt.chainKey = ck;",
1),
# 7. them ham fxRtCrossfade sau fxRtApplyPdc
(" try { bus.pdcDelay.delayTime.setTargetAtTime(l / (ctx.sampleRate || 44100), ctx.currentTime, 0.05); } catch (e) {}\n}",
" try { bus.pdcDelay.delayTime.setTargetAtTime(l / (ctx.sampleRate || 44100), ctx.currentTime, 0.05); } catch (e) {}\n}\n\n// Crossfade master-FX switch: ramp masterFxGain/masterFxDryGain thay vi\n// hard-rewire (click). Hai gain nay duoc tao trong initMasterBus.\nfunction fxRtCrossfade(toFx, token) {\n const ctx = getAudioContext();\n const bus = masterBus;\n if (!ctx || !bus || !bus.masterFxGain || !bus.masterFxDryGain) return;\n if (token !== undefined && token !== fxRt.startToken) return;\n const tc = 0.025;\n try {\n bus.masterFxGain.gain.setTargetAtTime(toFx ? 1 : 0, ctx.currentTime, tc);\n bus.masterFxDryGain.gain.setTargetAtTime(toFx ? 0 : 1, ctx.currentTime, tc);\n } catch (e) {}\n}",
1),
# 8. fxRtStop: crossfade deactivation thay hard-switch; disconnect tre 180ms
(" if (!fxRt.active && !fxRt.ws && !fxRt.node && !fxRt.sessionId) return;\n try {\n if (fxRt.node) {\n fxRt.node.disconnect();\n if (masterBus && masterBus.outputAnalyser && masterBus.output) {\n try { masterBus.outputAnalyser.disconnect(masterBus.output); } catch (e) {}\n try { masterBus.outputAnalyser.connect(masterBus.output); } catch (e) {}\n }\n fxRt.node.port.onmessage = null;\n fxRt.node = null;\n }\n } catch (e) {}",
" if (!fxRt.active && !fxRt.ws && !fxRt.node && !fxRt.sessionId) return;\n let _fxStopNode = null;\n try {\n if (fxRt.node) {\n _fxStopNode = fxRt.node;\n if (masterBus && masterBus.masterFxGain && masterBus.masterFxDryGain) {\n // Crossfade deactivation: ramp dry 0->1 / fx 1->0 roi MOI disconnect\n // (tranh click hard-switch nguoc). Dry path da noi san qua\n // masterFxDryGain — khong can reconnect outputAnalyser->output. PDC giam\n // ve 0 sau khi dry chiem cho (fx silent).\n try { fxRtCrossfade(false); } catch (e) {}\n setTimeout(() => {\n try { _fxStopNode.disconnect(); } catch (e) {}\n _fxStopNode.port.onmessage = null;\n if (fxRt.node === _fxStopNode) fxRt.node = null;\n fxRt.fadedIn = false;\n fxRtApplyPdc();\n }, 180);\n } else {\n // Fallback graph cu (khong co crossfade gains) — hard switch nhu cu.\n try { _fxStopNode.disconnect(); } catch (e) {}\n if (masterBus && masterBus.outputAnalyser && masterBus.output) {\n try { masterBus.outputAnalyser.connect(masterBus.output); } catch (e) {}\n }\n _fxStopNode.port.onmessage = null;\n if (fxRt.node === _fxStopNode) fxRt.node = null;\n }\n }\n } catch (e) {}",
1),
# 9. fxRtStop tail: PDC da xu ly trong setTimeout crossfade (hoac ngay neu khong co node)
(" fxRt.chainKey = null;\n fxRt.latencyTotal = 0;\n fxRtApplyPdc();\n if (sid) {",
" fxRt.chainKey = null;\n fxRt.latencyTotal = 0;\n // PDC giam ve 0 sau khi dry chiem cho (trong setTimeout cua nhanh crossfade)\n // hoac ngay neu khong co node — khong goi tai day de tranh gap 80ms.\n if (!_fxStopNode) fxRtApplyPdc();\n if (sid) {",
1),
# 10. bump cache-buster (2 noi: master + per-track)
("sf-fx-realtime.js?v=202608241030", "sf-fx-realtime.js?v=202608241200", None),
]
patch(appjsx, A)
# ── sf-fx-realtime.js ───────────────────────────────────────────────────────
wl = 'app/static/js/sf-fx-realtime.js'
W = [
(" this.hasStarted = false; // đã phát block đầu chưa — phân biệt FILL khởi đầu vs REBUFFER sau underrun",
" this.hasStarted = false; // đã phát block đầu chưa — phân biệt FILL khởi đầu vs REBUFFER sau underrun\n this.FADE = 512; // fade-in 512 mẫu (~11ms @48k) khi bắt đầu phát/resume — hết click\n this.outGain = 0; // gain phát hiện tại (0→1 trong fade)\n this.fadeLeft = 0; // số mẫu còn lại của fade",
1),
(" if (this.outQueue.length >= need) {\n this.playing = true;\n this.hasStarted = true;\n } else {",
" if (this.outQueue.length >= need) {\n this.playing = true;\n this.hasStarted = true;\n // Fade-in mượt (hết click lúc FILL xong / resume sau underrun); báo\n // main thread để crossfade dry→fx đúng thời điểm.\n this.outGain = 0;\n this.fadeLeft = this.FADE;\n this.port.postMessage({ type: 'started' });\n } else {",
1),
(" const blk = this.outQueue[0];\n outL[i] = blk.l[this.outPos];\n if (outR) outR[i] = blk.r[this.outPos];",
" const blk = this.outQueue[0];\n if (this.fadeLeft > 0) {\n this.outGain = Math.min(1, this.outGain + 1 / this.FADE);\n this.fadeLeft--;\n }\n outL[i] = blk.l[this.outPos] * this.outGain;\n if (outR) outR[i] = blk.r[this.outPos] * this.outGain;",
1),
]
patch(wl, W)
print('ALL OK')
Binary file not shown.
Binary file not shown.