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