diff --git a/assets/js/creations/file.js b/assets/js/creations/file.js index 6cd1f556..b4cd2f78 100755 --- a/assets/js/creations/file.js +++ b/assets/js/creations/file.js @@ -12,7 +12,7 @@ import { import { loadAudioForTrack } from "./pattern/pattern_state.js"; import { renderAll, getSamplePathMap } from "./ui.js"; import { DEFAULT_PAN, DEFAULT_VOLUME, NOTE_LENGTH } from "./config.js"; -import { initializeAudioContext, getMainGainNode } from "./audio.js"; +import { initializeAudioContext, getMainGainNode, getAudioContext } from "./audio.js"; import { DEFAULT_PROJECT_XML, getSecondsPerStep, SAMPLE_SRC } from "./utils.js"; import * as Tone from "https://esm.sh/tone"; import { sendAction } from "./socket.js"; @@ -669,14 +669,15 @@ export async function parseMmpContent(xmlString) { } // -------------------------------------------------------------- -// GERAÇÃO DE ARQUIVO (EXPORT) +// GERAÇÃO DE ARQUIVO (EXPORT) — MMPZ com WAVs “sliceados” // -------------------------------------------------------------- -export function generateMmpFile() { +// ✅ agora é async porque vamos gerar zip + wavs +export async function generateMmpFile() { if (appState.global.originalXmlDoc) { - modifyAndSaveExistingMmp(); + await modifyAndSaveExistingMmp(); } else { - generateNewMmp(); + await generateNewMmp(); } } @@ -806,7 +807,6 @@ function applyPlaylistClipsToXml(xmlDoc) { } } - function createTrackXml(track) { if (!track.patterns || track.patterns.length === 0) return ""; @@ -866,12 +866,12 @@ function createTrackXml(track) { `; } -function modifyAndSaveExistingMmp() { +async function modifyAndSaveExistingMmp() { const content = generateXmlFromState(); downloadFile(content, "projeto_editado.mmp"); } -function generateNewMmp() { +async function generateNewMmp() { const content = generateXmlFromState(); downloadFile(content, "novo_projeto.mmp"); } @@ -888,4 +888,257 @@ function downloadFile(content, fileName) { URL.revokeObjectURL(url); } -export { generateXmlFromState as generateXmlFromStateExported }; +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 xmlDoc = new DOMParser().parseFromString(baseXmlString, "application/xml"); + + // 2) cria zip e injeta samples sliceados + reescreve sampletracks/sampletco + // eslint-disable-next-line no-undef + 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); + + // 4) baixa .mmpz + const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE" }); + downloadBlob(blob, `${baseName}.mmpz`); +} + +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 _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)); +} + +function _encodeWav16(channelData, sampleRate) { + const numChannels = channelData.length; + const length = channelData[0]?.length || 0; + + const bytesPerSample = 2; + const blockAlign = numChannels * bytesPerSample; + const byteRate = sampleRate * blockAlign; + const dataSize = length * blockAlign; + + const buffer = new ArrayBuffer(44 + dataSize); + const view = new DataView(buffer); + + const writeStr = (off, s) => { + for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i)); + }; + + writeStr(0, "RIFF"); + 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.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 + writeStr(36, "data"); + view.setUint32(40, dataSize, true); + + let offset = 44; + for (let i = 0; i < length; i++) { + for (let ch = 0; ch < numChannels; ch++) { + let s = channelData[ch][i] || 0; + s = Math.max(-1, Math.min(1, s)); + view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true); + offset += 2; + } + } + + return buffer; +} + +function _sliceToWavArrayBuffer(audioBuffer, offsetSec, durSec, { volume = 1, pan = 0 } = {}) { + const sr = audioBuffer.sampleRate; + const start = Math.max(0, Math.floor((offsetSec || 0) * sr)); + const end = Math.min(audioBuffer.length, start + Math.floor((durSec || 0) * sr)); + const sliceLen = Math.max(0, end - start); + if (sliceLen <= 0) return null; + + volume = _clamp(volume, 0, 1.5); + pan = _clamp(pan, -1, 1); + + // equal-power pan + const angle = (pan + 1) * (Math.PI / 4); + const gL = Math.cos(angle) * volume; + 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) { + const src = audioBuffer.getChannelData(0).subarray(start, end); + if (outCh === 1) { + for (let i = 0; i < sliceLen; i++) out[0][i] = src[i] * volume; + } else { + for (let i = 0; i < sliceLen; i++) { + out[0][i] = src[i] * gL; + out[1][i] = src[i] * gR; + } + } + } else if (inCh >= 2) { + 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); +} + +async function _ensureClipBuffer(clip) { + if (clip?.buffer) return clip.buffer; + + // fallback: tenta buscar e decodificar do sourcePath + if (!clip?.sourcePath) return null; + + const ctx = getAudioContext(); + const res = await fetch(clip.sourcePath); + if (!res.ok) return null; + + const arr = await res.arrayBuffer(); + const decoded = await ctx.decodeAudioData(arr.slice(0)); + clip.buffer = decoded; + return decoded; +} + +// -------------------- XML: SampleTracks + zip -------------------- + +async function applySampleTracksToXmlAndZip(xmlDoc, zip) { + 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} + 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 + 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 + for (let i = 0; i < tracks.length; i++) { + const lane = tracks[i]; + const laneName = lane?.name || `Áudio ${i + 1}`; + + const trackNode = template + ? template.cloneNode(true) + : (() => { + const t = xmlDoc.createElement("track"); + t.setAttribute("type", "2"); + const st = xmlDoc.createElement("sampletrack"); + st.setAttribute("vol", "100"); + st.setAttribute("pan", "0"); + t.appendChild(st); + return t; + })(); + + trackNode.setAttribute("type", "2"); + trackNode.setAttribute("name", laneName); + + // garante sampletrack com vol/pan neutro (vamos “bakar” no WAV por clipe) + let st = trackNode.querySelector("sampletrack"); + if (!st) { + st = xmlDoc.createElement("sampletrack"); + trackNode.insertBefore(st, trackNode.firstChild); + } + st.setAttribute("vol", "100"); + st.setAttribute("pan", "0"); + + // limpa sampletco anteriores + Array.from(trackNode.querySelectorAll(":scope > sampletco")).forEach(n => n.remove()); + + const laneClips = clips + .filter(c => String(c.trackId) === String(lane.id)) + .slice() + .sort((a, b) => (a.startTimeInSeconds || 0) - (b.startTimeInSeconds || 0)); + + 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 durSec = clip.durationInSeconds || 0; + if (durSec <= 0.0001) continue; + + const wav = _sliceToWavArrayBuffer(buffer, offsetSec, durSec, { + volume: clip.volume ?? 1, + pan: clip.pan ?? 0, + }); + if (!wav) continue; + + const sliceName = + `${_sanitizeFileName(laneName)}__${_sanitizeFileName(clip.name || "clip")}` + + `__${_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)); + + 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("src", zipPath); + + trackNode.appendChild(tco); + } + + songTc.appendChild(trackNode); + } +} + +export { generateXmlFromState as generateXmlFromStateExported }; \ No newline at end of file