G3: 1 clock - PDC latency that + playhead engine-derived

This commit is contained in:
2026-08-28 15:51:19 +07:00
parent c72362e088
commit 6cbdbd8e9c
8 changed files with 218 additions and 10 deletions
+10 -1
View File
@@ -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:
+14 -2
View File
@@ -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()
+59 -6
View File
@@ -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(() => {
+6 -1
View File
@@ -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;