fix(bounce): offline render qua native bridge — bỏ gap realtime, thêm headroom, strip DIAG

This commit is contained in:
2026-08-25 09:11:29 +07:00
parent 4037dbf40d
commit dd17f87e4e
7 changed files with 66 additions and 107 deletions
+42 -55
View File
@@ -12,47 +12,6 @@ const API_AUDIO = `${API_BASE_URL}/api/v1/audio`;
const API_MULTITRACK = `${API_BASE_URL}/api/v1/multitrack`;
const API_TASKS = `${API_BASE_URL}/api/v1/audio/tasks`;
// ── DEBUG diag (tạm thời) ──
window.__sfDiag = (function () {
var arr = [];
try { var s = localStorage.getItem('__sf_diag'); if (s) arr = JSON.parse(s) || []; } catch (e) {}
return function (msg) {
try {
arr.push(new Date().toISOString().slice(11, 23) + ' ' + msg);
if (arr.length > 2000) arr = arr.slice(-2000);
localStorage.setItem('__sf_diag', JSON.stringify(arr));
} catch (e) {}
};
})();
window.__sfDiagDump = function () {
try {
var s = localStorage.getItem('__sf_diag');
var arr = s ? (JSON.parse(s) || []) : [];
var idx = -1;
for (var i = arr.length - 1; i >= 0; i--) { if (arr[i].indexOf('BOUNCE_START') !== -1) { idx = i; break; } }
return (idx >= 0 ? arr.slice(idx) : arr.slice(-50)).join('\n');
} catch (e) { return String(e); }
};
window.__sfDiagShow = function () {
try { if (window.__sfDiagDump) { var ov = document.createElement('div'); ov.style.cssText = 'position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,.65);display:flex;align-items:center;justify-content:center;font-family:monospace'; var box = document.createElement('div'); box.style.cssText = 'background:#fff;color:#111;border-radius:8px;padding:12px;width:min(92vw,900px);max-height:85vh;display:flex;flex-direction:column;box-shadow:0 4px 24px rgba(0,0,0,.5)'; var ta = document.createElement('textarea'); ta.readOnly = true; ta.value = window.__sfDiagDump(); ta.style.cssText = 'flex:1;min-height:55vh;font-family:monospace;font-size:11px;white-space:pre;resize:none'; var bar = document.createElement('div'); bar.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;margin-top:8px'; var bCopy = document.createElement('button'); bCopy.textContent = 'Copy'; bCopy.onclick = function () { try { ta.focus(); ta.select(); document.execCommand('copy'); bCopy.textContent = 'Đã copy!'; } catch (e) { bCopy.textContent = 'Copy thủ công (Ctrl+C)'; } }; var bClose = document.createElement('button'); bClose.textContent = 'Đóng'; bClose.onclick = function () { document.body.removeChild(ov); }; bar.appendChild(bCopy); bar.appendChild(bClose); box.appendChild(ta); box.appendChild(bar); ov.appendChild(box); document.body.appendChild(ov); try { ta.focus(); ta.select(); } catch (e) {} return; } } catch (e) { window.alert('DIAG:\n' + (window.__sfDiagDump ? window.__sfDiagDump() : '')); }
};
// HB: main-thread heartbeat — khoảng cách giữa các HB chứng minh main thread
// bị block (timer không fire trong lúc block). Chạy vĩnh viễn; dump chỉ lấy
// từ BOUNCE_START.
window.__sfHBCount = 0;
setInterval(function () { window.__sfHBCount++; if (window.__sfDiag) window.__sfDiag('HB:' + window.__sfHBCount); }, 250);
// WI_QUERY: bridge write_index liveness via proven invoke path (event-free).
window.__sfWICount = 0;
setInterval(function () {
window.__sfWICount++;
if (!window.__TAURI__ || !window.__TAURI__.core) return;
window.__TAURI__.core.invoke('bridge_status').then(function (s) {
if (window.__sfDiag) window.__sfDiag('WI_QUERY#' + window.__sfWICount + ' idx=' + (s && s.write_index) + ' conn=' + (s && s.connected ? 1 : 0));
}).catch(function (e) {
if (window.__sfDiag) window.__sfDiag('WI_QUERY#' + window.__sfWICount + ' err=' + String(e).slice(0, 80));
});
}, 2000);
// ── Dedicated per-track MIDI channel allocation ──
// FluidSynth has 16 channels; if two tracks share a channel, arming one track
// re-selects the other track's program and its instrument changes. Every track
@@ -372,7 +331,6 @@ const _renderNativeTrack = (track, bpm, baseOffsetSec, notesOverride) => {
try {
const eng = track && track.synth_engine;
const data = _buildNativeTrackNotes(track, bpm, baseOffsetSec, notesOverride);
if (window.__sfDiag) window.__sfDiag('renderNative track=' + (track && track.id) + ' sfId=' + (eng && eng.soundfont_id) + ' engType=' + (eng && eng.type) + ' notes=' + data.absNotes.length);
if (!data.absNotes.length) return Promise.resolve(null);
if (isSfTrackEngine(eng)) {
const sfId = eng && eng.soundfont_id;
@@ -2188,10 +2146,9 @@ function getAudioContext() {
window.BridgeAudioNode.init(audioCtx);
window.NativeBridgeService.onAudio(function (l, r) {
try {
if (!window.__audioCbEnterAt || Date.now() - window.__audioCbEnterAt >= 1000) { window.__audioCbEnterAt = Date.now(); if (window.__sfDiag) window.__sfDiag('AUDIO_CB_ENTER'); }
window.BridgeAudioNode.onAudio(l, r);
} catch (e) {
if (window.__sfDiag) window.__sfDiag('AUDIO_CB_ERR ' + String(e && e.message || e).slice(0, 150));
console.warn('[Bridge] audio callback error:', e && e.message || e);
}
});
// A14: plugin MIDI output -> route notes to the selected track
@@ -24614,7 +24571,6 @@ const App = () => {
// bridge tự render note-off sau duration. A11 (sample-accurate) sẽ thay
// setTimeout bằng sampleOffset đẩy thẳng vào SHM.
const scheduleMidiNoteDispatch = (track, pitch, velocity, durMs, startTime, program, destNode, ch, synthEngine) => {
if (window.__sfDiag) window.__sfDiag('schedMidi track=' + (track && track.id) + ' p=' + pitch + ' ch=' + ch + ' prog=' + program + ' bridgeActive=' + !!(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()));
if (window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()) {
const ctx = getAudioContext();
const startAt = startTime || ctx.currentTime; // undefined/null = phát ngay
@@ -24632,13 +24588,10 @@ const App = () => {
// V11: epoch guard — hủy timer cũ khi stop/loop restart (xem ref).
const epoch = bridgeNoteEpochRef.current;
setTimeout(function () {
if (window.__sfDiag) window.__sfDiag('TMR_FIRE track=' + trkId + ' p=' + (pitch || 60) + ' epOk=' + (epoch === bridgeNoteEpochRef.current) + ' guard=' + (guardPlay() ? 1 : 0));
if (epoch !== bridgeNoteEpochRef.current) return;
if (!guardPlay()) return;
if (window.__sfDiag) window.__sfDiag('DISPATCH_ON track=' + trkId + ' p=' + (pitch || 60));
var _rt = window.SonicMidiRouter;
if (window.__sfDiag) window.__sfDiag('PUSH_CALL router=' + (typeof _rt) + ' push=' + (_rt ? typeof _rt.pushEvent : 'none') + ' ver=' + (_rt && _rt.pushEvent ? (_rt.pushEvent.toString().indexOf('PUSH_ROUTE') >= 0 ? 'new' : 'old') : 'na') + ' brAct=' + (_rt ? _rt.isBridgeActive() : 'na'));
try { window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_ON', channel: trkId, pitch: pitch || 60, velocity: velocity || 0.8, percussion: isPerc }); } catch (e) { if (window.__sfDiag) window.__sfDiag('PUSH_EXC ' + (e && e.message)); }
try { window.SonicMidiRouter.pushEvent({ cmd: 'NOTE_ON', channel: trkId, pitch: pitch || 60, velocity: velocity || 0.8, percussion: isPerc }); } catch (e) { console.warn('[Midi] pushEvent error:', e && e.message); }
}, delayMs);
scheduleNoteOff('B:' + trkId + ':' + (pitch || 60), function () {
if (epoch !== bridgeNoteEpochRef.current) return;
@@ -24658,7 +24611,6 @@ const App = () => {
const startTrackPlayback = offsetTime => {
const context = getAudioContext();
if (window.__sfDiag) window.__sfDiag('startPlay offset=' + offsetTime + ' isBridgeConn=' + !!(window.NativeBridgeService && window.NativeBridgeService.isBridgeConnected) + ' routerActive=' + !!(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()) + ' sonicSF=' + !!window.SonicSF + ' standalone=' + isStandaloneSf());
_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
// Realtime VST FX (master chain qua SHM bridge): bật khi play + chain active.
@@ -24694,7 +24646,6 @@ const App = () => {
allPlayTracks.forEach(track => {
const isPlayable = hasSolo ? track.solo : !track.muted;
if (!isPlayable) return;
if (window.__sfDiag) window.__sfDiag('playTrk id=' + track.id + ' name=' + track.name + ' type=' + track.type + ' midiItems=' + (track.midiItems || []).length + ' items=' + ((track.items || []).length) + ' sfEng=' + isSfTrackEngine(track.synth_engine) + ' vstEng=' + isVstTrackEngine(track.synth_engine) + ' routerAct=' + !!(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive()));
const gainNode = getOrCreateTrackNode(track, context);
// Realtime VST FX per-track (SHM bridge): mọi luồng track qua bridge —
@@ -28305,9 +28256,7 @@ const App = () => {
// (audio + MIDI + sections qua FX racks + mastering chain, đúng như nghe) và
// capture PCM từ master bus, sau đó encode WAV trung thực.
const triggerBounceExport = async () => {
if (!masterBus) { showToast("Hãy phát thử một lần để khởi tạo audio engine trước.", "warning"); return; }
if (isExporting) return;
if (window.__sfDiag) window.__sfDiag('BOUNCE_START');
isExportingRef.current = true; // guardPlay: bounce = đang phát thật
setIsExporting(true);
setShowExportPanel(false);
@@ -28327,6 +28276,45 @@ const App = () => {
}
let durationLimit = loopDur || (Math.max(1.0, computeMainSessionEndTime(allTracks)) + 1.2); // FX/mastering tail (chỉ khi không loop)
// ── OFFLINE render (ưu tiên): engine render full project qua native
// bridge --render (MIDI sample-accurate, audio items, track FX, mastering
// chain) — không gap, nhanh hơn realtime, đúng timeline hiển thị.
// Chỉ khi server connected; loop vẫn dùng realtime (cần cắt selection).
if (!loopDur && serverStatus === 'connected' && window.SonicAPI && window.SonicAPI.renderProject) {
try {
const schema = serializeProjectToSchema(currentProjectId || 'temp_project', projectName || 'Dự án tạm chưa lưu', bpm, tracks, subTabs, sessionTabs, masteringSettings, masterVolume, { volL: masterVolL, volR: masterVolR, link: masterLink, muted: masterMuted });
const isFloat = exportSettings.bitDepth === 'float';
const outExt = exportSettings.format === 'ogg' ? 'ogg' : 'wav';
const res = await window.SonicAPI.renderProject(schema, 'bounce_' + Date.now() + '.' + outExt, {
bit_depth: isFloat ? 32 : parseInt(exportSettings.bitDepth) || 16,
sample_rate: parseInt(exportSettings.sampleRate) || 44100,
dither: !!exportSettings.dither,
normalize: !!exportSettings.normalize,
normalize_target: exportSettings.normalizeTarget || '-6dbtp',
mono_check: !!exportSettings.monoCheck,
metadata: {
title: exportSettings.metaTitle || '',
artist: exportSettings.metaArtist || '',
isrc: exportSettings.metaIsrc || ''
}
});
const lufsTxt = (res.lufs !== null && res.lufs !== undefined) ? res.lufs + ' LUFS' : '-- LUFS';
const tpTxt = (res.true_peak_db !== null && res.true_peak_db !== undefined) ? res.true_peak_db + ' dBTP' : '-- dBTP';
const br = await fetch(res.url);
if (!br.ok) throw new Error('HTTP ' + br.status);
const b = await br.blob();
const sp = await saveExportViaDialog(`sonicforge-bounce-${parseInt(exportSettings.sampleRate) || 44100}hz-${isFloat ? 32 : parseInt(exportSettings.bitDepth) || 16}bit.${outExt}`, b);
if (sp) showToast(`Bounce hoàn tất → ${sp} • ${lufsTxt} / ${tpTxt}`, 'success');
else showToast(`Bounce hoàn tất — ${lufsTxt} / ${tpTxt}`, 'success');
return;
} catch (err) {
console.warn('[Bounce] offline render fail — fallback realtime:', err);
}
}
// ── Realtime fallback (server offline / render fail): chạy lại project
// qua WebAudio, capture master bus post-FX + post-mastering. ──
if (!masterBus) { showToast("Hãy phát thử một lần để khởi tạo audio engine trước.", "warning"); return; }
const captureRate = audioCtx.sampleRate || 44100;
const isFloatBit = exportSettings.bitDepth === 'float';
const bitDepth = isFloatBit ? 32 : parseInt(exportSettings.bitDepth) || 16;
@@ -28406,8 +28394,6 @@ const App = () => {
}
const blob = new Blob([view], { type: 'audio/wav' });
await downloadBlobViaServer(blob, `sonicforge-bounce-${captureRate}hz-${bitDepth}bit.wav`).catch(() => {});
if (window.__sfDiag) window.__sfDiag('BOUNCE_END');
try { window.__sfDiagShow(); } catch (e) {}
showToast("Bounce hoàn tất — file gồm MIDI + FX Rack + Mastering Chain!", "success");
} catch (err) {
showToast("Lỗi bounce: " + err.message, "error");
@@ -28417,6 +28403,7 @@ const App = () => {
setShowExportProgress(false);
}
};
const handleSaveLocalProject = (name) => {
const finalName = name || projectName || 'Dự án mới';
const localId = currentProjectId && currentProjectId.startsWith('local_') ? currentProjectId : 'local_' + Date.now();