diff --git a/assets/js/creations/file.js b/assets/js/creations/file.js index b4cd2f78..f4cd9354 100755 --- a/assets/js/creations/file.js +++ b/assets/js/creations/file.js @@ -867,13 +867,13 @@ function createTrackXml(track) { } async function modifyAndSaveExistingMmp() { - const content = generateXmlFromState(); - downloadFile(content, "projeto_editado.mmp"); + console.log("EXPORT MMPZ"); + await generateAndDownloadMmpz("projeto_editado"); } async function generateNewMmp() { - const content = generateXmlFromState(); - downloadFile(content, "novo_projeto.mmp"); + console.log("EXPORT MMPZ"); + await generateAndDownloadMmpz("novo_projeto"); } function downloadFile(content, fileName) { @@ -891,22 +891,17 @@ function downloadFile(content, fileName) { async function generateAndDownloadMmpz(baseName) { initializeAudioContext(); - // 1) gera XML base (patterns OK) — hoje isso já funciona no seu projeto - const baseXmlString = generateXmlFromState(); // já inclui applyPlaylistClipsToXml :contentReference[oaicite:3]{index=3} + const baseXmlString = generateXmlFromState(); // seu método atual (patterns OK) const xmlDoc = new DOMParser().parseFromString(baseXmlString, "application/xml"); - // 2) cria zip e injeta samples sliceados + reescreve sampletracks/sampletco - // eslint-disable-next-line no-undef + // JSZip precisa existir (você já usa pra abrir mmpz) const zip = new JSZip(); await applySampleTracksToXmlAndZip(xmlDoc, zip); - // 3) grava o .mmp dentro do zip const finalXml = new XMLSerializer().serializeToString(xmlDoc); - const mmpName = `${baseName}.mmp`; - zip.file(mmpName, finalXml); + zip.file(`${baseName}.mmp`, finalXml); - // 4) baixa .mmpz const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE" }); downloadBlob(blob, `${baseName}.mmpz`); } @@ -922,17 +917,19 @@ function downloadBlob(blob, fileName) { URL.revokeObjectURL(url); } -// -------------------- WAV helpers -------------------- - -function _sanitizeFileName(name) { - return String(name || "clip") - .normalize("NFKD") - .replace(/[^\w\-\.]+/g, "_") - .replace(/_+/g, "_") - .replace(/^_+|_+$/g, "") - .slice(0, 80) || "clip"; +function downloadBlob(blob, fileName) { + 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); } +// -------------------- WAV helpers -------------------- + function _clamp(n, a, b) { const x = Number(n); if (!Number.isFinite(x)) return a; @@ -959,13 +956,13 @@ function _encodeWav16(channelData, sampleRate) { view.setUint32(4, 36 + dataSize, true); writeStr(8, "WAVE"); writeStr(12, "fmt "); - view.setUint32(16, 16, true); // PCM - view.setUint16(20, 1, true); // format = 1 + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); view.setUint16(22, numChannels, true); view.setUint32(24, sampleRate, true); view.setUint32(28, byteRate, true); view.setUint16(32, blockAlign, true); - view.setUint16(34, 16, true); // bits + view.setUint16(34, 16, true); writeStr(36, "data"); view.setUint32(40, dataSize, true); @@ -978,7 +975,6 @@ function _encodeWav16(channelData, sampleRate) { offset += 2; } } - return buffer; } @@ -998,10 +994,7 @@ function _sliceToWavArrayBuffer(audioBuffer, offsetSec, durSec, { volume = 1, pa const gR = Math.sin(angle) * volume; const inCh = audioBuffer.numberOfChannels || 1; - - // se for mono e pan != 0, gera estéreo pra “congelar” a pan const outCh = (inCh === 1 && Math.abs(pan) > 1e-6) ? 2 : inCh; - const out = new Array(outCh).fill(0).map(() => new Float32Array(sliceLen)); if (inCh === 1) { @@ -1014,23 +1007,32 @@ function _sliceToWavArrayBuffer(audioBuffer, offsetSec, durSec, { volume = 1, pa out[1][i] = src[i] * gR; } } - } else if (inCh >= 2) { + } else { const L = audioBuffer.getChannelData(0).subarray(start, end); const R = audioBuffer.getChannelData(1).subarray(start, end); for (let i = 0; i < sliceLen; i++) { out[0][i] = L[i] * gL; out[1][i] = R[i] * gR; } - // se tiver mais canais, copia/atenua sem pan (raro) - for (let ch = 2; ch < outCh; ch++) { - const C = audioBuffer.getChannelData(ch).subarray(start, end); - for (let i = 0; i < sliceLen; i++) out[ch][i] = C[i] * volume; - } } return _encodeWav16(out, sr); } +function _makeSilenceWav(sampleRate = 44100, seconds = 0.1) { + const len = Math.max(1, Math.floor(sampleRate * seconds)); + const ch = [new Float32Array(len)]; + return _encodeWav16(ch, sampleRate); +} + +function _ensureSilenceInZip(zip) { + if (__exportCache.silencePath) return __exportCache.silencePath; + const path = "samples/__silence.wav"; + zip.file(path, _makeSilenceWav(44100, 0.1)); + __exportCache.silencePath = path; + return path; +} + async function _ensureClipBuffer(clip) { if (clip?.buffer) return clip.buffer; @@ -1047,24 +1049,74 @@ async function _ensureClipBuffer(clip) { return decoded; } +const __exportCache = { + decodedBySrc: new Map(), // src -> AudioBuffer + rawBySrc: new Map(), // src -> ArrayBuffer (wav original) + sliceByKey: new Map(), // key -> { path } + silencePath: null, +}; + +function _resetExportCache() { + __exportCache.decodedBySrc.clear(); + __exportCache.rawBySrc.clear(); + __exportCache.sliceByKey.clear(); + __exportCache.silencePath = null; +} + +function _sanitizeFileName(name) { + return String(name || "clip") + .normalize("NFKD") + .replace(/[^\w\-\.]+/g, "_") + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 80) || "clip"; +} + +function _clamp(n, a, b) { + const x = Number(n); + if (!Number.isFinite(x)) return a; + return Math.max(a, Math.min(b, x)); +} + +async function _fetchArrayBufferCached(url) { + if (__exportCache.rawBySrc.has(url)) return __exportCache.rawBySrc.get(url); + const res = await fetch(url); + if (!res.ok) throw new Error(`Falha ao buscar áudio: ${url}`); + const arr = await res.arrayBuffer(); + __exportCache.rawBySrc.set(url, arr); + return arr; +} + +async function _decodeAudioBufferCached(url) { + if (__exportCache.decodedBySrc.has(url)) return __exportCache.decodedBySrc.get(url); + const ctx = getAudioContext(); + const arr = await _fetchArrayBufferCached(url); + const decoded = await ctx.decodeAudioData(arr.slice(0)); + __exportCache.decodedBySrc.set(url, decoded); + return decoded; +} + // -------------------- XML: SampleTracks + zip -------------------- async function applySampleTracksToXmlAndZip(xmlDoc, zip) { + _resetExportCache(); + const songTc = xmlDoc.querySelector("song > trackcontainer"); if (!songTc) return; - // sampletracks no LMMS são track[type="2"] e cada clipe é :contentReference[oaicite:4]{index=4} + // template se existir const existingSample = songTc.querySelector(':scope > track[type="2"]'); const template = existingSample ? existingSample.cloneNode(true) : null; - // remove todas as sample tracks existentes e recria pelo estado atual + // remove todas as sample tracks antigas Array.from(songTc.querySelectorAll(':scope > track[type="2"]')).forEach(n => n.remove()); const secondsPerStep = getSecondsPerStep(); + const tracks = appState.audio?.tracks || []; const clips = appState.audio?.clips || []; - // recria track por lane + // agrupa clips por lane for (let i = 0; i < tracks.length; i++) { const lane = tracks[i]; const laneName = lane?.name || `Áudio ${i + 1}`; @@ -1084,7 +1136,7 @@ async function applySampleTracksToXmlAndZip(xmlDoc, zip) { trackNode.setAttribute("type", "2"); trackNode.setAttribute("name", laneName); - // garante sampletrack com vol/pan neutro (vamos “bakar” no WAV por clipe) + // sampletrack neutro (mix “baked” no wav) let st = trackNode.querySelector("sampletrack"); if (!st) { st = xmlDoc.createElement("sampletrack"); @@ -1093,7 +1145,6 @@ async function applySampleTracksToXmlAndZip(xmlDoc, zip) { st.setAttribute("vol", "100"); st.setAttribute("pan", "0"); - // limpa sampletco anteriores Array.from(trackNode.querySelectorAll(":scope > sampletco")).forEach(n => n.remove()); const laneClips = clips @@ -1104,36 +1155,97 @@ async function applySampleTracksToXmlAndZip(xmlDoc, zip) { for (let cidx = 0; cidx < laneClips.length; cidx++) { const clip = laneClips[cidx]; - const buffer = await _ensureClipBuffer(clip); - if (!buffer) continue; - - const offsetSec = clip.offset || 0; + const startSec = clip.startTimeInSeconds || 0; const durSec = clip.durationInSeconds || 0; if (durSec <= 0.0001) continue; - const wav = _sliceToWavArrayBuffer(buffer, offsetSec, durSec, { - volume: clip.volume ?? 1, - pan: clip.pan ?? 0, - }); + const vol = clip.volume ?? 1; + const pan = clip.pan ?? 0; + const muted = !!clip.muted || vol === 0; + + const posTicks = Math.round((startSec / secondsPerStep) * 12); + const lenTicks = Math.max(1, Math.round((durSec / secondsPerStep) * 12)); + + // ----------------------------------------- + // ✅ OTIMIZAÇÃO 1: mutado -> usa silence.wav + // ----------------------------------------- + if (muted) { + const silPath = _ensureSilenceInZip(zip); + const tco = xmlDoc.createElement("sampletco"); + tco.setAttribute("pos", String(Math.max(0, posTicks))); + tco.setAttribute("len", String(lenTicks)); + tco.setAttribute("muted", "0"); // já é silêncio + tco.setAttribute("src", silPath); + trackNode.appendChild(tco); + continue; + } + + // precisamos do sourcePath (arquivo original) + const srcUrl = clip.sourcePath || clip.src || clip.url; + if (!srcUrl) continue; + + const offsetSec = clip.offset || 0; + + // ----------------------------------------- + // ✅ OTIMIZAÇÃO 2: sem offset + sem bake mix + // -> usa arquivo original no zip (sem decode/encode) + // ----------------------------------------- + const needsOffset = Math.abs(offsetSec) > 1e-6; + const needsBake = Math.abs((vol ?? 1) - 1) > 1e-6 || Math.abs((pan ?? 0) - 0) > 1e-6; + + if (!needsOffset && !needsBake) { + const raw = await _fetchArrayBufferCached(srcUrl); + const name = + `${_sanitizeFileName(laneName)}__${_sanitizeFileName(clip.name || "clip")}` + + `__raw__${_sanitizeFileName(clip.id || String(cidx))}.wav`; + const zipPath = `samples/${name}`; + + // evita regravar o mesmo raw várias vezes + if (!zip.file(zipPath)) zip.file(zipPath, raw); + + const tco = xmlDoc.createElement("sampletco"); + tco.setAttribute("pos", String(Math.max(0, posTicks))); + tco.setAttribute("len", String(lenTicks)); + tco.setAttribute("muted", "0"); + tco.setAttribute("src", zipPath); + trackNode.appendChild(tco); + continue; + } + + // ----------------------------------------- + // ✅ OTIMIZAÇÃO 3: cache de slice idêntico + // ----------------------------------------- + const sliceKey = `${srcUrl}__o=${offsetSec.toFixed(6)}__d=${durSec.toFixed(6)}__v=${Number(vol).toFixed(6)}__p=${Number(pan).toFixed(6)}`; + const cached = __exportCache.sliceByKey.get(sliceKey); + if (cached) { + const tco = xmlDoc.createElement("sampletco"); + tco.setAttribute("pos", String(Math.max(0, posTicks))); + tco.setAttribute("len", String(lenTicks)); + tco.setAttribute("muted", "0"); + tco.setAttribute("src", cached.path); + trackNode.appendChild(tco); + continue; + } + + // decode uma vez por srcUrl + const buffer = await _decodeAudioBufferCached(srcUrl); + + const wav = _sliceToWavArrayBuffer(buffer, offsetSec, durSec, { volume: vol, pan }); if (!wav) continue; const sliceName = `${_sanitizeFileName(laneName)}__${_sanitizeFileName(clip.name || "clip")}` + - `__${_sanitizeFileName(clip.id || String(cidx))}.wav`; - + `__slice__${_sanitizeFileName(clip.id || String(cidx))}.wav`; const zipPath = `samples/${sliceName}`; - zip.file(zipPath, wav); - // converte segundos -> ticks LMMS (12 ticks por step), inverso do seu parser :contentReference[oaicite:5]{index=5} - const posTicks = Math.round(((clip.startTimeInSeconds || 0) / secondsPerStep) * 12); - const lenTicks = Math.max(1, Math.round((durSec / secondsPerStep) * 12)); + zip.file(zipPath, wav); + __exportCache.sliceByKey.set(sliceKey, { path: zipPath }); const tco = xmlDoc.createElement("sampletco"); tco.setAttribute("pos", String(Math.max(0, posTicks))); tco.setAttribute("len", String(lenTicks)); - tco.setAttribute("muted", String((clip.volume === 0 || clip.muted) ? 1 : 0)); + tco.setAttribute("muted", "0"); tco.setAttribute("src", zipPath); - trackNode.appendChild(tco); } @@ -1141,4 +1253,5 @@ async function applySampleTracksToXmlAndZip(xmlDoc, zip) { } } + export { generateXmlFromState as generateXmlFromStateExported }; \ No newline at end of file