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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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_;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user