diff --git a/app/api/v1/plugins.py b/app/api/v1/plugins.py index f2bd60a..2cc0ca4 100644 --- a/app/api/v1/plugins.py +++ b/app/api/v1/plugins.py @@ -1134,18 +1134,27 @@ async def ws_fx_realtime(websocket: WebSocket, session_id: str): if _pf is not None: _pf.write("%d pump_start\n" % int(_time.time() * 1000)) _last_lat = None + _last_proc = None try: while True: try: sess.flush_input_stale() # input lẻ kẹt trong pending → publish _lats = sess.get_latencies() _total = sum(_lats.values()) if _lats else 0 - if _total != _last_lat: + # G3: processed samples (playhead engine-derived) — bridge + # bao moi block; gui kem latency message khi doi (khong gate + # theo total nua — total chi doi khi chain doi, processed + # doi lien tuc). App: position = (processed - anchor - lat)/sr. + _proc = sess.get_processed() + _proc_changed = (_proc is not None and _proc != _last_proc) + if _total != _last_lat or _proc_changed: _last_lat = _total + _last_proc = _proc await websocket.send_text(json.dumps({ "cmd": "latency", "total": _total, "latencies": _lats, + "processed": _proc, "stats": sess.stats, })) except Exception: diff --git a/app/core/fx_realtime.py b/app/core/fx_realtime.py index 5fdb5f2..f6eaaf3 100644 --- a/app/core/fx_realtime.py +++ b/app/core/fx_realtime.py @@ -128,6 +128,7 @@ class FxRealtimeSession: # Phase 2.8: C++ consume qua SHM control ring. self.pending_params = {} # {slot_idx: {key: value}} self.latencies = {} # {slot_idx: samples} + self.processed = None # G3: processed samples tu bridge (lat slot 0xFFFFFFFE) # Diagnostics (FXRT crackle hunt): drop/publish counters. self.stats = {'publish': 0, 'drop_batch': 0, 'drop_blocks': 0, 'stale_flush': 0, 'read_blocks': 0, 'read_empty': 0, @@ -396,6 +397,12 @@ class FxRealtimeSession: self._lat_drain() return dict(self.latencies) + def get_processed(self): + """G3: processed samples (bridge da xu li) - playhead engine-derived.""" + with self._lock: + self._lat_drain() + return self.processed + def _lat_drain(self): shm = getattr(self, "shm", None) if shm is None: @@ -405,12 +412,17 @@ class FxRealtimeSession: lats = (FxLatReport * FXRT_LAT_SLOTS).from_buffer(shm.buf, LAT_OFF) while h.lat_read < h.lat_write: ls = h.lat_read & (h.lat_slots - 1) - self.latencies[int(lats[ls].slot)] = int(lats[ls].samples) + _slot = int(lats[ls].slot) + _samples = int(lats[ls].samples) + if _slot == 0xFFFFFFFE: + # G3: processed - khong vao latencies dict (sum chain sai). + self.processed = _samples + else: + self.latencies[_slot] = _samples h.lat_read += 1 except Exception: pass - # Registry toàn tiến trình (desktop app 1 user). _SESSIONS = {} _SESSIONS_LOCK = threading.Lock() diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index f5ca30e..2f70361 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -1768,7 +1768,7 @@ 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, starting: false, startToken: 0, sessionId: null, ws: null, node: null, inQueue: [], pumpTimer: null, lastInSent: 0, chainKey: null, latencyTotal: 0, fadedIn: false, pdcCalibMs: 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, pdcCalibMs: 0, processedSamples: null, processedAnchor: null }; // Loop restart/seek/reschedule: stopAllPlayback giữ session FX realtime (không // teardown) → fxRtStart early-return → KHÔNG reload VST ~4s mỗi pass loop // (hết bypass rồi mới load lại). Chỉ set TRƯỚC nhánh restart/resume; user @@ -1939,6 +1939,17 @@ async function fxRtStart() { const d = JSON.parse(ev.data); if (d && d.cmd === 'latency' && typeof d.total === 'number') { fxRt.latencyTotal = d.total; + // G3: processed samples (playhead engine-derived) — anchor lan dau + // khi active (cold start: VST load xong -> message latency dau tien). + if (typeof d.processed === 'number') { + fxRt.processedSamples = d.processed; + if (fxRt.active && fxRt.processedAnchor == null) { + fxRt.processedAnchor = d.processed; + } + } + // G3: bao latency that cho worklet — align wrap crossfade theo + // processed tail (wrapFadeBlocks = queue + ceil(latency/BLOCK)). + try { if (fxRt.node && fxRt.node.port) fxRt.node.port.postMessage({ type: 'latency', samples: fxRt.latencyTotal || 0 }); } catch (e) {} // 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(); @@ -2156,6 +2167,19 @@ function fxRtStop(silent) { // chưa có phép đo (fx_play_first) — dùng cho freeze playhead thay V39 0.31s. const FX_RT_ROUNDTRIP_SAMPLES = 3840; const FX_RT_START_EST_MS = 450; +// G3: anchor processed — playhead = startOffset + (processed - anchor - latency)/sr +// (da tru latency -> bam vi tri mau NGHE DUOC; pre-roll tu giu tai startOffset — +// thay freeze/snap V40). Anchor moi play/wrap restart qua __fxRtPlayheadFreeze +// (moi play/resume path deu goi); cold start (chua active) -> null -> WS message +// dau tien anchor. +function fxRtAnchorProcessed() { + if (fxRt && fxRt.active && typeof fxRt.processedSamples === 'number') { + fxRt.processedAnchor = fxRt.processedSamples; + } else { + fxRt.processedAnchor = null; + } +} + function fxRtApplyPdc() { const ctx = getAudioContext(); const bus = masterBus; @@ -20843,6 +20867,13 @@ const App = () => { // session không active trong 1500ms → hủy freeze (đừng đóng băng playhead vô hạn). window.__fxRtPlayheadFreeze = function (allowCold) { try { if (_fxRtFreezeTimer) { clearTimeout(_fxRtFreezeTimer); _fxRtFreezeTimer = null; } } catch (e3) {} + // G3: FX active -> playhead engine-derived (processed) — KHONG freeze/snap. + // Anchor processed tai day (moi play/resume path deu goi ham nay). + if (fxRt && fxRt.active) { + _fxRtPlayheadSnapPending = false; + fxRtAnchorProcessed(); + return; + } // V41: gate theo CHAIN (không chỉ active) — session FX warm (stop giữ warm, // bypass toggle gọi rebuildMasteringGraph KHÔNG gọi fxRtStop) → fxRt.active vẫn // true khi bypass → trước đây freeze nhầm → playhead chạy trước → wrap sớm → vấp. @@ -24009,8 +24040,19 @@ const App = () => { if (window.NativeBridgeService && window.NativeBridgeService.isBridgeConnected && (isPlaying || isPlayingRef.current)) { try { const _pctx = getAudioContext(); - const _elapsed = _pctx.currentTime - (startAudioTimeRef.current || _pctx.currentTime); - bridgePlayheadSampleRef.current = Math.max(0, Math.floor((startOffsetTimeRef.current + _elapsed) * (_pctx.sampleRate || 44100))); + // G3: FX active -> position tu processed engine (da tru latency) — bam + // vi tri mau NGHE duoc, khong freeze/snap; fallback wall-clock. + let _posSamples; + if (fxRt && fxRt.active && typeof fxRt.processedSamples === 'number' && fxRt.processedAnchor != null) { + _posSamples = Math.max( + Math.floor(startOffsetTimeRef.current * (_pctx.sampleRate || 44100)), + Math.floor(startOffsetTimeRef.current * (_pctx.sampleRate || 44100) + (fxRt.processedSamples - fxRt.processedAnchor - (fxRt.latencyTotal || 0))) + ); + } else { + const _elapsed = _pctx.currentTime - (startAudioTimeRef.current || _pctx.currentTime); + _posSamples = Math.max(0, Math.floor((startOffsetTimeRef.current + _elapsed) * (_pctx.sampleRate || 44100))); + } + bridgePlayheadSampleRef.current = _posSamples; // set_position ~mỗi giây: C++ A13 cập nhật timeline anchor, tối ưu cho // seek/loop (Rust pump + C++ không tự biết vị trí; JS là nguồn duy nhất). try { @@ -24268,7 +24310,10 @@ const App = () => { // yên (chế độ edit). Loop luôn tiếp tục (rAF). if (isPlaying) { const context = getAudioContext(); - const _ph = startOffsetTimeRef.current + (context.currentTime - startAudioTimeRef.current); + let _ph = startOffsetTimeRef.current + (context.currentTime - startAudioTimeRef.current); + if (fxRt && fxRt.active && typeof fxRt.processedSamples === 'number' && fxRt.processedAnchor != null) { + _ph = startOffsetTimeRef.current + (fxRt.processedSamples - fxRt.processedAnchor - (fxRt.latencyTotal || 0)) / (context.sampleRate || 44100); + } let _local = _ph; const _itemInfo = getPianoRollItemInfo(st); if (_itemInfo) { @@ -24292,8 +24337,11 @@ const App = () => { const context = getAudioContext(); const speedFactor = st.speed || 1.0; const elapsed = context.currentTime - startAudioTimeRef.current; - const wallTime = startOffsetTimeRef.current + elapsed; + let wallTime = startOffsetTimeRef.current + elapsed; const bufferPos = startBufferOffsetRef.current + elapsed * speedFactor; + if (fxRt && fxRt.active && typeof fxRt.processedSamples === 'number' && fxRt.processedAnchor != null) { + wallTime = startOffsetTimeRef.current + (fxRt.processedSamples - fxRt.processedAnchor - (fxRt.latencyTotal || 0)) / (context.sampleRate || 44100); + } // Loop sub-tab selection (bufferPos is buffer-time) if (st.selectionStart !== null && st.selectionEnd !== null && st.selectionStart !== st.selectionEnd && st.isLooping) { @@ -24363,6 +24411,11 @@ const App = () => { const context = getAudioContext(); const elapsed = context.currentTime - startAudioTimeRef.current; const updatedTime = startOffsetTimeRef.current + elapsed; + // G3: playhead display engine-derived khi FX active (wrap/loop trigger giu + // wall-clock — transport truth). + const _playheadSec = (fxRt && fxRt.active && typeof fxRt.processedSamples === 'number' && fxRt.processedAnchor != null) + ? Math.max(startOffsetTimeRef.current, startOffsetTimeRef.current + (fxRt.processedSamples - fxRt.processedAnchor - (fxRt.latencyTotal || 0)) / (context.sampleRate || 44100)) + : updatedTime; // Selection Loop - LOOP_MAKER.md + LOOP_EDITOR_2.md §4.2 // If selection cleared by user, play linearly (don't loop) @@ -24421,7 +24474,7 @@ const App = () => { setCurrentTime(0); return; } - setCurrentTime(updatedTime); + setCurrentTime(_playheadSec); animationFrameIdRef.current = requestAnimationFrame(updatePlayhead); }; useEffect(() => { diff --git a/app/static/js/sf-fx-realtime.js b/app/static/js/sf-fx-realtime.js index 4c55eeb..4d2fc44 100644 --- a/app/static/js/sf-fx-realtime.js +++ b/app/static/js/sf-fx-realtime.js @@ -56,6 +56,7 @@ class SfFxRealtimeProcessor extends AudioWorkletProcessor { this.lastOutL = 0.0; // mẫu output cuối — nguồn crossfade + điểm bắt đầu fade-out this.lastOutR = 0.0; this.wrapFadeBlocks = -1; // V42: số block tail pass cũ còn lại sau clear_queue('loop') — đếm ngược để crossfade đúng seam tail→head + this.latencySamples = 0; // G3: latency chain thật (engine qua WS) — align wrap fade theo processed tail this.WRAP_FADE = 256; // V42: crossfade 256 mẫu (~5.3ms @48k) tại seam wrap — che click jump nội dung end→start (FX path) this.fadeFromL = 0.0; // mẫu cố định khi fade-out underrun (fade tuyến tính tới 0) this.fadeFromR = 0.0; @@ -63,6 +64,7 @@ class SfFxRealtimeProcessor extends AudioWorkletProcessor { const d = e.data; if (d && d.type === 'set_param') { this.port.postMessage(d); return; } if (d && d.type === 'flush_done') { this.flushPending = false; return; } + if (d && d.type === 'latency') { this.latencySamples = (d.samples || 0); return; } if (d && d.type === 'flush_for_stop') { // V26: user stop — main thread báo xóa audio tail trong queue (audio đã // xử lí còn phát ~0.3-0.5s sau stop = tiếng "ding"). Fade-out 21ms từ @@ -105,7 +107,10 @@ class SfFxRealtimeProcessor extends AudioWorkletProcessor { // V42: dem so block tail pass cu con lai trong queue -> wrap crossfade // trigger dung luc block dau pass moi len front (seam tail->head). if (d.mode === 'loop') { - this.wrapFadeBlocks = this.outQueue.length; + // G3: align processed-tail — tail pass cũ còn in-flight trong bridge + // (FX latency thật từ engine) chưa về queue; đếm đủ block tới seam + // (block đầu pass mới lên front) → crossfade đúng chỗ jump end→start. + this.wrapFadeBlocks = this.outQueue.length + Math.ceil((this.latencySamples || 0) / this.BLOCK); return; } if (d.mode !== 'play' && this.outQueue.length < this.FILL) return; diff --git a/native_bridge/debug/G3_RESULTS.md b/native_bridge/debug/G3_RESULTS.md new file mode 100644 index 0000000..d2c2a57 --- /dev/null +++ b/native_bridge/debug/G3_RESULTS.md @@ -0,0 +1,58 @@ +# G3 — 1 clock: PDC latency thật + playhead engine-derived (kết quả) + +Ngày: 2026-08-28. Plan: `JUCE_LATENCY_PLAN.md` G3. Option A — 1 clock. + +## Nội dung + +- **Bridge báo processed samples** qua lat ring slot đặc biệt `0xFFFFFFFE` + (`JuceFxLoop.cpp`, mỗi block sau `++processed`): `samples = processed*n` + (n=curBlock). Python `_lat_drain` tách slot này riêng → `self.processed` + (KHÔNG vào `latencies` dict — tổng chain không đổi). Thêm `get_processed()`. +- **WS latency message thêm `processed`** (`plugins.py`): gửi khi `processed` + đổi (mỗi block ~5.33ms), bỏ gate chỉ theo total — total chỉ đổi khi chain đổi. +- **Playhead engine-derived** (`app.jsx`): khi FX active và có anchor, + `position = startOffset + (processed - anchor - latencyTotal)/sr` (đã trừ + latency → bám vị trí mẫu NGHE được; pre-roll tự giữ tại startOffset qua + clamp). Anchor = raw processed tại play/wrap start (`fxRtAnchorProcessed` + gọi từ `__fxRtPlayheadFreeze`, mọi play/resume path đều đi qua). Bỏ + freeze/snap khi active; fallback wall-clock khi không active. Áp dụng 4 chỗ: + bridge position block, sub-tab `_ph`, sub-tab `wallTime`, main branch + `_playheadSec` → `setCurrentTime`. +- **Worklet nhận latency thật** qua port message `{type:'latency', samples}`; + `clear_queue('loop')`: `wrapFadeBlocks = outQueue.length + + ceil(latencySamples/BLOCK)` — align processed-tail, crossfade đúng seam + end→start kể cả khi FX latency (pureComp 2108 mẫu) còn in-flight trong bridge. + +## Verify (đã chạy) + +1. **Build**: `cmake --build native_bridge/build_juce --config Release --target + juce_fx_bridge` OK. Copy `src-tauri/binaries/juce_fx_bridge-x86_64-pc-windows-msvc.exe`, + SHA256 khớp bản build (`07621b81...`). +2. **Probe SHM thật** (`native_bridge/debug/g3_probe_check.py`, JUCE bridge qua + `SF_JUCE_FX_BRIDGE_PATH`, chain pureComp, SR 48k, 400 block ~2.1s): + - `latencies` dict KHÔNG đổi: `{0: 2108}` trước/sau — processed tách riêng + không làm sai tổng chain. + - `processed` tiến 0 → 102400 (= 400 block × 256 mẫu) — bridge báo đúng. +3. **Cú pháp**: `python -m py_compile` fx_realtime.py + plugins.py OK; + `node --check` worklet OK; `babel` (node_modules) parse app.jsx OK (node + thuần không hiểu JSX — dùng babel, exit 0). +4. **Fake session** (object.__new__, không shm): `get_processed()` → None, + `get_latencies()` → `{0: 2108}` — path không-SHM an toàn. + +## Done-when trạng thái + +- ✅ processed tách riêng, tổng latency chain không đổi (probe) +- ✅ playhead công thức engine-derived đã cài đủ 4 chỗ; freeze/snap vô hiệu khi + active (code path) +- ⏳ **Chưa verify bằng tai**: 100-pass loop không click với crossfade tối + thiểu; playhead bám vị trí mẫu trong app thật (cần user chạy GUI + nghe). + +## Ghi chú + +- Probe check script giữ ở `native_bridge/debug/g3_probe_check.py` (chạy lại: + `python native_bridge/debug/g3_probe_check.py`). +- BufferError lúc thoát (`cannot close exported pointers`) là quirk teardown + shared_memory Python 3.13, không ảnh hưởng kết quả. +- Latency WS ~5.33ms/msg = 187 msg/s — nhỏ so với pump audio; nếu cần tiết + kiệm sau này: batch processed 4 block/lần (giảm 4×) — `ponytail: chỉ làm khi + có bằng chứng quá tải`. diff --git a/native_bridge/debug/g3_probe_check.py b/native_bridge/debug/g3_probe_check.py new file mode 100644 index 0000000..5922161 --- /dev/null +++ b/native_bridge/debug/g3_probe_check.py @@ -0,0 +1,62 @@ +"""G3 check — processed slot 0xFFFFFFFE tach rieng, khong lam sai latencies dict. + +Chay: python native_bridge/debug/g3_probe_check.py +""" +import ctypes +import os +import sys +import time + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) + +try: + _winmm = ctypes.windll.winmm + _winmm.timeBeginPeriod(1) +except Exception: + pass + +import numpy as np # noqa: E402 + +from app.core.fx_realtime import FxRealtimeSession, FXRT_BLOCK # noqa: E402 + +# Chon JUCE bridge (processed slot chi co o JuceFxLoop.cpp). +_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +os.environ["SF_JUCE_FX_BRIDGE_PATH"] = os.path.join( + _REPO, "native_bridge", "build_juce", "Release", "juce_fx_bridge.exe") + +SR = 48000 +PURECOMP = r"C:\Program Files\Common Files\VST3\Sonible\purecomp.vst3" + +sess = FxRealtimeSession([{"type": "vst3", "path": PURECOMP, "bypass": False, "preset_b64": ""}], SR) +try: + sess.wait_ready(30) + time.sleep(1.0) + lat0 = dict(sess.get_latencies()) + proc0 = sess.get_processed() + print("lat0:", lat0) + print("proc0:", proc0) + + t = np.arange(FXRT_BLOCK, dtype=np.float32) / SR + s = (np.sin(2 * np.pi * 440 * t) * 0.1).astype(np.float32) + inter = np.empty(FXRT_BLOCK * 2, dtype=np.float32) + inter[0::2] = s + inter[1::2] = s + blk = inter.tobytes() + + n_ok = 0 + last = proc0 + for _ in range(400): # ~2.1s + sess.write_input(blk) + sess.flush_input_stale(0.001) + sess.read_output() + time.sleep(0.002) + time.sleep(0.5) + lat1 = dict(sess.get_latencies()) + proc1 = sess.get_processed() + print("lat1:", lat1) + print("proc1:", proc1) + assert lat0 == lat1, "latencies dict changed!" + assert isinstance(proc1, int) and proc1 >= (proc0 or 0), "processed not advancing" + print("OK: latencies dict unchanged, processed=%d->%d" % (proc0 or 0, proc1)) +finally: + sess.close() diff --git a/native_bridge/juce_fx/JuceFxLoop.cpp b/native_bridge/juce_fx/JuceFxLoop.cpp index 3e007dd..ada1265 100644 --- a/native_bridge/juce_fx/JuceFxLoop.cpp +++ b/native_bridge/juce_fx/JuceFxLoop.cpp @@ -236,6 +236,15 @@ int run_juce_fx_loop(const std::string& jobPath, const std::string& shmName, MemoryBarrier(); ipc->h.outWrite++; ++processed; + // G3: báo processed samples (playhead engine-derived — app tính + // position = (processed - anchor - latency)/sr) qua lat ring slot + // đặc biệt 0xFFFFFFFE. Python _lat_drain tách riêng (không vào + // latencies dict). Ring 8 slot, Python drain mỗi ~2ms — an toàn. + const uint32_t pls = ipc->h.latWrite & (FXRT_LAT_SLOTS - 1); + ipc->lat[pls].slot = 0xFFFFFFFEu; + ipc->lat[pls].samples = (uint32_t)(processed * n); + MemoryBarrier(); + ipc->h.latWrite++; } const auto perfIterT1 = std::chrono::steady_clock::now(); const double perfLoopMs = std::chrono::duration(perfIterT1 - perfT0).count(); diff --git a/src-tauri/binaries/juce_fx_bridge-x86_64-pc-windows-msvc.exe b/src-tauri/binaries/juce_fx_bridge-x86_64-pc-windows-msvc.exe index c74a1e6..1ba34b4 100644 Binary files a/src-tauri/binaries/juce_fx_bridge-x86_64-pc-windows-msvc.exe and b/src-tauri/binaries/juce_fx_bridge-x86_64-pc-windows-msvc.exe differ