feat(export): Export Mix loop không fade (checkbox + cắt buffer client) + server download thay blob (WebView2 chặn)

This commit is contained in:
2026-08-21 11:25:49 +07:00
parent 96da8b5ac0
commit 2c93c701ce
4 changed files with 160 additions and 43 deletions
+19 -2
View File
@@ -8,7 +8,7 @@
| Phase | Nội dung | Trạng thái | Ghi chú |
|-------|----------|-----------|---------|
| 1 | Fix Export MIDI | ✅ | backend + frontend + build + verify xong (commit riêng) |
| 2 | Fix Export Mix + loop | ⏳ | |
| 2 | Fix Export Mix + loop | ✅ | frontend + build + verify xong (commit riêng) |
| 3 | FX CHAIN (hợp nhất modal) | ⏳ | |
| 4 | Nút FX TCP → FX CHAIN | ⏳ | wiring đã có, cần verify |
| 5 | Virtual MIDI Keyboard | ⏳ | |
@@ -35,7 +35,24 @@
---
## PHASE 2 — FIX EXPORT MIX + LOOP
(chưa bắt đầu)
### Đã làm
- [x] Khảo sát: 3 đường xuất — server `POST /multitrack/mix` (cần `allOnServer && connected`), client `clientSideExport` (OfflineAudioContext, raw buffer không fade), bounce `triggerBounceExport` (realtime capture master bus, trước đây luôn +1.2s tail + blob download). Worker `app/tasks/worker.py` tôn trọng `fade_in_ms/fade_out_ms` từ clip.
- [x] UI ExportModal: checkbox "Xuất loop (không fade in / không fade out)" + input "Số ô nhịp" (1..256, mặc định 4).
- [x] Helper `downloadBlobViaServer(blob, filename)`: POST `/api/v1/audio/upload` → download `${API_BASE_URL}/api/v1/audio/download/{file_id}`; fail → fallback blob download + throw (pattern Phase 1).
- [x] `triggerWavExport`: khi loop → cắt buffer từng track đúng `[loopStart, loopStart+loopDur)` (`loopSecPerBar=(60/bpm)*4`, `loopStart` = selection nếu hợp lệ else playhead), clip `export_loop_clip` startTime 0, luôn đi client path (buffer đã cắt phía client), gọi `clientSideExport(exportTracks, {loopStart, loopDur})`.
- [x] `clientSideExport(activeTracks, loopInfo)`: `durationLimit = loopDur` khi loop (không kéo đuôi); midi cache buffer cắt `subarray(0, loopDur*sr)`; **fix bug**: midi cache không được kéo dài `durationLimit` khi loop (file không dư đuôi); WAV thay blob download bằng `downloadBlobViaServer`.
- [x] `triggerBounceExport`: khi loop → `durationLimit = loopDur` (bỏ +1.2 tail), sau capture cắt cứng `[loopStart, loopStart+loopDur)` frames khỏi stereo buffer, download qua `downloadBlobViaServer`.
- [x] Build: `node build.mjs` OK (2 lần — lần 2 sau fix durationLimit) + PyInstaller rebuild engine + copy `dist/daw_engine/*` sang `src-tauri/target/release/daw_engine/` + bump cache-bust `app.precompiled.js?v=202608211105`.
- [x] Verify chạy thật: engine serve `app.precompiled.js` mới (md5 khớp build local, đủ loop UI/loopActive/downloadBlobViaServer/export_loop_clip), index.html `?v=202608211105`; `/upload` + `/download` roundtrip OK; `POST /multitrack/mix` (server path) SUCCESS → file 2.0s đúng, all samples nonzero (không regression).
- [x] Self-check toán loop (node `tools/_loopcheck.mjs`): độ dài WAV = `loopBars*4*60/bpm` ±0 sample với mọi tổ hợp (120bpm×4, 120×4 target 48k, 90×8 start 1.5s, 140×1), biên đầu/cuối giữ nguyên mẫu (không fade) — PASS.
- [x] Regression cũ: `tools/shm_note_energy.py` (energy 0 — app chưa load instrument, đúng), `tools/test_sf2_ring.py` PASS (energy 12.337, ring decay), `tools/test_vst2_gui.py` PASS (DUNE host + editor VSTGUI, proc alive).
### Tồn tại / cần bổ sung
- Loop với track MIDI: midi cache cắt từ đầu cache (`subarray(0, loopDur*sr)`), chưa offset theo `loopStart` — đúng với thiết kế cache preview (cache bắt đầu từ preview), ghi nhận để Phase 7 review nếu cần.
### Cần xác nhận (review)
- (trống)
## PHASE 3 — FX CHAIN
(chưa bắt đầu)
+112 -27
View File
@@ -11931,6 +11931,22 @@ const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExpor
</div>
<div></div>
</div>
<div className="flex items-center gap-2 pt-1 bg-[#141414] border border-zinc-800 rounded px-2 py-1.5">
<label className="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" checked={!!exportSettings.loop}
onChange={e => setExportSettings(p => ({ ...p, loop: e.target.checked }))}
className="accent-cyan-500 w-3.5 h-3.5" />
<span className="text-xs text-zinc-300">Xuất loop (không fade in / không fade out)</span>
</label>
{exportSettings.loop && (
<label className="flex items-center gap-1 ml-auto">
<span className="text-[7px] text-zinc-500 font-bold uppercase">Số ô nhịp</span>
<input type="number" min="1" max="256" value={exportSettings.loopBars}
onChange={e => setExportSettings(p => ({ ...p, loopBars: Math.max(1, parseInt(e.target.value) || 1) }))}
className="w-16 bg-[#141414] border border-zinc-800 rounded px-1 py-0.5 text-xs text-zinc-300 focus:outline-none" />
</label>
)}
</div>
<div className="grid grid-cols-2 gap-2 pt-1">
<button onClick={onBounce} disabled={isExporting}
title="Bounce realtime — file WAV đầy đủ MIDI + FX Rack + Mastering Chain (chạy lại project thật)"
@@ -17226,7 +17242,9 @@ const App = () => {
format: 'wav',
source: 'project',
quality: '44khz',
channels: 'stereo'
channels: 'stereo',
loop: false,
loopBars: 4
});
const [serverStatus, setServerStatus] = useState('checking...');
const _lan = window.SonicRuntime && window.SonicRuntime.capabilities && window.SonicRuntime.capabilities.lan;
@@ -26057,6 +26075,39 @@ const App = () => {
});
}
// ── Export audio: tải qua server (network download, WebView2 không chặn) ──
// Blob download bị WebView2 chặn sau khi chuyển standalone → upload file
// tạm lên engine local (POST /api/v1/audio/upload) → tải về qua GET
// /api/v1/audio/download/{file_id} (Content-Disposition attachment).
// Nếu upload thất bại (server offline) → fallback blob download cũ.
async function downloadBlobViaServer(blob, filename) {
try {
const fd = new FormData();
fd.append('file', new File([blob], filename, { type: blob.type || 'audio/wav' }));
const up = await fetch(`${API_AUDIO}/upload`, { method: 'POST', body: fd });
if (!up.ok) throw new Error('HTTP ' + up.status);
const data = await up.json();
const a = document.createElement('a');
a.href = `${API_BASE_URL}/api/v1/audio/download/${data.file_id}`;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
return data;
} catch (err) {
// fallback: blob download (một số môi trường vẫn cho phép)
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
throw err;
}
}
const triggerMidiExport = async () => {
const bpmNum = parseFloat(bpm) || 120;
const midiTracks = [];
@@ -26279,9 +26330,33 @@ const App = () => {
return;
}
// LOOP export (BIG_PHASE_3 Phase 2): cắt chính xác vùng loop — bắt đầu từ
// selection (nếu có) hoặc playhead, độ dài = loopBars * secondsPerBar.
// Không fade in/out, không kéo đuôi (cắt cứng tại biên).
let loopStart = 0, loopDur = 0;
if (exportSettings.loop) {
const loopSecPerBar = (60.0 / (parseFloat(bpm) || 120)) * 4;
loopStart = (selectionStart !== null && selectionEnd !== null && selectionEnd > selectionStart) ? selectionStart : currentTime;
loopDur = Math.max(0.1, (exportSettings.loopBars || 4) * loopSecPerBar);
const lctx = getAudioContext();
exportTracks = exportTracks.map(t => {
if (!t.buffer) return null;
const ls = Math.max(0, Math.floor(loopStart * lctx.sampleRate));
const le = Math.min(t.buffer.length, Math.ceil((loopStart + loopDur) * lctx.sampleRate));
if (le <= ls) return null;
const buf = lctx.createBuffer(t.buffer.numberOfChannels, le - ls, t.buffer.sampleRate);
for (let ch = 0; ch < t.buffer.numberOfChannels; ch++) {
buf.copyToChannel(t.buffer.getChannelData(ch).subarray(ls, le), ch);
}
return { ...t, buffer: buf, startTime: 0, clips: [{ id: 'export_loop_clip', buffer: buf, startTime: 0, name: t.name, speed: 1 }] };
}).filter(Boolean);
if (exportTracks.length === 0) { showToast("Vùng loop không có dữ liệu âm thanh.", "warning"); return; }
}
// Check if all active tracks have server file IDs
const allOnServer = exportTracks.every(t => serverFileIdMap[t.id]);
if (allOnServer && serverStatus === 'connected') {
// Loop đã cắt buffer phía client → không đi server path (server chỉ có file gốc)
if (!exportSettings.loop && allOnServer && serverStatus === 'connected') {
// Use server-side export
setIsExporting(true);
showToast("Đang gửi yêu cầu xuất âm thanh đến máy chủ...", "info");
@@ -26348,7 +26423,7 @@ const App = () => {
}
} else {
// Client-side export (existing working code)
clientSideExport(exportTracks);
clientSideExport(exportTracks, { loopStart, loopDur });
}
};
@@ -26366,8 +26441,15 @@ const App = () => {
const allTracks = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : tracks;
// MAIN SESSION end time (items trên track main — sections tính theo bounds,
// không kéo dài theo nội dung SECTION-TAB) — bounce không cắt sớm/cắt thiếu.
let durationLimit = Math.max(1.0, computeMainSessionEndTime(allTracks));
durationLimit += 1.2; // FX/mastering tail
// LOOP export: độ dài = loopBars*secondsPerBar, bắt đầu từ selection/playhead,
// cắt cứng biên — không fade, không kéo tail.
let loopStart = 0, loopDur = 0;
if (exportSettings.loop) {
const loopSecPerBar = (60.0 / (parseFloat(bpm) || 120)) * 4;
loopStart = (selectionStart !== null && selectionEnd !== null && selectionEnd > selectionStart) ? selectionStart : currentTime;
loopDur = Math.max(0.1, (exportSettings.loopBars || 4) * loopSecPerBar);
}
let durationLimit = loopDur || (Math.max(1.0, computeMainSessionEndTime(allTracks)) + 1.2); // FX/mastering tail (chỉ khi không loop)
const captureRate = audioCtx.sampleRate || 44100;
const isFloatBit = exportSettings.bitDepth === 'float';
@@ -26405,10 +26487,16 @@ const App = () => {
// Encode interleaved PCM chunks → WAV
const totalFrames = chunks.reduce((a, c) => a + c.length / 2, 0);
const stereoBuf = new Float32Array(totalFrames * 2);
let stereoBuf = new Float32Array(totalFrames * 2);
let off = 0;
chunks.forEach(c => { stereoBuf.set(c, off); off += c.length; });
const frames = totalFrames;
// LOOP export: cắt cứng [loopStart, loopStart+loopDur) — bỏ phần trước + phần dư tail
if (loopDur > 0) {
const sf = Math.max(0, Math.floor(loopStart * captureRate)) * 2;
const ef = Math.min(stereoBuf.length, Math.floor((loopStart + loopDur) * captureRate) * 2);
if (ef > sf) stereoBuf = stereoBuf.subarray(sf, ef);
}
const frames = stereoBuf.length / 2;
const bytesPerSample = isFloatBit ? 4 : bitDepth / 8;
const headerSize = 44;
const fileSizeBytes = headerSize + frames * bytesPerSample * outChannels;
@@ -26439,12 +26527,7 @@ const App = () => {
}
}
const blob = new Blob([view], { type: 'audio/wav' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `sonicforge-bounce-${captureRate}hz-${bitDepth}bit.wav`;
a.click();
URL.revokeObjectURL(url);
await downloadBlobViaServer(blob, `sonicforge-bounce-${captureRate}hz-${bitDepth}bit.wav`).catch(() => {});
showToast("Bounce hoàn tất — file gồm MIDI + FX Rack + Mastering Chain!", "success");
} catch (err) {
showToast("Lỗi bounce: " + err.message, "error");
@@ -26650,7 +26733,7 @@ const App = () => {
};
input.click();
};
const clientSideExport = async activeTracks => {
const clientSideExport = async (activeTracks, loopInfo) => {
setIsExporting(true);
showToast("Đang trộn âm thanh đa kênh (Offline Mixdown)...", "info");
try {
@@ -26658,11 +26741,14 @@ const App = () => {
const bitDepth = parseInt(exportSettings.bitDepth);
// MAIN SESSION end time (items trên track main — sections theo bounds) —
// offline render không cắt sớm; MIDI cache (preview) giữ max với cache.
let durationLimit = Math.max(0.1, computeMainSessionEndTime(activeTracks));
// LOOP export: độ dài chính xác = loopDur (không kéo đuôi, không fade)
let durationLimit = loopInfo && loopInfo.loopDur ? loopInfo.loopDur : Math.max(0.1, computeMainSessionEndTime(activeTracks));
// Include preview-captured MIDI cache length (soundfont tracks have no clips)
// LOOP: midi buffer đã cắt đúng loopDur → không cho cache kéo dài file
const loopActive = !!(loopInfo && loopInfo.loopDur);
Object.keys(midiCacheRef.current).forEach(id => {
const c = midiCacheRef.current[id];
if (c && c.duration > durationLimit) durationLimit = c.duration;
if (c && !loopActive && c.duration > durationLimit) durationLimit = c.duration;
});
const outChannels = exportSettings.channels === 'mono' ? 1 : 2;
const offlineCtx = new OfflineAudioContext(outChannels, Math.ceil(targetRate * Math.max(0.1, durationLimit)), targetRate);
@@ -26710,8 +26796,14 @@ const App = () => {
if (node.sfEntry) {
const midiBuf = midiCacheToBuffer(t.id);
if (midiBuf) {
let mSrcBuf = midiBuf;
if (loopInfo && loopInfo.loopDur) {
const ln = Math.min(midiBuf.length, Math.max(1, Math.ceil(loopInfo.loopDur * midiBuf.sampleRate)));
mSrcBuf = offlineCtx.createBuffer(midiBuf.numberOfChannels, ln, midiBuf.sampleRate);
for (let mc = 0; mc < midiBuf.numberOfChannels; mc++) mSrcBuf.copyToChannel(midiBuf.getChannelData(mc).subarray(0, ln), mc);
}
const mSrc = offlineCtx.createBufferSource();
mSrc.buffer = midiBuf;
mSrc.buffer = mSrcBuf;
mSrc.connect(node.sfEntry);
mSrc.start(0);
}
@@ -26764,7 +26856,7 @@ const App = () => {
const blob = new Blob([view], {
type: 'audio/wav'
});
const url = URL.createObjectURL(blob);
const wavName = `sonicforge-session-${targetRate}hz-${bitDepth}bit.wav`;
if ((exportSettings.format === 'mp3' || exportSettings.format === 'ogg') && serverStatus === 'connected') {
setIsExporting(true);
showToast("Đang gửi yêu cầu nén định dạng lên máy chủ...", "info");
@@ -26807,23 +26899,16 @@ const App = () => {
}
} catch (transcodeErr) {
showToast(`Lỗi chuyển đổi: ${transcodeErr.message}.`, "warning", "Tải WAV thay thế", () => {
const a = document.createElement('a');
a.href = url;
a.download = `sonicforge-session-${targetRate}hz-${bitDepth}bit.wav`;
a.click();
downloadBlobViaServer(blob, wavName).catch(() => {});
});
}
} else {
if (exportSettings.format !== 'wav') {
showToast("Đang ngoại tuyến. Tải về định dạng WAV thay thế.", "warning");
}
const a = document.createElement('a');
a.href = url;
a.download = `sonicforge-session-${targetRate}hz-${bitDepth}bit.wav`;
a.click();
await downloadBlobViaServer(blob, wavName).catch(() => {});
showToast("Xuất bản âm thanh hoàn tất!", "success");
}
URL.revokeObjectURL(url);
} finally {
// Restore the live master bus + live mastering state after offline render.
masterBus = savedMasterBus;
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -50,7 +50,7 @@
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
<script src="/static/js/app.precompiled.js?v=202608211103" defer></script>
<script src="/static/js/app.precompiled.js?v=202608211105" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style>
:root {