diff --git a/assets/js/creations/main.js b/assets/js/creations/main.js index 8491cc0e..959038c5 100755 --- a/assets/js/creations/main.js +++ b/assets/js/creations/main.js @@ -1,5 +1,5 @@ // js/main.js -import { appState, loadStateFromSession } from "./state.js"; // <--- Importe loadStateFromSession +import { appState, loadStateFromSession } from "./state.js"; import { updateTransportLoop, restartAudioEditorIfPlaying, @@ -17,7 +17,7 @@ import { adjustValue, enforceNumericInput, DEFAULT_PROJECT_XML, secondsToSongTic import { ZOOM_LEVELS } from "./config.js"; import { loadProjectFromServer } from "./file.js"; import { sendAction, joinRoom, setUserName } from "./socket.js"; -import { renderActivePatternToBlob } from "./pattern/pattern_audio.js"; +import { renderActivePatternToBlob, renderProjectAndDownload } from "./pattern/pattern_audio.js"; import { showToast } from "./ui.js"; import { toggleRecording } from "./recording.js" import * as Tone from "https://esm.sh/tone"; // Adicione o Tone aqui se não estiver global @@ -127,6 +127,10 @@ document.addEventListener("DOMContentLoaded", () => { const deleteClipBtn = document.getElementById("delete-clip"); const addPatternBtn = document.getElementById("add-pattern-btn"); const removePatternBtn = document.getElementById("remove-pattern-btn"); + const downloadPackageBtn = document.getElementById("download-package-btn"); + + // Download projeto + downloadPackageBtn?.addEventListener("click", generateMmpFile); // Configuração do botão de Gravação const recordBtn = document.getElementById('record-btn'); @@ -365,18 +369,13 @@ document.addEventListener("DOMContentLoaded", () => { if (file) handleFileLoad(file).then(() => closeOpenProjectModal()); }); uploadSampleBtn?.addEventListener("click", () => sampleFileInput?.click()); - saveMmpBtn?.addEventListener("click", generateMmpFile); + saveMmpBtn?.addEventListener("click", renderProjectAndDownload); addInstrumentBtn?.addEventListener("click", () => { initializeAudioContext(); sendAction({ type: "ADD_TRACK" }); }); - removeInstrumentBtn?.addEventListener("click", () => { - initializeAudioContext(); - sendAction({ type: "REMOVE_LAST_TRACK" }); - }); - // 👇 ATUALIZE ESTE LISTENER removeInstrumentBtn?.addEventListener("click", () => { initializeAudioContext(); diff --git a/assets/js/creations/pattern/pattern_audio.js b/assets/js/creations/pattern/pattern_audio.js index d4dd5a73..c479d21c 100755 --- a/assets/js/creations/pattern/pattern_audio.js +++ b/assets/js/creations/pattern/pattern_audio.js @@ -913,3 +913,379 @@ export function stopSongPatternPlaybackOnTransport() { } catch {} songPatternScheduleId = null; } + +// ========================================================================= +// Renderizar o PROJETO inteiro (Playlist patterns + Audio Timeline) para WAV +// ========================================================================= + +function _n(v, def = 0) { + const x = Number(v); + return Number.isFinite(x) ? x : def; +} + +function _secondsPerStep(bpm) { + return 60 / (bpm * 4); // 1/16 +} + +function _ticksToSeconds(ticks, stepSec) { + // LMMS: 12 ticks por 1/16 + return (_n(ticks, 0) / 12) * stepSec; +} + +function _dbFromVol(vol, muted) { + const v = clamp(vol ?? 1, 0, MAX_VOL); + if (muted || v <= 0) return -Infinity; + return Tone.gainToDb(v); +} + +function _sanitizeFileName(name) { + return String(name || "projeto") + .trim() + .replace(/[<>:"/\\|?*\x00-\x1F]+/g, "_") + .replace(/\s+/g, "_") + .slice(0, 80); +} + +function _patternLengthTicks(patt) { + const T = 12; + + let byNotes = 0; + if (Array.isArray(patt?.notes) && patt.notes.length) { + for (const n of patt.notes) { + const pos = _n(n.pos, 0); + const rawLen = _n(n.len, T); + const len = rawLen < 0 ? T : Math.max(rawLen, T); + byNotes = Math.max(byNotes, pos + len); + } + } + + const bySteps = (patt?.steps?.length || 0) * T; + + return Math.max(byNotes, bySteps, T); +} + +function _collectArrangements() { + const basslines = (appState.pattern?.tracks || []).filter(t => t.type === "bassline"); + const arr = []; + + for (const b of basslines) { + const clips = (b.playlist_clips || []).filter(c => _n(c.len, 0) > 0); + if (clips.length) arr.push(b); + } + + // Fallback: se não houver playlist_clips, renderiza o pattern ativo por N compassos + if (arr.length === 0) { + const bars = parseInt(document.getElementById("bars-input")?.value, 10) || 1; + const activePi = _n(appState.pattern?.activePatternIndex, 0); + arr.push({ + patternIndex: activePi, + volume: 1, + pan: 0, + muted: false, + isMuted: false, + playlist_clips: [{ pos: 0, len: bars * 192 }], // 192 ticks por compasso (4/4) + }); + } + + return arr; +} + +function _projectDurationSeconds(bpm) { + const stepSec = _secondsPerStep(bpm); + + // 1) fim vindo da playlist (ticks) + let maxTick = 0; + for (const b of _collectArrangements()) { + for (const c of (b.playlist_clips || [])) { + const end = _n(c.pos, 0) + _n(c.len, 0); + if (end > maxTick) maxTick = end; + } + } + const playlistEndSec = _ticksToSeconds(maxTick, stepSec); + + // 2) fim vindo do editor de áudio (segundos) + let audioEndSec = 0; + for (const c of (appState.audio?.clips || [])) { + const end = _n(c.startTimeInSeconds, 0) + _n(c.durationInSeconds, 0); + if (end > audioEndSec) audioEndSec = end; + } + + return Math.max(playlistEndSec, audioEndSec, stepSec); +} + +async function _fetchAudioBuffer(url, audioCtx) { + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const arr = await res.arrayBuffer(); + // slice(0) evita problemas em alguns browsers com ArrayBuffer "detached" + return await audioCtx.decodeAudioData(arr.slice(0)); + } catch (e) { + console.warn("[Render] Falha ao carregar áudio:", url, e); + return null; + } +} + +function _playOneShot(buffer, time, dest, stopTime = null, playbackRate = 1) { + const p = new Tone.Player(buffer); + p.playbackRate = playbackRate; + p.connect(dest); + p.start(time); + + if (stopTime != null && stopTime > time) { + try { p.stop(stopTime); } catch {} + } + + p.onstop = () => { + try { p.dispose(); } catch {} + }; +} + +export async function renderProjectToBlob({ tailSec = 0.25 } = {}) { + initializeAudioContext(); + + const bpm = parseInt(document.getElementById("bpm-input")?.value, 10) || 120; + const stepSec = _secondsPerStep(bpm); + const duration = _projectDurationSeconds(bpm) + Math.max(0, Number(tailSec) || 0); + + const buffer = await Tone.Offline(async ({ transport }) => { + transport.bpm.value = bpm; + + const rawCtx = Tone.getContext().rawContext; + const master = new Tone.Gain(1).toDestination(); + + // ------------------------------------------------------------ + // CACHE de buffers (para não baixar/decodificar repetido) + // ------------------------------------------------------------ + const bufferCache = new Map(); + const getBuf = async (url) => { + const key = String(url || ""); + if (!key) return null; + if (bufferCache.has(key)) return bufferCache.get(key); + const b = await _fetchAudioBuffer(key, rawCtx); + bufferCache.set(key, b); + return b; + }; + + // ------------------------------------------------------------ + // (A) Render do AUDIO TIMELINE (appState.audio.clips) + // ------------------------------------------------------------ + for (const clip of (appState.audio?.clips || [])) { + const muted = !!clip.muted || (_n(clip.volume, 1) <= 0); + if (muted) continue; + + const url = clip.sourcePath || clip.src || clip.url; + if (!url) continue; + + const buf = await getBuf(url); + if (!buf) continue; + + const start = _n(clip.startTimeInSeconds, 0); + const dur = _n(clip.durationInSeconds, 0); + if (dur <= 0.0001) continue; + + const offset = Math.max(0, _n(clip.offset, 0)); + const vol = clamp(clip.volume ?? 1, 0, MAX_VOL); + const pan = clamp(clip.pan ?? 0, -1, 1); + + const volNode = new Tone.Volume(vol <= 0 ? -Infinity : Tone.gainToDb(vol)); + const panNode = new Tone.Panner(pan); + + volNode.connect(panNode); + panNode.connect(master); + + const player = new Tone.Player(buf); + player.connect(volNode); + + player.start(start, offset, dur); + player.stop(start + dur + 0.01); + + player.onstop = () => { + try { player.dispose(); } catch {} + try { volNode.dispose(); } catch {} + try { panNode.dispose(); } catch {} + }; + } + + // ------------------------------------------------------------ + // (B) Render da PLAYLIST (patterns via bassline.playlist_clips) + // ------------------------------------------------------------ + const arrangements = _collectArrangements(); + const instrumentTracks = (appState.pattern?.tracks || []).filter(t => t.type !== "bassline"); + + // mix por (trackId + patternIndex) no OFFLINE: + // instVol -> instPan -> pattVol -> pattPan -> master + const mixCache = new Map(); + const pluginCache = new Map(); + const samplerBufCache = new Map(); + + const getMix = (track, bassline) => { + const pi = _n(bassline.patternIndex, 0); + const key = `${track.id}::${pi}`; + if (mixCache.has(key)) return mixCache.get(key); + + const instMuted = !!(track.isMuted || track.muted) || clamp(track.volume ?? 1, 0, MAX_VOL) <= 0; + const pattMuted = !!(bassline.isMuted || bassline.muted) || clamp(bassline.volume ?? 1, 0, MAX_VOL) <= 0; + + const instVol = new Tone.Volume(_dbFromVol(track.volume ?? 1, instMuted)); + const instPan = new Tone.Panner(clamp(track.pan ?? 0, -1, 1)); + const pattVol = new Tone.Volume(_dbFromVol(bassline.volume ?? 1, pattMuted)); + const pattPan = new Tone.Panner(clamp(bassline.pan ?? 0, -1, 1)); + + instVol.connect(instPan); + instPan.connect(pattVol); + pattVol.connect(pattPan); + pattPan.connect(master); + + const m = { instVol, instPan, pattVol, pattPan }; + mixCache.set(key, m); + return m; + }; + + const getPluginInst = (track, bassline, mix) => { + const pi = _n(bassline.patternIndex, 0); + const key = `${track.id}::${pi}`; + if (pluginCache.has(key)) return pluginCache.get(key); + + const plugKey = _getPluginKey(track); + const Cls = PLUGIN_CLASSES[plugKey]; + if (!Cls) { + console.warn("[Render] Plugin não encontrado:", plugKey, "track:", track.name); + pluginCache.set(key, null); + return null; + } + + const inst = new Cls(null, track.params || track.pluginData || {}); + inst.connect(mix.instVol); + + pluginCache.set(key, inst); + return inst; + }; + + const getSamplerBuf = async (track) => { + const key = String(track.id); + if (samplerBufCache.has(key)) return samplerBufCache.get(key); + + const url = track.samplePath; + if (!url) { + samplerBufCache.set(key, null); + return null; + } + + const b = await getBuf(url); + samplerBufCache.set(key, b); + return b; + }; + + for (const b of arrangements) { + const pi = _n(b.patternIndex, 0); + + const pattMuted = !!(b.isMuted || b.muted) || clamp(b.volume ?? 1, 0, MAX_VOL) <= 0; + if (pattMuted) continue; + + const clips = (b.playlist_clips || []).filter(c => _n(c.len, 0) > 0); + if (!clips.length) continue; + + for (const clip of clips) { + const clipStartTick = _n(clip.pos, 0); + const clipEndTick = clipStartTick + _n(clip.len, 0); + const clipEndSec = _ticksToSeconds(clipEndTick, stepSec); + + for (const track of instrumentTracks) { + const instMuted = !!(track.isMuted || track.muted) || clamp(track.volume ?? 1, 0, MAX_VOL) <= 0; + if (instMuted) continue; + + const patt = track.patterns?.[pi]; + if (!patt) continue; + + const pattLenTicks = _patternLengthTicks(patt); + const mix = getMix(track, b); + + // prepara recursos do track + let pluginInst = null; + let samplerBuf = null; + if (track.type === "plugin") pluginInst = getPluginInst(track, b, mix); + if (track.type === "sampler") samplerBuf = await getSamplerBuf(track); + + // --- Piano roll (notes) --- + if (Array.isArray(patt.notes) && patt.notes.length > 0) { + for (const n of patt.notes) { + const notePos = _n(n.pos, 0); + const rawLen = _n(n.len, 12); + const lenTicks = rawLen < 0 ? 12 : Math.max(rawLen, 12); + const vel = _n(n.vol, 100) / 100; + const midi = _n(n.key, 60); + + for (let startTick = clipStartTick + notePos; startTick < clipEndTick; startTick += pattLenTicks) { + const tSec = _ticksToSeconds(startTick, stepSec); + + let durSec = _ticksToSeconds(lenTicks, stepSec); + durSec = Math.min(durSec, Math.max(0, clipEndSec - tSec)); + if (durSec <= 0.0001) continue; + + if (track.type === "plugin" && pluginInst) { + const freq = Tone.Frequency(midi, "midi").toFrequency(); + try { pluginInst.triggerAttackRelease(freq, durSec, tSec, vel); } catch {} + } else if (track.type === "sampler" && samplerBuf) { + const base = _n(track.baseNote, 60); + const rate = Math.pow(2, (midi - base) / 12); + _playOneShot(samplerBuf, tSec, mix.instVol, tSec + durSec, rate); + } + } + } + } + + // --- Step sequencer (steps) --- + else if (Array.isArray(patt.steps) && patt.steps.length > 0) { + for (let s = 0; s < patt.steps.length; s++) { + if (!patt.steps[s]) continue; + + const stepTick = s * 12; + + for (let startTick = clipStartTick + stepTick; startTick < clipEndTick; startTick += pattLenTicks) { + const tSec = _ticksToSeconds(startTick, stepSec); + + if (track.type === "plugin" && pluginInst) { + try { pluginInst.triggerAttackRelease("C5", stepSec, tSec); } catch {} + } else if (track.type === "sampler" && samplerBuf) { + // one-shot (sem transposição) + _playOneShot(samplerBuf, tSec, mix.instVol, clipEndSec, 1); + } + } + } + } + } + } + } + + transport.start(); + }, duration); + + return bufferToWave(buffer); +} + +export async function renderProjectAndDownload() { + try { + const blob = await renderProjectToBlob({ tailSec: 0.35 }); + + const projectName = + appState.global?.currentBeatBasslineName || + appState.global?.projectName || + "projeto"; + + const fileName = `${_sanitizeFileName(projectName)}.wav`; + + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + a.remove(); + + setTimeout(() => URL.revokeObjectURL(url), 1500); + } catch (e) { + console.error("Erro ao renderizar projeto:", e); + alert("Erro ao renderizar projeto. Veja o console para detalhes."); + } +} diff --git a/creation.html b/creation.html index 3ae62f52..950ce433 100755 --- a/creation.html +++ b/creation.html @@ -144,12 +144,11 @@ > Salvar projeto - Baixar ZIP + Renderizar -