V42+V42A: fix seam click khi loop master FX — worklet wrap crossfade (tail->head) + native WebAudio loop cho bypass (het click beat 0); dip bypass 5ms; PDC playhead freeze/snap

This commit is contained in:
2026-08-28 11:35:32 +07:00
parent 9ccc28d211
commit fa61712ecc
10 changed files with 690 additions and 108 deletions
+331 -40
View File
@@ -240,6 +240,8 @@ let _cc11Epoch = 0;
let _pendingFadeSession = null;
const restorePendingFadeSession = () => {
if (!_pendingFadeSession) return;
let _bgBefore = -1;
try { if (_pendingFadeSession.bridgeGainNode) _bgBefore = _pendingFadeSession.bridgeGainNode.gain.value; } catch (e) {}
try {
const t = getAudioContext().currentTime;
_pendingFadeSession.fadedGains.forEach(g => {
@@ -249,8 +251,64 @@ const restorePendingFadeSession = () => {
try { _pendingFadeSession.bridgeGainNode.gain.cancelScheduledValues(t); _pendingFadeSession.bridgeGainNode.gain.setValueAtTime(1.0, t); } catch (e) {}
}
} catch (e) {}
_dbgLog('restore', 'gains=' + _pendingFadeSession.fadedGains.length + ' bg=' + _bgBefore.toFixed(4) + '->1.0');
_pendingFadeSession = null;
};
// V28: timing diagnostics -> %APPDATA%/SonicForgeDAW/logs/dbg.log (Rust dbg_log).
// Fire-and-forget invoke — khong chan main thread; tag+extra ghi kem unix_ms.
const _dbgLog = (tag, extra) => {
try {
if (window.__TAURI__ && window.__TAURI__.core && window.__TAURI__.core.invoke) {
window.__TAURI__.core.invoke('dbg_log', { tag: tag, extra: extra === undefined ? '' : String(extra) }).catch(function () {});
}
} catch (e) {}
};
// V29: expose dbg hook cho BridgeAudioNode (worklet state) + helpers.
window.__dbgLog = _dbgLog;
if (window.PerformanceObserver) { try { new PerformanceObserver((list) => { for (const e of list.getEntries()) _dbgLog('longtask', 'd=' + Math.round(e.duration) + ' s=' + Math.round(e.startTime)); }).observe({ entryTypes: ['longtask'] }); } catch (e) {} }
const _dbgBridgeGain = () => {
try {
if (window.BridgeAudioNode && window.BridgeAudioNode.getOutputNode) {
const _g = window.BridgeAudioNode.getOutputNode();
if (_g && _g.gain) return 'bg=' + _g.gain.value.toFixed(4);
return 'bg=none';
}
} catch (e) {}
return 'bg=n/a';
};
const _dbgWorkletState = () => {
try {
if (window.BridgeAudioNode && window.BridgeAudioNode.getDiag) {
const _d = window.BridgeAudioNode.getDiag();
return 'wl=' + (_d.useWorklet ? 'y' : 'n') + ' pre=' + _d.preBufferLen + ' ch=' + _d.chunksLen;
}
} catch (e) {}
return 'wl=n/a';
};
window.__dbgBridgeGain = _dbgBridgeGain;
window.__dbgWorkletState = _dbgWorkletState;
// V28: ding fix — midi bat ky (keyboard/pad/timeline) restore bridge gain sau
// stop (gain giu ~0.0001 — V27 restore 800ms khong du cho VSTi release tail dai).
window.__restorePendingFadeSession = restorePendingFadeSession;
// V31: live note (ARM keyboard) — xoa backlog outQueue master FX truoc khi
// note preview render (idle block ~341ms lam ARM lag ~0.3s).
window.__clearMasterFxQueue = function () {
_dbgLastClearMs = performance.now();
try { if (fxRt && fxRt.node && fxRt.node.port) fxRt.node.port.postMessage({ type: 'clear_queue', mode: 'live' }); } catch (e) {}
};
// V32: queueLen master FX worklet hien tai (midi_push log — ARM latency).
window.__fxRtQueueLen = function () {
try { return (fxRt && fxRt.stats) ? fxRt.stats.queueLen : 'n/a'; } catch (e) { return 'n/a'; }
};
// V28: do jank — play_start timestamp; play_rAF / play_first_audio log delta.
let _dbgPlayStartMs = 0;
let _dbgLastClearMs = 0;
let _dbgFirstAudioPending = false;
let _dbgFirstAudiblePending = false;
// V40: real PDC — freeze playhead theo latency đo được (fx_play_first self-calib).
let _fxRtPlayheadSnapPending = false;
let _fxRtFreezeTimer = null;
let _fxRtFreezeTries = 0;
const _nativeTrackUrls = {};
const _nativeTrackPending = {};
// ── MIDI overlap cleanup + polyphony gain (spec midi_polyphony_loudness) ─
@@ -1710,13 +1768,14 @@ 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 };
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 };
// 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
// stop thật (fade=true) giữ master FX warm + idle timeout (V21); bridge-down /
// new-project / export-end → teardown bình thường.
let fxRtKeepForRestart = false;
let fxRtWrapRestart = false; // V38: loop wrap restart -- clear_queue mode 'loop' (always-wipe + RESUME_FILL)
// V21: user stop (fade=true) giữ master FX session warm — play kế tiếp có FX
// ngay, không chờ reload VST ~4s. Idle timeout: ngừng chơi lâu → teardown
// giải phóng CPU/VST (chấp nhận ~4s delay ở play sau khi idle lâu).
@@ -1848,7 +1907,7 @@ async function fxRtStart() {
fxRt.sessionId = st.session_id;
fxRtGuiPushShm();
try {
await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608231800`);
await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608272300`);
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);
@@ -1897,8 +1956,8 @@ async function fxRtStart() {
if (fxRt.flushing) return; // V24: output stale trong luc flush — drop
const f = new Float32Array(ev.data);
const half = f.length / 2;
const l = new Float32Array(half);
const r = new Float32Array(half);
let l = fxRt.outL, r = fxRt.outR;
if (!l || l.length !== half) { l = fxRt.outL = new Float32Array(half); r = fxRt.outR = new Float32Array(half); }
for (let i = 0; i < half; i++) { l[i] = f[i * 2]; r[i] = f[i * 2 + 1]; }
fxRt.node.port.postMessage({ type: 'out', l, r });
};
@@ -1909,12 +1968,13 @@ async function fxRtStart() {
// 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 fxRtChunks = [0, 1, 2, 3].map(() => new Float32Array(512));
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);
for (let i = 0; i < f.length; i += 512) { const c = fxRtChunks[i / 512]; c.set(f.subarray(i, i + 512)); fxRt.ws.send(c.buffer); }
} else {
fxRt.ws.send(d.data);
}
@@ -1978,6 +2038,28 @@ async function fxRtStart() {
// Telemetry worklet (mỗi ~2s): chẩn đoán crackle session thật.
fxRt.stats = d;
console.debug('[FX RT stats]', d);
// V33: log dbg.log — diff giua 2 dong = so su kien trong ~2s (u=underrun,
// h=hold, f=fade, s=silence, cap=capDrop, fi=fadeIn, q=queueLen).
try { _dbgLog('fx_stats', 'u=' + d.underruns + ' h=' + d.holds + ' f=' + d.fades + ' s=' + d.silences + ' cap=' + d.capDrops + ' fi=' + d.fadeIns + ' q=' + d.queueLen); } catch (e) {}
return;
}
if (d.type === 'dbg') {
// V32: hop dau sau clear_queue (in/out/play) — do latency master FX path.
const _now = performance.now();
// V40: self-calib PDC — fx_play_first = block đầu tiên thực sự phát ra
// (đã qua FILL) → latency path thực đo được; snap playhead về đúng
// startOffset ngay mẫu đầu tiên nghe được (cả 2 chiều: hết chạy trước
// lẫn hết lag). Native-bridge path dùng _dbgFirstAudiblePending riêng —
// không đụng flag này.
// V41: guard ≥50ms — tại loop wrap (clear_queue mode 'loop' giữ queue)
// worklet phát lại OLD TAIL ~5ms → dbgPlayFirst tiêu thụ → trước đây snap
// playhead SỚM + calib nhiễu ~50ms → display chạy trước → wrap sớm → vấp
// beat 0:0. Pass mới thật ≥ ~80ms → snap/calib chỉ trên event hợp lệ.
if (d.tag === 'fx_play_first' && (_now - _dbgPlayStartMs) >= 50) {
fxRt.pdcCalibMs = Math.max(50, Math.min(3000, _now - _dbgPlayStartMs));
try { if (window.__fxRtSnapPlayhead) window.__fxRtSnapPlayhead(); } catch (e2) {}
}
_dbgLog('fx_hop_' + d.tag, 'sincePlay=' + Math.round(_now - _dbgPlayStartMs) + ' sinceClear=' + Math.round(_now - _dbgLastClearMs) + ' wlT=' + d.t + ' q=' + (d.q !== undefined ? d.q : ''));
return;
}
if (d.type !== 'in') return;
@@ -2068,10 +2150,12 @@ function fxRtStop(silent) {
// qua worklet → lệch pha với mastered. fxRtApplyPdc trễ dry path đúng lượng
// để align. FX_RT_ROUNDTRIP_SAMPLES là ước lượng — ponytail: đo RTT thật
// (tag seq trên block) khi cần sample-accurate.
// RTT = batching 4 block (~7 block @48k ≈ 1792) + FILL buffer 24 block
// (6144) — worklet chỉ phát khi queue ≥ FILL → latency tĩnh thêm FILL → 31
// block ≈ 7936. (FILL 16 trước đây = 5888; giữ đồng bộ với sf-fx-realtime.js)
const FX_RT_ROUNDTRIP_SAMPLES = 7936;
// V40: FILL 24→8 (sf-fx-realtime.js) → RTT = batching 4 block (~7 block @48k
// ≈ 1792) + FILL buffer 8 block (2048) = 15 block ≈ 3840 (~80ms @48k). Giữ
// đồng bộ với sf-fx-realtime.js. FX_RT_START_EST_MS: ước lượng khởi đầu khi
// 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;
function fxRtApplyPdc() {
const ctx = getAudioContext();
const bus = masterBus;
@@ -2228,28 +2312,49 @@ function getAudioContext() {
var st = await window.NativeBridgeService.queryStatus();
var connected = !!(st && st.connected);
window.SonicMidiRouter.setBridgeConnected(connected);
_dbgLog('bootstrap', 'bridge_sr=' + (st && st.sample_rate) + ' ctx_sr=' + audioCtx.sampleRate + ' connected=' + connected);
console.log('[Bridge] bootstrap connected=', connected);
if (connected) {
try {
console.log('[Bridge] Requesting restart to match sampleRate:', audioCtx.sampleRate);
await window.__TAURI__.core.invoke('restart_bridge_with_sample_rate', { sampleRate: audioCtx.sampleRate });
// Bridge restart → mất hết instrument → reset map để reconnect reload.
window.__bridgeLoadedChannels = {};
window.__bridgeLoadedPaths = {};
for (var retry = 0; retry < 10; retry++) {
await new Promise(r => setTimeout(r, 100));
var st2 = await window.NativeBridgeService.queryStatus();
if (st2 && st2.connected) {
console.log('[Bridge] Reconnected at sampleRate:', audioCtx.sampleRate);
break;
// V27: bridge_status trả sample_rate thật (BridgeSampleRate state) —
// nếu đã khớp audioCtx (48k mặc định) thì KHÔNG restart: trước đây
// luôn kill+respawn → first play sau launch im lặng ~0.5s (chờ bridge
// respawn + restore instrument từ bridge_state.json).
if (st.sample_rate && st.sample_rate !== audioCtx.sampleRate) {
try {
console.log('[Bridge] Requesting restart to match sampleRate:', audioCtx.sampleRate, '(bridge was', st.sample_rate + ')');
await window.__TAURI__.core.invoke('restart_bridge_with_sample_rate', { sampleRate: audioCtx.sampleRate });
// Bridge restart → mất hết instrument → reset map để reconnect reload.
window.__bridgeLoadedChannels = {};
window.__bridgeLoadedPaths = {};
for (var retry = 0; retry < 10; retry++) {
await new Promise(r => setTimeout(r, 100));
var st2 = await window.NativeBridgeService.queryStatus();
if (st2 && st2.connected) {
console.log('[Bridge] Reconnected at sampleRate:', audioCtx.sampleRate);
break;
}
}
} catch (err) {
console.warn('[Bridge] restart error:', err);
}
} catch (err) {
console.warn('[Bridge] restart error:', err);
} else {
console.log('[Bridge] sampleRate match — skip restart:', audioCtx.sampleRate);
}
window.BridgeAudioNode.init(audioCtx);
window.NativeBridgeService.onAudio(function (l, r) {
try {
if (_dbgFirstAudioPending) {
_dbgFirstAudioPending = false;
_dbgLog('play_first_audio', 'delta_ms=' + Math.round(performance.now() - _dbgPlayStartMs));
}
if (_dbgFirstAudiblePending && l && r) {
var _nz = false;
for (var _i = 0; _i < l.length; _i++) { if (l[_i] !== 0 || r[_i] !== 0) { _nz = true; break; } }
if (_nz) {
_dbgFirstAudiblePending = false;
_dbgLog('play_first_audible', 'delta_ms=' + Math.round(performance.now() - _dbgPlayStartMs));
}
}
window.BridgeAudioNode.onAudio(l, r);
} catch (e) {
console.warn('[Bridge] audio callback error:', e && e.message || e);
@@ -14185,11 +14290,13 @@ const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.pat
window.addEventListener('resize', resizeAll);
const fftData = new Uint8Array(1024);
let _peakScratch = new Float32Array(2048);
function getPeakLevel(analyser) {
if (!analyser) return 0;
const bufferLength = analyser.fftSize;
const dataArray = new Float32Array(bufferLength);
if (_peakScratch.length < bufferLength) _peakScratch = new Float32Array(bufferLength);
const dataArray = _peakScratch;
analyser.getFloatTimeDomainData(dataArray);
let maxVal = 0;
@@ -14353,15 +14460,17 @@ const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.pat
// enough to gauge perceived loudness of the processed master).
const outAn = masterBus && masterBus.analyser;
if (outAn) {
const outBuf = new Float32Array(outAn.fftSize);
const outLen = outAn.fftSize;
if (_peakScratch.length < outLen) _peakScratch = new Float32Array(outLen);
const outBuf = _peakScratch;
outAn.getFloatTimeDomainData(outBuf);
let sq = 0;
for (let i = 0; i < outBuf.length; i++) sq += outBuf[i] * outBuf[i];
const rmsDb = 20 * Math.log10(Math.sqrt(sq / outBuf.length) + 1e-12);
for (let i = 0; i < outLen; i++) sq += outBuf[i] * outBuf[i];
const rmsDb = 20 * Math.log10(Math.sqrt(sq / outLen) + 1e-12);
const rmsEl = document.getElementById('outRmsText');
if (rmsEl) rmsEl.innerText = rmsDb < -90 ? '-inf' : rmsDb.toFixed(1) + ' dB';
if (!lufsAccRef.current) lufsAccRef.current = [];
lufsAccRef.current.push(sq / outBuf.length);
lufsAccRef.current.push(sq / outLen);
const accLen = 24; // ~0.4s at 60fps rAF
if (lufsAccRef.current.length >= accLen) {
const blocks = lufsAccRef.current.splice(0, accLen);
@@ -20722,9 +20831,51 @@ const App = () => {
const rulerRef = useRef(null);
const activeSourcesRef = useRef([]);
const activeTrackNodesRef = useRef({}); // { [trackId]: { gainNode, pannerNode } }
const nativeLoopSrcsRef = useRef(new Map()); // V42A: { [trackId+'::'+clipId]: { src, loopStart, loopEnd } } — native WebAudio loop qua wrap
const startOffsetTimeRef = useRef(0);
const startBufferOffsetRef = useRef(0);
const startAudioTimeRef = useRef(0);
// V40: real PDC — freeze playhead theo latency FX path (thay V39 freeze giả
// định 0.31s → under-estimate → playhead chạy trước ~0.66s). __fxRtPlayheadFreeze:
// gọi ở mọi play start (allowCold=true ở startTrackPlayback che cold-start VST
// pre-roll; section play không khởi động FX session → false). __fxRtSnapPlayhead:
// worklet fx_play_first → bỏ freeze đúng mẫu đầu tiên nghe được. Fail-safe:
// 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) {}
// 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.
const _fxWillRun = !!(fxRt && fxRtChain().length > 0 && (fxRt.active || allowCold));
if (!_fxWillRun) return;
const _estMs = (fxRt.pdcCalibMs || FX_RT_START_EST_MS);
const _wasActiveAtFreeze = !!(fxRt && fxRt.active);
startAudioTimeRef.current = getAudioContext().currentTime + _estMs / 1000;
_fxRtPlayheadSnapPending = true;
_fxRtFreezeTries = 0;
// V41: snap xác định bằng timer tại est — loop wrap warm không post fx_play_first
// cho pass mới (old-tail tiêu thụ dbgPlayFirst) nên không có event để snap. Cold
// start (fxRt.active=false) KHÔNG snap sớm: chờ fx_play_first thật (VST load 4s).
_fxRtFreezeTimer = setTimeout(function freezeSnapRetry() {
_fxRtFreezeTimer = null;
if (_wasActiveAtFreeze && _fxRtPlayheadSnapPending) {
_fxRtPlayheadSnapPending = false;
startAudioTimeRef.current = getAudioContext().currentTime;
return;
}
if (fxRt && fxRt.starting) {
if (_fxRtFreezeTries < 20) { _fxRtFreezeTries++; _fxRtFreezeTimer = setTimeout(freezeSnapRetry, 500); }
return;
}
if (!fxRt || !fxRt.active) { startAudioTimeRef.current = getAudioContext().currentTime; } // session fail → bỏ freeze
}, _estMs);
};
window.__fxRtSnapPlayhead = function () {
if (fxRt && fxRt.active && _fxRtPlayheadSnapPending) {
_fxRtPlayheadSnapPending = false;
startAudioTimeRef.current = getAudioContext().currentTime;
}
};
const animationFrameIdRef = useRef(null);
const toastTimeoutRef = useRef(null);
const rulerDragStartRef = useRef(null);
@@ -23701,6 +23852,13 @@ const App = () => {
// để master FX bị teardown giữa lúc section đang phát).
if (fxRtWarmIdleTimer) { clearTimeout(fxRtWarmIdleTimer); fxRtWarmIdleTimer = null; }
restorePendingFadeSession(); // V17: huy fade dang treo — replay khong bi dip gain
// V30: flush ring audio cu (engine idle blocks tu truoc) — xoa backlog truoc
// khi play; neu khong block dau cua play cho ring cu chay het (~341ms cap cu).
try { if (window.BridgeAudioNode && window.BridgeAudioNode.flush) window.BridgeAudioNode.flush(); } catch (e) {}
// V31: xoa backlog outQueue master FX (block idle ~341ms) — play moi khong
// cho phia sau; flush ring bridge (V30) khong chaym queue nay.
const _wrapMode = fxRtWrapRestart ? 'loop' : 'play'; fxRtWrapRestart = false; // V38
try { if (fxRt && fxRt.node && fxRt.node.port) fxRt.node.port.postMessage({ type: 'clear_queue', mode: _wrapMode }); } catch (e) {}
if (!st.buffer) {
st.buffer = context.createBuffer(1, 128, context.sampleRate);
}
@@ -23805,6 +23963,9 @@ const App = () => {
startOffsetTimeRef.current = offsetWallTime;
startBufferOffsetRef.current = offsetBuffer;
startAudioTimeRef.current = context.currentTime;
// V40: real PDC — freeze playhead theo latency FX path (đo được, không ước
// như V39). Section play không khởi động FX session → allowCold=false.
try { if (window.__fxRtPlayheadFreeze) window.__fxRtPlayheadFreeze(false); } catch (e) { }
};
const playMetronomeClick = (time, isDownbeat = false) => {
try {
@@ -24139,6 +24300,7 @@ const App = () => {
const start = Math.min(st.selectionStart, st.selectionEnd);
const end = Math.max(st.selectionStart, st.selectionEnd);
if (bufferPos >= end) {
fxRtWrapRestart = true; // V38: loop wrap restart
fxRtKeepForRestart = true; // restart/resume — giữ session FX realtime
stopAllPlayback();
setSubTabs(prev => prev.map(s => s.id === activeTabRef.current ? {
@@ -24166,6 +24328,7 @@ const App = () => {
return Math.max(maxEnd * beatSec, 16 * beatSec * 4) + 1.0;
})() : st.buffer.duration;
if (bufferPos >= effectiveDuration) {
fxRtWrapRestart = true; // V38: loop wrap restart
fxRtKeepForRestart = true; // restart/resume — giữ session FX realtime
stopAllPlayback();
if (st.isLooping) {
@@ -24206,6 +24369,7 @@ const App = () => {
if (!selectionCleared && isLoopingSelection && selLeft !== null && selRight !== null) {
if (selRight > selLeft && updatedTime >= selRight) {
if (hasAnySolo || selectionMode === 'local') {
fxRtWrapRestart = true; // V38: loop wrap restart
fxRtKeepForRestart = true; // restart/resume — giữ session FX realtime
stopAllPlayback();
startOffsetTimeRef.current = selLeft;
@@ -24219,6 +24383,7 @@ const App = () => {
setCurrentTime(selLeft);
setIsPlaying(true);
} else {
fxRtWrapRestart = true; // V38: loop wrap restart
fxRtKeepForRestart = true; // restart/resume — giữ session FX realtime
stopAllPlayback();
startOffsetTimeRef.current = selLeft;
@@ -24241,6 +24406,7 @@ const App = () => {
return;
}
if (isLoopingSelection) {
fxRtWrapRestart = true; // V38: loop wrap restart
fxRtKeepForRestart = true; // restart/resume — giữ session FX realtime
stopAllPlayback();
startOffsetTimeRef.current = 0;
@@ -24502,7 +24668,7 @@ const App = () => {
fxRtTracks[key] = entry;
fxRtGuiPushShm();
try {
await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608231800`);
await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608272300`);
const node = new AudioWorkletNode(ctx, 'sf-fx-realtime', { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [2] });
entry.node = node;
const u = new URL(API_BASE_URL);
@@ -25125,8 +25291,23 @@ const App = () => {
const context = getAudioContext();
_nativePlayEpoch++; // new play — vô hiệu doHardStop đang fade (play trong cửa sổ 80ms)
restorePendingFadeSession(); // V17: huy fade dang treo — replay khong bi dip gain
// V30: flush ring audio cu (engine idle blocks tu truoc) — xoa backlog truoc
// khi play; neu khong block dau cua play cho ring cu chay het (~341ms cap cu).
try { if (window.BridgeAudioNode && window.BridgeAudioNode.flush) window.BridgeAudioNode.flush(); } catch (e) {}
// V31: xoa backlog outQueue master FX (block idle ~341ms) — play moi khong
// cho phia sau; flush ring bridge (V30) khong chaym queue nay.
const _wrapMode = fxRtWrapRestart ? 'loop' : 'play'; fxRtWrapRestart = false; // V38
try { if (fxRt && fxRt.node && fxRt.node.port) fxRt.node.port.postMessage({ type: 'clear_queue', mode: _wrapMode }); } catch (e) {}
_dbgPlayStartMs = performance.now();
_dbgFirstAudioPending = true;
_dbgFirstAudiblePending = true;
_dbgLog('play_start', 'offset=' + offsetTime + ' epoch=' + _nativePlayEpoch + ' ' + _dbgBridgeGain() + ' ' + _dbgWorkletState() + ' fxrt=' + (fxRt.active ? 1 : 0) + ' node=' + (fxRt.node ? 1 : 0) + ' q=' + (fxRt.stats ? fxRt.stats.queueLen : 'n/a'));
// Realtime VST FX (master chain qua SHM bridge): bật khi play + chain active.
try { fxRtStart(); } catch (e) { console.warn('[FX RT] start error:', e); }
// V40: real PDC — freeze playhead theo latency FX path; allowCold=true che
// cold-start VST pre-roll (playhead không chạy trước khi có tiếng). Bỏ V39
// freeze giả định 0.31s (under-estimate → playhead chạy trước ~0.66s).
try { if (window.__fxRtPlayheadFreeze) window.__fxRtPlayheadFreeze(true); } catch (e) { }
// D2: bridge active -> báo transport PLAY (bridge flush note-off cũ + đồng
// bộ timeline; A13 C++ xử lý arg1=playheadSamples sau này).
if (window.NativeBridgeService && window.NativeBridgeService.isBridgeConnected) {
@@ -25138,6 +25319,7 @@ const App = () => {
bridgeStopRetryTimerRef.current = null;
}
try { window.NativeBridgeService.transport('play'); } catch (e) {}
_dbgLog('play_transport', 'offset=' + offsetTime);
}
// V12: guardPlay (scheduleMidiNoteDispatch) đọc isPlayingRef.current — nếu
// để React render set (setIsPlaying(true) sau) thì note-on delay=0 của
@@ -25152,9 +25334,19 @@ const App = () => {
const allPlayTracks = activeTracksRef.current && activeTracksRef.current.length ? activeTracksRef.current : activeTracks;
// Capture items signature at schedule time — updatePlayhead so sánh với
// signature này để phát hiện items đổi vị trí giữa lúc play (re-schedule).
scheduledItemsSigRef.current = buildItemsSignature(allPlayTracks, sessionTabs);
// V28: defer bookkeeping signature — tính đồng bộ chặn main thread trước
// first-audio (góp vào delay onset); signature chỉ so sánh ~10fps trong
// updatePlayhead → trễ 0ms là đủ (re-schedule phát hiện muộn không sao).
setTimeout(function () { try { scheduledItemsSigRef.current = buildItemsSignature(allPlayTracks, sessionTabs); } catch (e) {} }, 0);
console.log('[Play] offset=' + offsetTime + ' tracks=' + allPlayTracks.length + ' masterBus=' + !!masterBus + ' dest=' + (context.destination ? 'ok' : 'MISSING') + ' projectEnd=' + (projectEndRef.current || 0).toFixed(2));
const hasSolo = allPlayTracks.some(t => t.solo);
// V42A: native loop region — loop selection hợp lệ → [selLeft, selRight];
// isLoopingSelection không selection → [0, projectEnd] (project-end wrap 24407).
// Không loop → _loopActive=false → fallback dip V42.
const _loopSel = !selectionCleared && isLoopingSelection && selLeft !== null && selRight !== null && selRight > selLeft;
const _loopLs = _loopSel ? selLeft : 0;
const _loopLe = _loopSel ? selRight : projectEndRef.current;
const _loopActive = isLoopingSelection && _loopLe > _loopLs;
allPlayTracks.forEach(track => {
const isPlayable = hasSolo ? track.solo : !track.muted;
if (!isPlayable) return;
@@ -25178,9 +25370,7 @@ const App = () => {
const rb = routeBypassFor(track);
playNode.route.routeGain.gain.value = rb ? 0 : 1;
playNode.route.dryGain.gain.value = rb ? 1 : 0;
console.log('[Bypass] reapply track', track.id, 'contentType=' + (routeBypassFor._lastType ? 'midi' : 'audio'), 'bypass=' + rb, '→ routeGain=' + playNode.route.routeGain.gain.value + '/dryGain=' + playNode.route.dryGain.gain.value);
}
console.log('[Play] track', track.id, track.name, 'node ok:', !!(gainNode && pannerNode), 'muted:', track.muted);
const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{
id: 'default',
buffer: track.buffer,
@@ -25190,13 +25380,34 @@ const App = () => {
}] : [];
clips.forEach(clip => {
if (!clip.buffer) return;
const clipStart = clip.startTime || 0;
const clipDuration = clip.buffer.duration / (clip.speed || 1.0);
const clipEnd = clipStart + clipDuration;
// V42A: native WebAudio loop — wrap mẫu-chính-xác tại loopEnd, không
// restart → không click (thay dip V42). Chỉ khi loop active + track 1
// clip + speed=1 + clip phủ đủ vùng loop. Source giữ nguyên qua wrap
// (doHardStop skip khi fxRtWrapRestart) → seam-free.
const _loopable = _loopActive && clips.length === 1 && Math.abs((clip.speed || 1.0) - 1) < 0.0001 && clipStart <= _loopLs + 0.001 && clipEnd >= _loopLe - 0.001;
const _key = track.id + '::' + (clip.id || 'default');
const _kept = nativeLoopSrcsRef.current.get(_key);
if (_loopable && _kept && _kept.src && _kept.src.playbackState !== 'ended') {
if (Math.abs(_kept.loopStart - _loopLs) < 0.001 && Math.abs(_kept.loopEnd - _loopLe) < 0.001) {
activeSourcesRef.current.push(_kept.src); // giữ source loop đang chạy qua wrap
return;
}
try { _kept.src.stop(); } catch (e) {}
nativeLoopSrcsRef.current.delete(_key);
}
const source = context.createBufferSource();
source.buffer = clip.buffer;
source.playbackRate.value = clip.speed || 1.0;
source.connect(gainNode);
const clipStart = clip.startTime || 0;
const clipDuration = clip.buffer.duration / (clip.speed || 1.0);
const clipEnd = clipStart + clipDuration;
if (_loopable) {
source.loop = true;
source.loopStart = (_loopLs - clipStart) / (clip.speed || 1.0);
source.loopEnd = (_loopLe - clipStart) / (clip.speed || 1.0);
nativeLoopSrcsRef.current.set(_key, { src: source, loopStart: _loopLs, loopEnd: _loopLe });
}
if (offsetTime < clipStart) {
const delay = clipStart - offsetTime;
source.start(context.currentTime + delay, 0);
@@ -25447,15 +25658,34 @@ const App = () => {
});
}
});
// V42A: sweep native-loop sources không được schedule pass này (track muted/
// solo-off) → dừng + xóa — tránh source loop chơi âm thầm mãi.
try {
nativeLoopSrcsRef.current.forEach((v, k) => {
if (!v || !v.src || v.src.playbackState === 'ended') { nativeLoopSrcsRef.current.delete(k); return; }
if (activeSourcesRef.current.indexOf(v.src) === -1) {
try { v.src.stop(); } catch (e) {}
nativeLoopSrcsRef.current.delete(k);
}
});
} catch (e) {}
updateSfRouting();
};
// Solo playback for Local Selection Loop (LOOP_MAKER.md §2.2)
const startLocalTrackPlayback = (trackId, offsetTime) => {
const context = getAudioContext();
// V42A: local/solo path khong dung native loop — dung + xoa src loop con lai
// tu main play (wrap local giu chung qua doHardStop -> double audio neu khong).
try {
nativeLoopSrcsRef.current.forEach(v => { try { if (v && v.src) v.src.stop(); } catch (e) {} });
nativeLoopSrcsRef.current.clear();
} catch (e) {}
// V24: solo/local path — gọi fxRtStart (idempotent) để âm solo qua master
// chain (trước đây path này bỏ qua → solo không qua master FX).
try { fxRtStart(); } catch (e) { console.warn('[FX RT] start error:', e); }
const _wrapMode = fxRtWrapRestart ? 'loop' : 'play'; fxRtWrapRestart = false; // V38
try { if (fxRt && fxRt.node && fxRt.node.port) fxRt.node.port.postMessage({ type: 'clear_queue', mode: _wrapMode }); } catch (e) {}
// V12: parity với startTrackPlayback — loop restart (solo/local) sau
// stopAllPlayback(): hủy STOP#2 + báo PLAY (nếu không C++ transportStopped
// giữ true → mọi note-on bị drop → solo/local loop pass 2+ câm) + set
@@ -25569,6 +25799,12 @@ const App = () => {
const context = getAudioContext();
_nativePlayEpoch++; // new play — vô hiệu doHardStop đang fade
restorePendingFadeSession(); // V17: huy fade dang treo — replay khong bi dip gain
// V30: flush ring audio cu (engine idle blocks tu truoc) — xoa backlog truoc
// khi play; neu khong block dau cua play cho ring cu chay het (~341ms cap cu).
try { if (window.BridgeAudioNode && window.BridgeAudioNode.flush) window.BridgeAudioNode.flush(); } catch (e) {}
// V31: xoa backlog outQueue master FX (block idle ~341ms) — play moi khong
// cho phia sau; flush ring bridge (V30) khong chaym queue nay.
try { if (fxRt && fxRt.node && fxRt.node.port) fxRt.node.port.postMessage({ type: 'clear_queue', mode: 'play' }); } catch (e) {}
// V10: piano roll loop restart cũng gọi stopAllPlayback() trước (STOP#1 →
// bridge transportStopped=true) → mọi note-on bị drop nếu không PLAY lại.
// Hủy STOP#2 (150ms) + báo PLAY để bridge nhận note mới (loop liên tục).
@@ -25713,6 +25949,10 @@ const App = () => {
startAudioTimeRef.current = context.currentTime;
startTrackPlayback(currentTime);
setIsPlaying(true);
// V28: rAF kế tiếp sau setState → đo jank main thread (chênh với play_start).
requestAnimationFrame(function () {
_dbgLog('play_rAF', 'delta_ms=' + Math.round(performance.now() - _dbgPlayStartMs));
});
}
};
handlePlayPauseRef.current = handlePlayPause;
@@ -25764,6 +26004,19 @@ const App = () => {
};
const stopAllPlayback = (fade) => {
try {
if (fxRtWrapRestart && fxRtChain().length === 0 && nativeLoopSrcsRef.current.size === 0 && masterBus && masterBus.muteGain && masterBus.muteGain.gain) {
// V41: dip CHỈ khi bypass (chain rỗng). FX active → seam thật xảy ra sau
// latency (~80ms) tại _now+latency; dip tại _now chỉ tạo dropout 14ms giữa
// đuôi pass cũ (audible) mà không che được seam click.
// V42: rút ngắn dip 14ms → 5ms (2ms xuống + 3ms lên) — đủ che click seam
// nhưng dropout quá ngắn để nghe thấy (V39 14ms tạo dropout rõ).
const _actx = getAudioContext(); const _now = _actx.currentTime;
masterBus.muteGain.gain.cancelScheduledValues(_now);
masterBus.muteGain.gain.setValueAtTime(masterBus.muteGain.gain.value || 1, _now);
masterBus.muteGain.gain.linearRampToValueAtTime(0.0001, _now + 0.002);
masterBus.muteGain.gain.linearRampToValueAtTime(1, _now + 0.005);
} // V39: dip input truoc hard-stop
_dbgLog('stop_start', 'fade=' + !!fade + ' ' + _dbgBridgeGain());
isPlayingRef.current = false;
// Gap 7: xoa automation schedule — tra gain master ve 1.0 (fader rieng).
try { if (masterBus && masterBus.output && masterBus.output.gain) { const _actx = getAudioContext(); masterBus.output.gain.cancelScheduledValues(_actx.currentTime); masterBus.output.gain.setValueAtTime(1, _actx.currentTime); } } catch (e) { }
@@ -25807,7 +26060,9 @@ const App = () => {
const capturedSources = activeSourcesRef.current.slice();
const capturedNodes = Object.values(activeTrackNodesRef.current);
activeSourcesRef.current = [];
activeTrackNodesRef.current = {};
// V42A: wrap (fade=false) GIỮ track nodes — source native-loop kept vẫn
// routed (node cũ). User stop (fade=true) mới xóa nodes.
if (fade) activeTrackNodesRef.current = {};
const fadedGains = [];
// V16: stop NGAY clip source CHƯA phát (playbackState='scheduled') — nếu
// chờ tới doHardStop (sau FADE_MS) chúng đã bắt đầu phát trong lúc fade →
@@ -25848,8 +26103,12 @@ const App = () => {
const doHardStop = () => {
try {
const stale = _nativePlayEpoch !== stopEpoch; // play mới bắt đầu trong lúc fade
// V42A: wrap (fxRtWrapRestart, fade=false) — giữ source native-loop đang
// chạy qua restart (seam-free tại loopEnd); stop/seek thật → dừng + xóa.
const _wrapKeepLoop = !stale && fxRtWrapRestart && nativeLoopSrcsRef.current.size > 0;
const _loopSrcs = _wrapKeepLoop ? new Set(Array.from(nativeLoopSrcsRef.current.values(), v => v.src)) : null;
capturedSources.forEach(src => {
try { src.stop(); } catch (e) { }
try { if (_loopSrcs && _loopSrcs.has(src)) return; src.stop(); } catch (e) { }
});
capturedNodes.forEach(n => {
if (n.fxStopFn) { try { n.fxStopFn(); } catch (e) {} }
@@ -25857,16 +26116,42 @@ const App = () => {
// V17: restore gain LUON — play moi trong cua so fade (stale) truoc day bo qua
// restore -> track/bridge gain ket 0.0001 -> moi am sau bi fade-out dai dang.
// Chi skip khi co fade session MOI hon (session nay het pending — session moi tu restore).
if (_pendingFadeSession && _pendingFadeSession.stopEpoch === stopEpoch) {
restorePendingFadeSession();
if (stale) {
if (_pendingFadeSession && _pendingFadeSession.stopEpoch === stopEpoch) {
restorePendingFadeSession();
}
return; // play mới đang chạy — không dừng bridge/SF của nó
}
if (stale) return; // play mới đang chạy — không dừng bridge/SF của nó
if (!_wrapKeepLoop) nativeLoopSrcsRef.current.clear(); // V42A: stop/seek thật — quên native loop
// V26: user stop (fade) — dừng master FX tail: xóa outQueue worklet + flush
// engine ring (audio đã xử lí còn trong pipeline phát tiếp ~0.3-0.5s sau stop
// = "ding"). Worklet tự fade-out 21ms (hết click) rồi báo main flush engine;
// session giữ warm (play kế tiếp FX ngay, không reload VST).
try {
if (fade && fxRt && fxRt.node && fxRt.active) {
fxRt.node.port.postMessage({ type: 'flush_for_stop' });
}
} catch (e) { console.warn('[Stop] fx flush error:', e); }
stopMidiCapture();
stopAllNativeSfNotes(); stopAllNativeVstNotes();
// D2/D6: bridge active -> transport STOP (bridge flush toàn pitch, hết
// âm ngân tức thì; A13 C++ flush note-off).
if (window.NativeBridgeService && window.NativeBridgeService.isBridgeConnected) {
try { window.NativeBridgeService.transport('stop'); } catch (e) {}
_dbgLog('stop_transport', 'epoch=' + stopEpoch);
// V39: MIDI panic CC123/CC120 tai wrap — VSTi note treo (khong nhan note-off) phai tat ngay.
try {
if (window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive && window.SonicMidiRouter.isBridgeActive()) {
Object.keys(trackMidiChannelsRef.current || {}).forEach(function (_tid) {
var _ch = trackMidiChannelsRef.current[_tid];
window.NativeBridgeService.dispatchMidiEvent('CC', _ch, 123, 0, 0, 0, 0, false);
window.NativeBridgeService.dispatchMidiEvent('CC', _ch, 120, 0, 0, 0, 0, false);
});
}
} catch (e) { console.warn('[Stop] midi panic error:', e); }
// V27: xóa audio còn buffer trong pipeline (preBuffer/chunks + worklet
// ring) — nếu không, âm trước stop còn trong ring phát tiếp sau fade.
try { if (window.BridgeAudioNode && window.BridgeAudioNode.flush) window.BridgeAudioNode.flush(); } catch (e) { console.warn('[Stop] bridge flush error:', e); }
// V8 bug 3: transport stop thu 2 sau 150ms — note-on timer guard sync
// qua React effect (cham) van bay toi bridge sau STOP → retrigger am
// treo. V10: timer lưu ref — startTrackPlayback (loop restart) hủy
@@ -25882,6 +26167,12 @@ const App = () => {
// Dừng triệt để: noteoff từng note + hủy scheduled note-on (hết âm stuck)
if (window.SonicSF.panic) { try { window.SonicSF.panic(); } catch (e) { console.warn('[Stop] panic error:', e); } }
}
// V28: KHÔNG auto-restore gain sau stop — VSTi release tail có thể dài
// hơn 800ms (V27 vẫn còn "ding"). Gain giữ ~0.0001 vô thời hạn; restore
// khi: play mới (startTrackPlayback entry) hoặc midi bất kỳ (pushEvent →
// __restorePendingFadeSession — keyboard/pad preview). Tail vẫn render
// nhưng im lặng qua bridge gain.
_dbgLog('stop_hard', 'epoch=' + stopEpoch + ' ' + _dbgBridgeGain());
} catch (e) {
console.warn('[Stop] stopAllPlayback error:', e);
}