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:
+19
-2
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user