diff --git a/assets/js/creations/audio/audio_audio.js b/assets/js/creations/audio/audio_audio.js index a92d9807..9e306f0f 100644 --- a/assets/js/creations/audio/audio_audio.js +++ b/assets/js/creations/audio/audio_audio.js @@ -1,7 +1,15 @@ // js/audio/audio_audio.js import { appState } from "../state.js"; -import { updateAudioEditorUI, updatePlayheadVisual, resetPlayheadVisual } from "./audio_ui.js"; -import { initializeAudioContext, getAudioContext, getMainGainNode } from "../audio.js"; +import { + updateAudioEditorUI, + updatePlayheadVisual, + resetPlayheadVisual, +} from "./audio_ui.js"; +import { + initializeAudioContext, + getAudioContext, + getMainGainNode, +} from "../audio.js"; import { getPixelsPerSecond } from "../utils.js"; // 🔊 ADIÇÃO: usar a MESMA instância do Tone que o projeto usa import * as Tone from "https://esm.sh/tone"; @@ -17,19 +25,19 @@ let schedulerIntervalId = null; let animationFrameId = null; // Sincronização de Tempo -let startTime = 0; +let startTime = 0; // (seek/logical ficam em appState.audio) // Configurações de Loop let isLoopActive = false; let loopStartTimeSec = 0; -let loopEndTimeSec = 8; +let loopEndTimeSec = 8; // estado runtime const runtimeClipState = new Map(); // ⚠️ agora armazenamos Tone.Player em vez de BufferSource const scheduledNodes = new Map(); // eventId -> { player, clipId } -let nextEventId = 0; +let nextEventId = 0; const callbacks = { onClipScheduled: null, @@ -41,9 +49,15 @@ function _getBpm() { const bpmInput = document.getElementById("bpm-input"); return parseFloat(bpmInput.value) || 120; } -function _getSecondsPerBeat() { return 60.0 / _getBpm(); } -function _convertBeatToSeconds(beat) { return beat * _getSecondsPerBeat(); } -function _convertSecondsToBeat(seconds) { return seconds / _getSecondsPerBeat(); } +function _getSecondsPerBeat() { + return 60.0 / _getBpm(); +} +function _convertBeatToSeconds(beat) { + return beat * _getSecondsPerBeat(); +} +function _convertSecondsToBeat(seconds) { + return seconds / _getSecondsPerBeat(); +} // garante um único contexto — o rawContext do Tone function _initContext() { @@ -75,8 +89,14 @@ function _scheduleClip(clip, absolutePlayTime, durationSec) { if (!toneBuf) return; // cadeia de ganho/pan por clipe (se já tiver no estado, use; aqui garantimos) - const gain = clip.gainNode instanceof Tone.Gain ? clip.gainNode : new Tone.Gain(clip.volume ?? 1); - const pan = clip.pannerNode instanceof Tone.Panner ? clip.pannerNode : new Tone.Panner(clip.pan ?? 0); + const gain = + clip.gainNode instanceof Tone.Gain + ? clip.gainNode + : new Tone.Gain(clip.volume ?? 1); + const pan = + clip.pannerNode instanceof Tone.Panner + ? clip.pannerNode + : new Tone.Panner(clip.pan ?? 0); // conecta no destino principal (é um ToneAudioNode) try { @@ -91,19 +111,35 @@ function _scheduleClip(clip, absolutePlayTime, durationSec) { const player = new Tone.Player(toneBuf).sync().connect(gain); // aplica pitch como rate (semitons → rate) - const rate = (clip.pitch && clip.pitch !== 0) ? Math.pow(2, clip.pitch / 12) : 1; + const rate = + clip.pitch && clip.pitch !== 0 ? Math.pow(2, clip.pitch / 12) : 1; player.playbackRate = rate; // calculamos o "when" no tempo do Transport: // absolutePlayTime é em audioCtx.currentTime; o "zero" lógico foi quando demos play: // logical = (now - startTime) + seek; => occurrence = (absolutePlayTime - startTime) + seek - const occurrenceInTransportSec = (absolutePlayTime - startTime) + (appState.audio.audioEditorSeekTime || 0); + const occurrenceInTransportSec = + absolutePlayTime - startTime + (appState.audio.audioEditorSeekTime || 0); const offset = clip.offsetInSeconds ?? clip.offset ?? 0; const dur = durationSec ?? toneBuf.duration; - // agenda - player.start(occurrenceInTransportSec, offset, dur); + // --- INÍCIO DA CORREÇÃO (BUG: RangeError) --- + // O log de erro (RangeError: Value must be within [0, Infinity]) + // indica que um destes valores é um número negativo minúsculo + // (um bug de precisão de ponto flutuante). + // Usamos Math.max(0, ...) para "clampar" os valores e garantir + // que nunca sejam negativos. + + const safeOccurrence = Math.max(0, occurrenceInTransportSec); + const safeOffset = Math.max(0, offset); + // Duração pode ser 'undefined', mas se existir, não pode ser negativa + const safeDur = + dur === undefined || dur === null ? undefined : Math.max(0, dur); + // --- FIM DA CORREÇÃO --- + + // agenda (agora usando os valores seguros) + player.start(safeOccurrence, safeOffset, safeDur); const eventId = nextEventId++; scheduledNodes.set(eventId, { player, clipId: clip.id }); @@ -115,17 +151,21 @@ function _scheduleClip(clip, absolutePlayTime, durationSec) { // quando parar naturalmente, limpamos runtime player.onstop = () => { _handleClipEnd(eventId, clip.id); - try { player.unsync(); } catch {} - try { player.dispose(); } catch {} + try { + player.unsync(); + } catch {} + try { + player.dispose(); + } catch {} }; } function _handleClipEnd(eventId, clipId) { scheduledNodes.delete(eventId); - runtimeClipState.delete(clipId); + runtimeClipState.delete(clipId); if (callbacks.onClipPlayed) { - const clip = appState.audio.clips.find(c => c.id == clipId); + const clip = appState.audio.clips.find((c) => c.id == clipId); if (clip) callbacks.onClipPlayed(clip); } } @@ -134,7 +174,8 @@ function _schedulerTick() { if (!isPlaying || !audioCtx) return; const now = audioCtx.currentTime; - const logicalTime = (now - startTime) + (appState.audio.audioEditorSeekTime || 0); + const logicalTime = + now - startTime + (appState.audio.audioEditorSeekTime || 0); const scheduleWindowStartSec = logicalTime; const scheduleWindowEndSec = logicalTime + SCHEDULE_AHEAD_TIME_SEC; @@ -145,19 +186,32 @@ function _schedulerTick() { const clipStartTimeSec = clip.startTimeInSeconds; const clipDurationSec = clip.durationInSeconds; - if (typeof clipStartTimeSec === 'undefined' || typeof clipDurationSec === 'undefined') continue; + if ( + typeof clipStartTimeSec === "undefined" || + typeof clipDurationSec === "undefined" + ) + continue; let occurrenceStartTimeSec = clipStartTimeSec; if (isLoopActive) { const loopDuration = loopEndTimeSec - loopStartTimeSec; - if (loopDuration <= 0) continue; - if (occurrenceStartTimeSec < loopStartTimeSec && logicalTime >= loopStartTimeSec) { - const offsetFromLoopStart = (occurrenceStartTimeSec - loopStartTimeSec) % loopDuration; - occurrenceStartTimeSec = loopStartTimeSec + (offsetFromLoopStart < 0 ? offsetFromLoopStart + loopDuration : offsetFromLoopStart); + if (loopDuration <= 0) continue; + if ( + occurrenceStartTimeSec < loopStartTimeSec && + logicalTime >= loopStartTimeSec + ) { + const offsetFromLoopStart = + (occurrenceStartTimeSec - loopStartTimeSec) % loopDuration; + occurrenceStartTimeSec = + loopStartTimeSec + + (offsetFromLoopStart < 0 + ? offsetFromLoopStart + loopDuration + : offsetFromLoopStart); } if (occurrenceStartTimeSec < logicalTime) { - const loopsMissed = Math.floor((logicalTime - occurrenceStartTimeSec) / loopDuration) + 1; + const loopsMissed = + Math.floor((logicalTime - occurrenceStartTimeSec) / loopDuration) + 1; occurrenceStartTimeSec += loopsMissed * loopDuration; } } @@ -166,7 +220,9 @@ function _schedulerTick() { occurrenceStartTimeSec >= scheduleWindowStartSec && occurrenceStartTimeSec < scheduleWindowEndSec ) { - const absolutePlayTime = startTime + (occurrenceStartTimeSec - (appState.audio.audioEditorSeekTime || 0)); + const absolutePlayTime = + startTime + + (occurrenceStartTimeSec - (appState.audio.audioEditorSeekTime || 0)); _scheduleClip(clip, absolutePlayTime, clipDurationSec); clipRuntime.isScheduled = true; runtimeClipState.set(clip.id, clipRuntime); @@ -181,31 +237,33 @@ function _animationLoop() { return; } const now = audioCtx.currentTime; - let newLogicalTime = (now - startTime) + (appState.audio.audioEditorSeekTime || 0); - + let newLogicalTime = + now - startTime + (appState.audio.audioEditorSeekTime || 0); + if (isLoopActive) { if (newLogicalTime >= loopEndTimeSec) { const loopDuration = loopEndTimeSec - loopStartTimeSec; - newLogicalTime = loopStartTimeSec + ((newLogicalTime - loopStartTimeSec) % loopDuration); + newLogicalTime = + loopStartTimeSec + ((newLogicalTime - loopStartTimeSec) % loopDuration); startTime = now; appState.audio.audioEditorSeekTime = newLogicalTime; } } - - appState.audio.audioEditorLogicalTime = newLogicalTime; - + + appState.audio.audioEditorLogicalTime = newLogicalTime; + if (!isLoopActive) { let maxTime = 0; - appState.audio.clips.forEach(clip => { - const clipStartTime = clip.startTimeInSeconds || 0; - const clipDuration = clip.durationInSeconds || 0; - const endTime = clipStartTime + clipDuration; - if (endTime > maxTime) maxTime = endTime; + appState.audio.clips.forEach((clip) => { + const clipStartTime = clip.startTimeInSeconds || 0; + const clipDuration = clip.durationInSeconds || 0; + const endTime = clipStartTime + clipDuration; + if (endTime > maxTime) maxTime = endTime; }); if (maxTime > 0 && appState.audio.audioEditorLogicalTime >= maxTime) { - stopAudioEditorPlayback(true); // Rebobina no fim - resetPlayheadVisual(); - return; + stopAudioEditorPlayback(true); // Rebobina no fim + resetPlayheadVisual(); + return; } } const pixelsPerSecond = getPixelsPerSecond(); @@ -222,23 +280,30 @@ export function updateTransportLoop() { loopEndTimeSec = appState.global.loopEndTime; runtimeClipState.clear(); - + // parar e descartar players agendados scheduledNodes.forEach(({ player }) => { - try { player.unsync(); } catch {} - try { player.stop(); } catch {} - try { player.dispose(); } catch {} + try { + player.unsync(); + } catch {} + try { + player.stop(); + } catch {} + try { + player.dispose(); + } catch {} }); scheduledNodes.clear(); } -export async function startAudioEditorPlayback(seekTime) { // 1. Aceita 'seekTime' como parâmetro +export async function startAudioEditorPlayback(seekTime) { + // 1. Aceita 'seekTime' como parâmetro if (isPlaying) return; _initContext(); // garante contexto ativo do Tone (gesto do usuário já ocorreu antes) await Tone.start(); - if (audioCtx.state === 'suspended') { + if (audioCtx.state === "suspended") { await audioCtx.resume(); } @@ -251,23 +316,24 @@ export async function startAudioEditorPlayback(seekTime) { // 1. Aceita 'seekTim // ================================================================= // 👇 INÍCIO DA CORREÇÃO (Bugs 1 & 2) // ================================================================= - + // 1. Determine o tempo de início: - // Use o 'seekTime' recebido (da ação global) se for um número válido (>= 0). - // Caso contrário, use o tempo de seek local atual. - const timeToStart = (seekTime !== null && seekTime !== undefined && !isNaN(seekTime)) - ? seekTime - : (appState.audio.audioEditorSeekTime || 0); // 👈 Usa sua variável de estado + let timeToStart = + seekTime !== null && seekTime !== undefined && !isNaN(seekTime) + ? seekTime + : appState.audio.audioEditorSeekTime || 0; // 👈 Usa sua variável de estado - // 2. Atualize o estado global (para a agulha pular) - // Isso garante que o estado local E o Tone estejam sincronizados. - appState.audio.audioEditorSeekTime = timeToStart; + // 2. Clampa o valor (parte da correção do RangeError) + timeToStart = Math.max(0, timeToStart); - // 3. Alinhe o Tone.Transport a esse tempo + // 3. Atualize o estado global (para a agulha pular) + appState.audio.audioEditorSeekTime = timeToStart; + + // 4. Alinhe o Tone.Transport a esse tempo try { Tone.Transport.seconds = timeToStart; // 👈 Usa o tempo sincronizado } catch {} - + // ================================================================= // 👆 FIM DA CORREÇÃO // ================================================================= @@ -295,37 +361,48 @@ export function stopAudioEditorPlayback(rewind = false) { isPlaying = false; appState.global.isAudioEditorPlaying = false; - + console.log(`%cParando Playback... (Rewind: ${rewind})`, "color: #d9534f;"); // para o Transport (para Players .sync()) - try { Tone.Transport.stop(); } catch {} + try { + Tone.Transport.stop(); + } catch {} clearInterval(schedulerIntervalId); schedulerIntervalId = null; cancelAnimationFrame(animationFrameId); animationFrameId = null; - appState.audio.audioEditorSeekTime = appState.audio.audioEditorLogicalTime || 0; - appState.audio.audioEditorLogicalTime = 0; + appState.audio.audioEditorSeekTime = + appState.audio.audioEditorLogicalTime || 0; + appState.audio.audioEditorLogicalTime = 0; if (rewind) { appState.audio.audioEditorSeekTime = 0; - try { Tone.Transport.seconds = 0; } catch {} + try { + Tone.Transport.seconds = 0; + } catch {} } - + // parar e descartar players agendados scheduledNodes.forEach(({ player }) => { - try { player.unsync(); } catch {} - try { player.stop(); } catch {} - try { player.dispose(); } catch {} + try { + player.unsync(); + } catch {} + try { + player.stop(); + } catch {} + try { + player.dispose(); + } catch {} }); scheduledNodes.clear(); runtimeClipState.clear(); updateAudioEditorUI(); const playBtn = document.getElementById("audio-editor-play-btn"); - if (playBtn) playBtn.className = 'fa-solid fa-play'; - + if (playBtn) playBtn.className = "fa-solid fa-play"; + if (rewind) { resetPlayheadVisual(); } @@ -343,16 +420,21 @@ export function seekAudioEditor(newTime) { if (wasPlaying) { stopAudioEditorPlayback(false); // Pausa } - + + // Clampa o novo tempo + newTime = Math.max(0, newTime); + appState.audio.audioEditorSeekTime = newTime; - appState.audio.audioEditorLogicalTime = newTime; - - try { Tone.Transport.seconds = newTime; } catch {} + appState.audio.audioEditorLogicalTime = newTime; + + try { + Tone.Transport.seconds = newTime; + } catch {} const pixelsPerSecond = getPixelsPerSecond(); const newPositionPx = newTime * pixelsPerSecond; updatePlayheadVisual(newPositionPx); - + if (wasPlaying) { startAudioEditorPlayback(); } diff --git a/assets/js/creations/audio/audio_state.js b/assets/js/creations/audio/audio_state.js index 4fe23016..4cff2e49 100644 --- a/assets/js/creations/audio/audio_state.js +++ b/assets/js/creations/audio/audio_state.js @@ -5,38 +5,43 @@ import { getMainGainNode, getAudioContext } from "../audio.js"; import * as Tone from "https://esm.sh/tone"; export let audioState = { - tracks: [], - clips: [], - // --- TEMPOS MOVIDOS DO audio_audio.js PARA O ESTADO GLOBAL --- - audioEditorSeekTime: 0, - audioEditorLogicalTime: 0, - // --- FIM DA MUDANÇA --- - audioEditorStartTime: 0, - audioEditorAnimationId: null, - audioEditorPlaybackTime: 0, - isAudioEditorLoopEnabled: false, + tracks: [], + clips: [], + // --- TEMPOS MOVIDOS DO audio_audio.js PARA O ESTADO GLOBAL --- + audioEditorSeekTime: 0, + audioEditorLogicalTime: 0, + // --- FIM DA MUDANÇA --- + audioEditorStartTime: 0, + audioEditorAnimationId: null, + audioEditorPlaybackTime: 0, + isAudioEditorLoopEnabled: false, }; // ==== SNAPSHOT: exportação do estado atual (tracks + clips) ==== export function getAudioSnapshot() { // Se seu estado “oficial” é audioState.* use ele; // se for appState.audio.* troque abaixo. - const tracks = (audioState.tracks || []).map(t => ({ - id: t.id, name: t.name + const tracks = (audioState.tracks || []).map((t) => ({ + id: t.id, + name: t.name, })); - const clips = (audioState.clips || []).map(c => ({ + const clips = (audioState.clips || []).map((c) => ({ id: c.id, trackId: c.trackId, name: c.name, - sourcePath: c.sourcePath || null, // URL do asset (precisa ser acessível) + sourcePath: c.sourcePath || null, // URL do asset (precisa ser acessível) startTimeInSeconds: c.startTimeInSeconds || 0, - durationInSeconds: c.durationInSeconds || (c.buffer?.duration || 0), + durationInSeconds: c.durationInSeconds || c.buffer?.duration || 0, offset: c.offset || 0, pitch: c.pitch || 0, volume: c.volume ?? 1, pan: c.pan ?? 0, - originalDuration: c.originalDuration || (c.buffer?.duration || 0), + originalDuration: c.originalDuration || c.buffer?.duration || 0, + + // --- NOVA MODIFICAÇÃO (SNAPSHOT) --- + // Também enviamos os dados do pattern se existirem + patternData: c.patternData || null, })); return { tracks, clips }; @@ -48,32 +53,47 @@ export async function applyAudioSnapshot(snapshot) { // aplica trilhas (mantém ids/nome) if (Array.isArray(snapshot.tracks) && snapshot.tracks.length) { - audioState.tracks = snapshot.tracks.map(t => ({ id: t.id, name: t.name })); + audioState.tracks = snapshot.tracks.map((t) => ({ + id: t.id, + name: t.name, + })); } // insere clipes usando os MESMOS ids do emissor (idempotente) if (Array.isArray(snapshot.clips)) { for (const c of snapshot.clips) { // evita duplicar se já existir (idempotência) - if (audioState.clips.some(x => String(x.id) === String(c.id))) continue; + if (audioState.clips.some((x) => String(x.id) === String(c.id))) continue; // usa a própria função de criação (agora ela aceita id e nome) - // assinatura: addAudioClipToTimeline(samplePath, trackId, start, clipId, name) - addAudioClipToTimeline(c.sourcePath, c.trackId, c.startTimeInSeconds, c.id, c.name); + // --- NOVA MODIFICAÇÃO (SNAPSHOT) --- + // Passamos o patternData para a função de criação + addAudioClipToTimeline( + c.sourcePath, + c.trackId, + c.startTimeInSeconds, + c.id, + c.name, + c.patternData // <-- Passa os dados do pattern + ); // aplica propriedades adicionais (dur/offset/pitch/vol/pan) no mesmo id - const idx = audioState.clips.findIndex(x => String(x.id) === String(c.id)); + const idx = audioState.clips.findIndex( + (x) => String(x.id) === String(c.id) + ); if (idx >= 0) { const clip = audioState.clips[idx]; - clip.durationInSeconds = c.durationInSeconds; - clip.offset = c.offset; - clip.pitch = c.pitch; - clip.volume = c.volume; - clip.pan = c.pan; - clip.originalDuration = c.originalDuration; + clip.durationInSeconds = c.durationInSeconds; + clip.offset = c.offset; + clip.pitch = c.pitch; + clip.volume = c.volume; + clip.pan = c.pan; + clip.originalDuration = c.originalDuration; + + // (patternData já foi definido durante a criação acima) // reflete nos nós Tone já criados - if (clip.gainNode) clip.gainNode.gain.value = clip.volume ?? 1; + if (clip.gainNode) clip.gainNode.gain.value = clip.volume ?? 1; if (clip.pannerNode) clip.pannerNode.pan.value = clip.pan ?? 0; } } @@ -83,52 +103,57 @@ export async function applyAudioSnapshot(snapshot) { renderAudioEditor(); } - export function initializeAudioState() { - audioState.clips.forEach(clip => { - if (clip.pannerNode) clip.pannerNode.dispose(); - if (clip.gainNode) clip.gainNode.dispose(); - }); - Object.assign(audioState, { - tracks: [], - clips: [], - // --- ADICIONADO --- - audioEditorSeekTime: 0, - audioEditorLogicalTime: 0, - // --- FIM --- - audioEditorStartTime: 0, - audioEditorAnimationId: null, - audioEditorPlaybackTime: 0, - isAudioEditorLoopEnabled: false, - }); + audioState.clips.forEach((clip) => { + if (clip.pannerNode) clip.pannerNode.dispose(); + if (clip.gainNode) clip.gainNode.dispose(); + }); + Object.assign(audioState, { + tracks: [], + clips: [], + // --- ADICIONADO --- + audioEditorSeekTime: 0, + audioEditorLogicalTime: 0, + // --- FIM --- + audioEditorStartTime: 0, + audioEditorAnimationId: null, + audioEditorPlaybackTime: 0, + isAudioEditorLoopEnabled: false, + }); } export async function loadAudioForClip(clip) { // --- ADIÇÃO --- // Se já temos um buffer (do bounce ou colagem), não faz fetch if (clip.buffer) { - // Garante que as durações estão corretas - if (clip.originalDuration === 0) clip.originalDuration = clip.buffer.duration; - if (clip.durationInSeconds === 0) clip.durationInSeconds = clip.buffer.duration; - return clip; + // Garante que as durações estão corretas + if (clip.originalDuration === 0) + clip.originalDuration = clip.buffer.duration; + if (clip.durationInSeconds === 0) + clip.durationInSeconds = clip.buffer.duration; + return clip; } // --- FIM DA ADIÇÃO --- - if (!clip.sourcePath) return clip; - + if (!clip.sourcePath || clip.sourcePath.startsWith("blob:")) { + // Se não há caminho ou se é um blob, não há nada para buscar. + return clip; + } + const audioCtx = getAudioContext(); if (!audioCtx) { - console.error("AudioContext não disponível para carregar áudio."); - return clip; + console.error("AudioContext não disponível para carregar áudio."); + return clip; } - + try { const response = await fetch(clip.sourcePath); - if (!response.ok) throw new Error(`Falha ao buscar áudio: ${clip.sourcePath}`); + if (!response.ok) + throw new Error(`Falha ao buscar áudio: ${clip.sourcePath}`); const arrayBuffer = await response.arrayBuffer(); const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer); - clip.buffer = audioBuffer; + clip.buffer = audioBuffer; // --- CORREÇÃO: Salva a duração original --- if (clip.durationInSeconds === 0) { @@ -136,7 +161,6 @@ export async function loadAudioForClip(clip) { } // Salva a duração real do buffer para cálculos de stretch clip.originalDuration = audioBuffer.duration; - } catch (error) { console.error(`Falha ao carregar áudio para o clipe ${clip.name}:`, error); } @@ -145,123 +169,182 @@ export async function loadAudioForClip(clip) { // helper de id (fallback se o emissor não mandar) function genClipId() { - return (crypto?.randomUUID?.() || `clip_${Date.now()}_${Math.floor(Math.random()*1e6)}`); + return ( + crypto?.randomUUID?.() || + `clip_${Date.now()}_${Math.floor(Math.random() * 1e6)}` + ); } // --- FUNÇÃO MODIFICADA --- -// agora aceita clipId e clipName vindos do emissor; mantém compat com chamadas antigas -export function addAudioClipToTimeline(samplePath, trackId = 1, startTime = 0, clipIdOrName = null, nameOrBuffer = null, maybeBuffer = null) { - // compat: se passaram (filePath, trackId, start, clipId) - // mas versões antigas chamavam (filePath, trackId, start) ou (filePath, trackId, start, name, buffer) - let incomingId = null; - let clipName = null; - let existingBuffer = null; +// O 6º argumento (maybeBuffer) agora é tratado para aceitar (Buffer) OU (patternData) +export function addAudioClipToTimeline( + samplePath, + trackId = 1, + startTime = 0, + clipIdOrName = null, + nameOrBuffer = null, + maybeBuffer = null +) { + let incomingId = null; + let clipName = null; + let existingBuffer = null; + let incomingPatternData = null; // <-- NOSSO NOVO DADO - // heurística: se clipIdOrName parece um UUID/clip_ → é id, senão é nome - if (typeof clipIdOrName === 'string' && (clipIdOrName.startsWith('clip_') || clipIdOrName.length >= 16)) { - incomingId = clipIdOrName; - clipName = (typeof nameOrBuffer === 'string') ? nameOrBuffer : null; - existingBuffer = maybeBuffer || (nameOrBuffer && typeof nameOrBuffer !== 'string' ? nameOrBuffer : null); - } else { - // assinatura antiga: 4º arg era nome - clipName = (typeof clipIdOrName === 'string') ? clipIdOrName : null; - existingBuffer = (nameOrBuffer && typeof nameOrBuffer !== 'string') ? nameOrBuffer : null; + // Função helper para checar se é um buffer (para evitar bugs) + // (Um Tone.ToneAudioBuffer tem a prop ._buffer que é o AudioBuffer) + const isBuffer = (obj) => + obj && + (obj instanceof AudioBuffer || (obj && obj._buffer instanceof AudioBuffer)); + + // heurística: se clipIdOrName parece um UUID/clip_ → é id, senão é nome + if ( + typeof clipIdOrName === "string" && + (clipIdOrName.startsWith("clip_") || + clipIdOrName.startsWith("bounced_") || + clipIdOrName.length >= 16) + ) { + incomingId = clipIdOrName; // 4º arg é ID + clipName = typeof nameOrBuffer === "string" ? nameOrBuffer : null; // 5º arg é Nome + + // --- INÍCIO DA CORREÇÃO (Passo 3) --- + // O 6º argumento (maybeBuffer) pode ser o buffer OU o patternData. + // O 5º (nameOrBuffer) pode ser o nome OU o buffer. + + // 1. Checa se o 6º argumento é o buffer + if (isBuffer(maybeBuffer)) { + existingBuffer = maybeBuffer; + } + // 2. Checa se o 6º argumento é o patternData (array) + else if (Array.isArray(maybeBuffer)) { + incomingPatternData = maybeBuffer; } - const finalId = incomingId || genClipId(); - - // idempotência: se o id já existe, não duplica - if (audioState.clips.some(c => String(c.id) === String(finalId))) { - return; + // 3. Se o buffer não veio no 6º, checa se veio no 5º (assinatura antiga) + if (!existingBuffer && isBuffer(nameOrBuffer)) { + existingBuffer = nameOrBuffer; } + // --- FIM DA CORREÇÃO --- + } else { + // assinatura antiga: 4º arg era nome + clipName = typeof clipIdOrName === "string" ? clipIdOrName : null; + // 5º arg era buffer + existingBuffer = isBuffer(nameOrBuffer) ? nameOrBuffer : null; + } - const newClip = { - id: finalId, - trackId: trackId, - sourcePath: samplePath, // Pode ser null se existingBuffer for fornecido - name: clipName || (samplePath ? String(samplePath).split('/').pop() : 'Bounced Clip'), - - startTimeInSeconds: startTime, - offset: 0, - durationInSeconds: 0, - originalDuration: 0, + const finalId = incomingId || genClipId(); - pitch: 0, - volume: DEFAULT_VOLUME, - pan: DEFAULT_PAN, - - buffer: existingBuffer || null, - player: null, - }; - - // volume linear (0–1) - newClip.gainNode = new Tone.Gain(DEFAULT_VOLUME); - newClip.pannerNode = new Tone.Panner(DEFAULT_PAN); + // idempotência: se o id já existe, não duplica + if (audioState.clips.some((c) => String(c.id) === String(finalId))) { + return; + } - // conecta tudo no grafo do Tone (mesmo contexto) - newClip.gainNode.connect(newClip.pannerNode); - newClip.pannerNode.connect(getMainGainNode()); + const newClip = { + id: finalId, + trackId: trackId, + sourcePath: samplePath, // Pode ser null se existingBuffer for fornecido + name: + clipName || + (samplePath ? String(samplePath).split("/").pop() : "Bounced Clip"), - audioState.clips.push(newClip); - - // loadAudioForClip agora vai lidar com 'existingBuffer' - loadAudioForClip(newClip).then(() => { - renderAudioEditor(); - }); + startTimeInSeconds: startTime, + offset: 0, + durationInSeconds: 0, + originalDuration: 0, + + pitch: 0, + volume: DEFAULT_VOLUME, + pan: DEFAULT_PAN, + + buffer: existingBuffer || null, + player: null, + + // --- INÍCIO DA CORREÇÃO (Passo 3) --- + // A "partitura" é finalmente armazenada no objeto do clipe! + patternData: incomingPatternData || null, + // --- FIM DA CORREÇÃO --- + }; + + // volume linear (0–1) + newClip.gainNode = new Tone.Gain(DEFAULT_VOLUME); + newClip.pannerNode = new Tone.Panner(DEFAULT_PAN); + + // --- INÍCIO DA CORREÇÃO (O BUG ESTÁ AQUI) --- + // Precisamos de ligar os nós do novo clipe à saída principal, + // caso contrário, ele será criado "mudo". + newClip.gainNode.connect(newClip.pannerNode); + newClip.pannerNode.connect(getMainGainNode()); + // --- FIM DA CORREÇÃO --- + + audioState.clips.push(newClip); + + // loadAudioForClip agora vai lidar com 'existingBuffer' + loadAudioForClip(newClip).then(() => { + renderAudioEditor(); + }); } export function updateAudioClipProperties(clipId, properties) { - const clip = audioState.clips.find(c => String(c.id) == String(clipId)); - if (clip) { - Object.assign(clip, properties); - } + const clip = audioState.clips.find((c) => String(c.id) == String(clipId)); + if (clip) { + Object.assign(clip, properties); + } } export function sliceAudioClip(clipId, sliceTimeInTimeline) { - const originalClip = audioState.clips.find(c => String(c.id) == String(clipId)); - - if (!originalClip || - sliceTimeInTimeline <= originalClip.startTimeInSeconds || - sliceTimeInTimeline >= (originalClip.startTimeInSeconds + originalClip.durationInSeconds)) { - console.warn("Corte inválido: fora dos limites do clipe."); - return; - } + const originalClip = audioState.clips.find( + (c) => String(c.id) == String(clipId) + ); - const originalOffset = originalClip.offset || 0; - const cutPointInClip = sliceTimeInTimeline - originalClip.startTimeInSeconds; + if ( + !originalClip || + sliceTimeInTimeline <= originalClip.startTimeInSeconds || + sliceTimeInTimeline >= + originalClip.startTimeInSeconds + originalClip.durationInSeconds + ) { + console.warn("Corte inválido: fora dos limites do clipe."); + return; + } - const newClip = { - id: genClipId(), - trackId: originalClip.trackId, - sourcePath: originalClip.sourcePath, - name: originalClip.name, - buffer: originalClip.buffer, - - startTimeInSeconds: sliceTimeInTimeline, - offset: originalOffset + cutPointInClip, - durationInSeconds: originalClip.durationInSeconds - cutPointInClip, - - // --- CORREÇÃO: Propaga a duração original --- - originalDuration: originalClip.originalDuration, + const originalOffset = originalClip.offset || 0; + const cutPointInClip = sliceTimeInTimeline - originalClip.startTimeInSeconds; - pitch: originalClip.pitch, - volume: originalClip.volume, - pan: originalClip.pan, + const newClip = { + id: genClipId(), + trackId: originalClip.trackId, + sourcePath: originalClip.sourcePath, + name: originalClip.name, + buffer: originalClip.buffer, - gainNode: new Tone.Gain(originalClip.volume), - pannerNode: new Tone.Panner(originalClip.pan), - - player: null - }; + startTimeInSeconds: sliceTimeInTimeline, + offset: originalOffset + cutPointInClip, + durationInSeconds: originalClip.durationInSeconds - cutPointInClip, - newClip.gainNode.connect(newClip.pannerNode); - newClip.pannerNode.connect(getMainGainNode()); + // --- CORREÇÃO: Propaga a duração original --- + originalDuration: originalClip.originalDuration, - originalClip.durationInSeconds = cutPointInClip; + pitch: originalClip.pitch, + volume: originalClip.volume, + pan: originalClip.pan, - audioState.clips.push(newClip); - - console.log("Clipe dividido. Original:", originalClip, "Novo:", newClip); + gainNode: new Tone.Gain(originalClip.volume), + pannerNode: new Tone.Panner(originalClip.pan), + + player: null, + + // --- NOVA MODIFICAÇÃO (SLICE) --- + // Se o clip original tinha dados de pattern, o novo clip (parte 2) + // também deve tê-los, pois a referência é a mesma. + patternData: originalClip.patternData || null, + }; + + newClip.gainNode.connect(newClip.pannerNode); + newClip.pannerNode.connect(getMainGainNode()); + + originalClip.durationInSeconds = cutPointInClip; + + audioState.clips.push(newClip); + + console.log("Clipe dividido. Original:", originalClip, "Novo:", newClip); } export function updateClipVolume(clipId, volume) { @@ -270,7 +353,7 @@ export function updateClipVolume(clipId, volume) { const clampedVolume = Math.max(0, Math.min(1.5, volume)); clip.volume = clampedVolume; if (clip.gainNode) { - clip.gainNode.gain.value = clampedVolume; + clip.gainNode.gain.value = clampedVolume; } } } @@ -281,35 +364,45 @@ export function updateClipPan(clipId, pan) { const clampedPan = Math.max(-1, Math.min(1, pan)); clip.pan = clampedPan; if (clip.pannerNode) { - clip.pannerNode.pan.value = clampedPan; + clip.pannerNode.pan.value = clampedPan; } } } export function addAudioTrackLane() { - const newTrackName = `Pista de Áudio ${audioState.tracks.length + 1}`; - audioState.tracks.push({ id: Date.now(), name: newTrackName }); + const newTrackName = `Pista de Áudio ${audioState.tracks.length + 1}`; + audioState.tracks.push({ id: Date.now(), name: newTrackName }); } export function removeAudioClip(clipId) { - const clipIndex = audioState.clips.findIndex(c => String(c.id) == String(clipId)); - if (clipIndex === -1) return false; // Retorna false se não encontrou + const clipIndex = audioState.clips.findIndex( + (c) => String(c.id) == String(clipId) + ); + if (clipIndex === -1) return false; // Retorna false se não encontrou - const clip = audioState.clips[clipIndex]; + const clip = audioState.clips[clipIndex]; - // 1. Limpa os nós de áudio do Tone.js - if (clip.gainNode) { - try { clip.gainNode.disconnect(); } catch {} - try { clip.gainNode.dispose(); } catch {} - } - if (clip.pannerNode) { - try { clip.pannerNode.disconnect(); } catch {} - try { clip.pannerNode.dispose(); } catch {} - } - - // 2. Remove o clipe do array de estado - audioState.clips.splice(clipIndex, 1); + // 1. Limpa os nós de áudio do Tone.js + if (clip.gainNode) { + try { + clip.gainNode.disconnect(); + } catch {} + try { + clip.gainNode.dispose(); + } catch {} + } + if (clip.pannerNode) { + try { + clip.pannerNode.disconnect(); + } catch {} + try { + clip.pannerNode.dispose(); + } catch {} + } - // 3. Retorna true para o chamador (Controller) - return true; + // 2. Remove o clipe do array de estado + audioState.clips.splice(clipIndex, 1); + + // 3. Retorna true para o chamador (Controller) + return true; } diff --git a/assets/js/creations/audio/audio_ui.js b/assets/js/creations/audio/audio_ui.js index 71b50b74..6cbfe933 100644 --- a/assets/js/creations/audio/audio_ui.js +++ b/assets/js/creations/audio/audio_ui.js @@ -320,7 +320,7 @@ export function renderAudioEditor() { grid.style.setProperty("--bar-width", `${barWidthPx}px`); }); - // Render Clips (sem alterações) + // Render Clips (MODIFICADO) appState.audio.clips.forEach((clip) => { const parentGrid = newTrackContainer.querySelector( `.audio-track-lane[data-track-id="${clip.trackId}"] .spectrogram-view-grid` @@ -342,8 +342,44 @@ export function renderAudioEditor() { let pitchStr = clip.pitch > 0 ? `+${clip.pitch.toFixed(1)}` : `${clip.pitch.toFixed(1)}`; if (clip.pitch === 0) pitchStr = ""; + + // Define o HTML base (sem a visualização de steps ainda) clipElement.innerHTML = `
${clip.name} ${pitchStr}
`; + + // --- INÍCIO DA MODIFICAÇÃO (Passo 4: Desenhar Steps) --- + // (Fazemos isso DEPOIS de definir o innerHTML) + + // 1. Verifica se este clipe tem os dados da "partitura" (steps) + if ( + clip.patternData && + Array.isArray(clip.patternData) && + clip.patternData.length > 0 + ) { + // 2. Adiciona a classe CSS principal (do creation.html) + clipElement.classList.add("pattern-clip"); + + // 3. Determina o número de steps (do primeiro array de trilha) + // Assumimos que todos têm o mesmo comprimento, pois vieram do mesmo pattern. + const totalSteps = clip.patternData[0]?.length || 0; + + if (totalSteps > 0) { + // 4. Chama a nova função (adicionada no final deste arquivo) + // para construir o HTML da visualização + const patternViewEl = createPatternViewElement( + clip.patternData, + totalSteps + ); + + // 5. Adiciona a visualização ao clipe + // (O CSS .pattern-clip-view o posicionará sobre o canvas) + clipElement.appendChild(patternViewEl); + } + } + // --- FIM DA MODIFICAÇÃO --- + parentGrid.appendChild(clipElement); + + // Renderização do Canvas (Waveform) if (clip.buffer) { const canvas = clipElement.querySelector(".waveform-canvas-clip"); const canvasWidth = (clip.durationInSeconds || 0) * pixelsPerSecond; @@ -365,6 +401,8 @@ export function renderAudioEditor() { ); } } + + // Wheel listener (pitch) clipElement.addEventListener("wheel", (e) => { e.preventDefault(); const clipToUpdate = appState.audio.clips.find( @@ -817,3 +855,57 @@ export function resetPlayheadVisual() { ph.style.left = "0px"; }); } + +// --- INÍCIO DA NOVA FUNÇÃO (Passo 4: A Função de Desenho) --- +// (Adicionada ao final de audio_ui.js) + +/** + * Cria o elemento HTML (e seus filhos) para a visualização + * dos steps de um pattern clip. + * * @param {Array>} patternData - ex: [[true, false], [true, true]] + * @returns {HTMLElement} Um
com a classe 'pattern-clip-view' + */ +function createPatternViewElement(patternData) { + const view = document.createElement("div"); + view.className = "pattern-clip-view"; // (do creation.html) + + // Filtra trilhas que possam ser vazias ou inválidas no array + const validTracksData = patternData.filter( + (steps) => Array.isArray(steps) && steps.length > 0 + ); + + // Encontra o total de steps (usando a trilha mais longa como referência) + const totalSteps = validTracksData.reduce( + (max, steps) => Math.max(max, steps.length), + 0 + ); + if (totalSteps === 0) return view; // Retorna view vazia se não houver steps + + validTracksData.forEach((trackSteps) => { + const row = document.createElement("div"); + row.className = "pattern-clip-track-row"; // (do creation.html) + + // Calcula a largura de cada step como porcentagem + const stepWidthPercent = (1 / totalSteps) * 100; + + for (let i = 0; i < totalSteps; i++) { + // Se o step[i] for true, desenha a nota + if (trackSteps[i] === true) { + const note = document.createElement("div"); + note.className = "pattern-step-note"; // (do creation.html) + + // Define a posição (left) e a largura (width) em porcentagem + // Isso permite que o clip seja redimensionado (stretch) + // e as notas se ajustem. + note.style.left = `${(i / totalSteps) * 100}%`; + note.style.width = `${stepWidthPercent}%`; + + row.appendChild(note); + } + } + view.appendChild(row); + }); + + return view; +} +// --- FIM DA NOVA FUNÇÃO --- diff --git a/assets/js/creations/file.js b/assets/js/creations/file.js index 1c2ded85..75739adf 100644 --- a/assets/js/creations/file.js +++ b/assets/js/creations/file.js @@ -1,14 +1,65 @@ // js/file.js -import { appState, resetProjectState } from "./state.js"; +import { appState, saveStateToSession, resetProjectState } from "./state.js"; 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, getAudioContext, getMainGainNode } from "./audio.js"; +import { + initializeAudioContext, + getAudioContext, + getMainGainNode, +} from "./audio.js"; import * as Tone from "https://esm.sh/tone"; // --- NOVA IMPORTAÇÃO --- import { sendAction } from "./socket.js"; +// --- NOVA ADIÇÃO --- +// Conteúdo do 'teste.mmp' (projeto em branco) +const BLANK_PROJECT_XML = ` + + + + + + + + + +`; + +/** + * Executa um reset completo do estado local do projeto. + * Limpa o backup da sessão, reseta o appState e renderiza a UI. + */ +export function handleLocalProjectReset() { + console.log("Recebido comando de reset. Limpando estado local..."); + + // 1. Limpa o backup da sessão + if (window.ROOM_NAME) { + try { + sessionStorage.removeItem(`temp_state_${window.ROOM_NAME}`); + console.log("Estado da sessão local limpo."); + } catch (e) { + console.error("Falha ao limpar estado da sessão:", e); + } + } + + // 2. Reseta o estado da memória (appState) + // (Isso deve zerar o appState.pattern.tracks, etc) + resetProjectState(); + + // 3. Reseta a UI global para os padrões + document.getElementById("bpm-input").value = 140; + document.getElementById("bars-input").value = 1; + document.getElementById("compasso-a-input").value = 4; + document.getElementById("compasso-b-input").value = 4; + + // 4. Renderiza a UI vazia + renderAll(); // Isso deve redesenhar o editor de patterns vazio + + console.log("Reset local concluído."); +} + export async function handleFileLoad(file) { let xmlContent = ""; try { @@ -19,17 +70,18 @@ export async function handleFileLoad(file) { name.toLowerCase().endsWith(".mmp") ); if (!projectFile) - throw new Error("Não foi possível encontrar um arquivo .mmp dentro do .mmpz"); + throw new Error( + "Não foi possível encontrar um arquivo .mmp dentro do .mmpz" + ); xmlContent = await zip.files[projectFile].async("string"); } else { xmlContent = await file.text(); } - + // ANTES: await parseMmpContent(xmlContent); // DEPOIS: // Envia o XML para o servidor, que o transmitirá para todos (incluindo nós) - sendAction({ type: 'LOAD_PROJECT', xml: xmlContent }); - + sendAction({ type: "LOAD_PROJECT", xml: xmlContent }); } catch (error) { console.error("Erro ao carregar o projeto:", error); alert(`Erro ao carregar projeto: ${error.message}`); @@ -41,21 +93,20 @@ export async function loadProjectFromServer(fileName) { const response = await fetch(`mmp/${fileName}`); if (!response.ok) throw new Error(`Não foi possível carregar o arquivo ${fileName}`); - + const xmlContent = await response.text(); - // ANTES: + // ANTES: // await parseMmpContent(xmlContent); // return true; // DEPOIS: // Envia o XML para o servidor - sendAction({ type: 'LOAD_PROJECT', xml: xmlContent }); + sendAction({ type: "LOAD_PROJECT", xml: xmlContent }); return true; // Retorna true para que o modal de UI feche - } catch (error) { console.error("Erro ao carregar projeto do servidor:", error); - console.error(error); + console.error(error); alert(`Erro ao carregar projeto: ${error.message}`); return false; } @@ -67,6 +118,13 @@ export async function loadProjectFromServer(fileName) { export async function parseMmpContent(xmlString) { resetProjectState(); initializeAudioContext(); + appState.global.justReset = xmlString === BLANK_PROJECT_XML; + // Limpa manualmente a UI de áudio, pois resetProjectState() + // só limpa os *dados* (appState.audio.clips). + const audioContainer = document.getElementById("audio-track-container"); + if (audioContainer) { + audioContainer.innerHTML = ""; + } const parser = new DOMParser(); const xmlDoc = parser.parseFromString(xmlString, "application/xml"); @@ -76,125 +134,202 @@ export async function parseMmpContent(xmlString) { const head = xmlDoc.querySelector("head"); if (head) { - document.getElementById("bpm-input").value = head.getAttribute("bpm") || 140; - document.getElementById("compasso-a-input").value = head.getAttribute("timesig_numerator") || 4; - document.getElementById("compasso-b-input").value = head.getAttribute("timesig_denominator") || 4; + document.getElementById("bpm-input").value = + head.getAttribute("bpm") || 140; + document.getElementById("compasso-a-input").value = + head.getAttribute("timesig_numerator") || 4; + document.getElementById("compasso-b-input").value = + head.getAttribute("timesig_denominator") || 4; } - const allBBTrackNodes = Array.from(xmlDoc.querySelectorAll('song > trackcontainer[type="song"] > track[type="1"]')); + const allBBTrackNodes = Array.from( + xmlDoc.querySelectorAll( + 'song > trackcontainer[type="song"] > track[type="1"]' + ) + ); if (allBBTrackNodes.length === 0) { - appState.pattern.tracks = []; - renderAll(); - return; + const allBBTrackNodes = Array.from( + xmlDoc.querySelectorAll( + 'song > trackcontainer[type="song"] > track[type="1"]' + ) + ); + if (allBBTrackNodes.length === 0) { + appState.pattern.tracks = []; + + // --- INÍCIO DA CORREÇÃO --- + // O resetProjectState() [na linha 105] já limpou o appState.audio. + // No entanto, a UI (DOM) do editor de áudio não foi limpa. + // Vamos forçar a limpeza do container aqui: + const audioContainer = document.getElementById("audio-track-container"); + if (audioContainer) { + audioContainer.innerHTML = ""; // Limpa a UI de áudio + } + // --- FIM DA CORREÇÃO --- + + renderAll(); // + return; // + } } - + const sortedBBTrackNameNodes = [...allBBTrackNodes].sort((a, b) => { - const bbtcoA = a.querySelector('bbtco'); - const bbtcoB = b.querySelector('bbtco'); - const posA = bbtcoA ? parseInt(bbtcoA.getAttribute('pos'), 10) : Infinity; - const posB = bbtcoB ? parseInt(bbtcoB.getAttribute('pos'), 10) : Infinity; + const bbtcoA = a.querySelector("bbtco"); + const bbtcoB = a.querySelector("bbtco"); + const posA = bbtcoA ? parseInt(bbtcoA.getAttribute("pos"), 10) : Infinity; + const posB = bbtcoB ? parseInt(bbtcoB.getAttribute("pos"), 10) : Infinity; return posA - posB; }); - - const dataSourceTrack = allBBTrackNodes[0]; - appState.global.currentBeatBasslineName = dataSourceTrack.getAttribute("name") || "Beat/Bassline"; - const bbTrackContainer = dataSourceTrack.querySelector('bbtrack > trackcontainer'); - if (!bbTrackContainer) { - appState.pattern.tracks = []; - renderAll(); + // --- INÍCIO DA CORREÇÃO 1: Lendo TODAS as Basslines (Tracks type="1") --- + // O bug anterior era que o código só lia os instrumentos (tracks type="0") + // da PRIMEIRA bassline encontrada (allBBTrackNodes[0]). + // A correção abaixo itera em TODAS as basslines (allBBTrackNodes.forEach) + // e coleta os instrumentos de CADA UMA delas. + + // Define um nome global (pode usar o da primeira track, se existir) + appState.global.currentBeatBasslineName = + allBBTrackNodes[0]?.getAttribute("name") || "Beat/Bassline"; + + // Cria um array para guardar TODOS os instrumentos de TODAS as basslines + const allInstrumentTrackNodes = []; + + // Loop em CADA bassline (allBBTrackNodes) em vez de apenas na [0] + allBBTrackNodes.forEach((bbTrackNode) => { + const bbTrackContainer = bbTrackNode.querySelector( + "bbtrack > trackcontainer" + ); + if (bbTrackContainer) { + // Encontra os instrumentos (type="0") DENTRO desta bassline + const instrumentTracks = + bbTrackContainer.querySelectorAll('track[type="0"]'); + // Adiciona os instrumentos encontrados ao array principal + allInstrumentTrackNodes.push(...Array.from(instrumentTracks)); + } + }); + + // Se não achou NENHUM instrumento em NENHUMA bassline, encerra + if (allInstrumentTrackNodes.length === 0) { + appState.pattern.tracks = []; + renderAll(); return; } + // --- FIM DA CORREÇÃO 1 --- - const instrumentTracks = bbTrackContainer.querySelectorAll('track[type="0"]'); const pathMap = getSamplePathMap(); - - newTracks = Array.from(instrumentTracks).map(trackNode => { - const instrumentNode = trackNode.querySelector("instrument"); - const instrumentTrackNode = trackNode.querySelector("instrumenttrack"); - if (!instrumentNode || !instrumentTrackNode) return null; - - const trackName = trackNode.getAttribute("name"); - - if (instrumentNode.getAttribute("name") === 'tripleoscillator') { - return null; - } - const allPatternsNodeList = trackNode.querySelectorAll("pattern"); - const allPatternsArray = Array.from(allPatternsNodeList).sort((a, b) => { - const posA = parseInt(a.getAttribute('pos'), 10) || 0; - const posB = parseInt(b.getAttribute('pos'), 10) || 0; - return posB - posA; - }); - - const patterns = sortedBBTrackNameNodes.map((bbTrack, index) => { + // Agora o map usa o array corrigido (allInstrumentTrackNodes) + newTracks = Array.from(allInstrumentTrackNodes) + .map((trackNode) => { + const instrumentNode = trackNode.querySelector("instrument"); + const instrumentTrackNode = trackNode.querySelector("instrumenttrack"); + if (!instrumentNode || !instrumentTrackNode) return null; + + const trackName = trackNode.getAttribute("name"); + + if (instrumentNode.getAttribute("name") === "tripleoscillator") { + return null; + } + + const allPatternsNodeList = trackNode.querySelectorAll("pattern"); + const allPatternsArray = Array.from(allPatternsNodeList).sort((a, b) => { + const posA = parseInt(a.getAttribute("pos"), 10) || 0; + const posB = parseInt(b.getAttribute("pos"), 10) || 0; + + // --- CORREÇÃO 2: Ordenação dos Patterns --- + // O bug aqui era `posB - posA`, que invertia a ordem dos patterns + // (o "Pattern 1" recebia as notas do "Pattern 8", etc.) + // `posA - posB` garante a ordem correta (crescente: P1, P2, P3...). + return posA - posB; + }); + + const patterns = sortedBBTrackNameNodes.map((bbTrack, index) => { const patternNode = allPatternsArray[index]; - const bbTrackName = bbTrack.getAttribute("name") || `Pattern ${index + 1}`; + const bbTrackName = + bbTrack.getAttribute("name") || `Pattern ${index + 1}`; if (!patternNode) { - const firstPattern = allPatternsArray[0]; - const stepsLength = firstPattern ? parseInt(firstPattern.getAttribute("steps"), 10) || 16 : 16; - return { name: bbTrackName, steps: new Array(stepsLength).fill(false), pos: 0 }; + const firstPattern = allPatternsArray[0]; + const stepsLength = firstPattern + ? parseInt(firstPattern.getAttribute("steps"), 10) || 16 + : 16; + return { + name: bbTrackName, + steps: new Array(stepsLength).fill(false), + pos: 0, + }; } - const patternSteps = parseInt(patternNode.getAttribute("steps"), 10) || 16; + const patternSteps = + parseInt(patternNode.getAttribute("steps"), 10) || 16; const steps = new Array(patternSteps).fill(false); const ticksPerStep = 12; patternNode.querySelectorAll("note").forEach((noteNode) => { - const noteLocalPos = parseInt(noteNode.getAttribute("pos"), 10); - const stepIndex = Math.round(noteLocalPos / ticksPerStep); - if (stepIndex < patternSteps) { - steps[stepIndex] = true; - } + const noteLocalPos = parseInt(noteNode.getAttribute("pos"), 10); + const stepIndex = Math.round(noteLocalPos / ticksPerStep); + if (stepIndex < patternSteps) { + steps[stepIndex] = true; + } }); return { - name: bbTrackName, - steps: steps, - pos: parseInt(patternNode.getAttribute("pos"), 10) || 0 + name: bbTrackName, + steps: steps, + pos: parseInt(patternNode.getAttribute("pos"), 10) || 0, }; - }); + }); - const hasNotes = patterns.some(p => p.steps.includes(true)); - if (!hasNotes) return null; + const hasNotes = patterns.some((p) => p.steps.includes(true)); + if (!hasNotes) return null; - const afpNode = instrumentNode.querySelector("audiofileprocessor"); - const sampleSrc = afpNode ? afpNode.getAttribute("src") : null; - let finalSamplePath = null; - if (sampleSrc) { + const afpNode = instrumentNode.querySelector("audiofileprocessor"); + const sampleSrc = afpNode ? afpNode.getAttribute("src") : null; + let finalSamplePath = null; + if (sampleSrc) { const filename = sampleSrc.split("/").pop(); if (pathMap[filename]) { - finalSamplePath = pathMap[filename]; + finalSamplePath = pathMap[filename]; } else { - let cleanSrc = sampleSrc; - if (cleanSrc.startsWith('samples/')) { - cleanSrc = cleanSrc.substring('samples/'.length); - } - finalSamplePath = `src/samples/${cleanSrc}`; + let cleanSrc = sampleSrc; + if (cleanSrc.startsWith("samples/")) { + cleanSrc = cleanSrc.substring("samples/".length); + } + finalSamplePath = `src/samples/${cleanSrc}`; } - } - - const volFromFile = parseFloat(instrumentTrackNode.getAttribute("vol")); - const panFromFile = parseFloat(instrumentTrackNode.getAttribute("pan")); - const firstPatternWithNotesIndex = patterns.findIndex(p => p.steps.includes(true)); + } - return { - id: Date.now() + Math.random(), - name: trackName, - samplePath: finalSamplePath, - patterns: patterns, - activePatternIndex: firstPatternWithNotesIndex !== -1 ? firstPatternWithNotesIndex : 0, - volume: !isNaN(volFromFile) ? volFromFile / 100 : DEFAULT_VOLUME, - pan: !isNaN(panFromFile) ? panFromFile / 100 : DEFAULT_PAN, - instrumentName: instrumentNode.getAttribute("name"), - instrumentXml: instrumentNode.innerHTML, - }; - }).filter(track => track !== null); + const volFromFile = parseFloat(instrumentTrackNode.getAttribute("vol")); + const panFromFile = parseFloat(instrumentTrackNode.getAttribute("pan")); + const firstPatternWithNotesIndex = patterns.findIndex((p) => + p.steps.includes(true) + ); + + return { + id: Date.now() + Math.random(), + name: trackName, + samplePath: finalSamplePath, + patterns: patterns, + + // --- INÍCIO DA CORREÇÃO --- + // ANTES: + // activePatternIndex: + // firstPatternWithNotesIndex !== -1 ? firstPatternWithNotesIndex : 0, // + + // DEPOIS (force o Padrão 1): + activePatternIndex: 0, + // --- FIM DA CORREÇÃO --- + + volume: !isNaN(volFromFile) ? volFromFile / 100 : DEFAULT_VOLUME, + pan: !isNaN(panFromFile) ? panFromFile / 100 : DEFAULT_PAN, + instrumentName: instrumentNode.getAttribute("name"), + instrumentXml: instrumentNode.innerHTML, + }; + }) + .filter((track) => track !== null); let isFirstTrackWithNotes = true; - newTracks.forEach(track => { + newTracks.forEach((track) => { // --- INÍCIO DA CORREÇÃO --- + // (Esta parte já existia no seu arquivo, mantida) // Agora usando Volume em dB (Opção B) track.volumeNode = new Tone.Volume(Tone.gainToDb(track.volume)); track.pannerNode = new Tone.Panner(track.pan); @@ -211,14 +346,17 @@ export async function parseMmpContent(xmlString) { const firstPatternSteps = activePattern.steps.length; const stepsPerBar = 16; const requiredBars = Math.ceil(firstPatternSteps / stepsPerBar); - document.getElementById("bars-input").value = requiredBars > 0 ? requiredBars : 1; + document.getElementById("bars-input").value = + requiredBars > 0 ? requiredBars : 1; isFirstTrackWithNotes = false; } } }); try { - const trackLoadPromises = newTracks.map(track => loadAudioForTrack(track)); + const trackLoadPromises = newTracks.map((track) => + loadAudioForTrack(track) + ); await Promise.all(trackLoadPromises); } catch (error) { console.error("Ocorreu um erro ao carregar os áudios do projeto:", error); @@ -226,13 +364,87 @@ export async function parseMmpContent(xmlString) { appState.pattern.tracks = newTracks; appState.pattern.activeTrackId = appState.pattern.tracks[0]?.id || null; + // --- INÍCIO DA CORREÇÃO --- + // Define o estado global para também ser o Padrão 1 (índice 0) + appState.pattern.activePatternIndex = 0; + // --- FIM DA CORREÇÃO --- - // força atualização total da UI e dos editores de pattern - await Promise.resolve(); // garante que os tracks estejam no estado + // --- A MÁGICA DO F5 (Versão 2.0 - Corrigida) --- + try { + const roomName = window.ROOM_NAME || "default_room"; + const tempStateJSON = sessionStorage.getItem(`temp_state_${roomName}`); + + if (tempStateJSON) { + console.log("Restaurando estado temporário da sessão (pós-F5)..."); + const tempState = JSON.parse(tempStateJSON); + + // NÃO FAÇA: appState.pattern = tempState.pattern; (Isso apaga os Tone.js nodes) + + // EM VEZ DISSO, FAÇA O "MERGE" (MESCLAGEM): + + // 1. Mescla os 'tracks' + // Itera nos tracks "vivos" (com nós de áudio) que acabamos de criar + appState.pattern.tracks.forEach((liveTrack) => { + // Encontra o track salvo correspondente + const savedTrack = tempState.pattern.tracks.find( + (t) => t.id === liveTrack.id + ); + + if (savedTrack) { + // Copia os dados do 'savedTrack' para o 'liveTrack' + liveTrack.name = savedTrack.name; + liveTrack.patterns = savedTrack.patterns; + liveTrack.activePatternIndex = savedTrack.activePatternIndex; + liveTrack.volume = savedTrack.volume; + liveTrack.pan = savedTrack.pan; + + // ATUALIZA OS NÓS DO TONE.JS com os valores salvos! + if (liveTrack.volumeNode) { + liveTrack.volumeNode.volume.value = Tone.gainToDb( + savedTrack.volume + ); + } + if (liveTrack.pannerNode) { + liveTrack.pannerNode.pan.value = savedTrack.pan; + } + } + }); + + // 2. Remove tracks "vivos" que não existem mais no estado salvo + // (Ex: se o usuário deletou um track antes de dar F5) + appState.pattern.tracks = appState.pattern.tracks.filter((liveTrack) => + tempState.pattern.tracks.some((t) => t.id === liveTrack.id) + ); + + // 3. Restaura valores globais da UI + document.getElementById("bpm-input").value = tempState.global.bpm; + document.getElementById("compasso-a-input").value = + tempState.global.compassoA; + document.getElementById("compasso-b-input").value = + tempState.global.compassoB; + document.getElementById("bars-input").value = tempState.global.bars; + + // 4. Restaura o ID do track ativo + appState.pattern.activeTrackId = tempState.pattern.activeTrackId; + + console.log("Estado da sessão restaurado com sucesso."); + } + } catch (e) { + console.error( + "Erro ao restaurar estado da sessão (pode estar corrompido)", + e + ); + if (window.ROOM_NAME) { + sessionStorage.removeItem(`temp_state_${window.ROOM_NAME}`); + } + } + // --- FIM DA MÁGICA (V2.0) --- + + // Agora sim, renderiza com o estado CORRIGIDO E MESCLADO + await Promise.resolve(); renderAll(); - console.log('[UI] Projeto renderizado após parseMmpContent'); - + console.log("[UI] Projeto renderizado após parseMmpContent"); } export function generateMmpFile() { @@ -247,12 +459,14 @@ export function generateMmpFile() { // Copiada de generateMmpFile/modifyAndSaveExistingMmp function generateXmlFromState() { if (!appState.global.originalXmlDoc) { - // Se não houver XML original, precisamos gerar um novo - // Por simplicidade, para este fix, vamos retornar o estado atual do LMMS - // mas o ideal seria gerar o XML completo (como generateNewMmp) - console.warn("Não há XML original para modificar. Usando a base atual do appState."); - // No seu caso, use o conteúdo de generateNewMmp() - return ""; + // Se não houver XML original, precisamos gerar um novo + // Por simplicidade, para este fix, vamos retornar o estado atual do LMMS + // mas o ideal seria gerar o XML completo (como generateNewMmp) + console.warn( + "Não há XML original para modificar. Usando a base atual do appState." + ); + // No seu caso, use o conteúdo de generateNewMmp() + return ""; } const xmlDoc = appState.global.originalXmlDoc.cloneNode(true); @@ -260,14 +474,29 @@ function generateXmlFromState() { if (head) { head.setAttribute("bpm", document.getElementById("bpm-input").value); head.setAttribute("num_bars", document.getElementById("bars-input").value); - head.setAttribute("timesig_numerator", document.getElementById("compasso-a-input").value); - head.setAttribute("timesig_denominator", document.getElementById("compasso-b-input").value); + head.setAttribute( + "timesig_numerator", + document.getElementById("compasso-a-input").value + ); + head.setAttribute( + "timesig_denominator", + document.getElementById("compasso-b-input").value + ); } - const bbTrackContainer = xmlDoc.querySelector('track[type="1"] > bbtrack > trackcontainer'); + const bbTrackContainer = xmlDoc.querySelector( + 'track[type="1"] > bbtrack > trackcontainer' + ); if (bbTrackContainer) { - bbTrackContainer.querySelectorAll('track[type="0"]').forEach(node => node.remove()); - const tracksXml = appState.pattern.tracks.map((track) => createTrackXml(track)).join(""); - const tempDoc = new DOMParser().parseFromString(`${tracksXml}`, "application/xml"); + bbTrackContainer + .querySelectorAll('track[type="0"]') + .forEach((node) => node.remove()); + const tracksXml = appState.pattern.tracks + .map((track) => createTrackXml(track)) + .join(""); + const tempDoc = new DOMParser().parseFromString( + `${tracksXml}`, + "application/xml" + ); Array.from(tempDoc.documentElement.children).forEach((newTrackNode) => { bbTrackContainer.appendChild(newTrackNode); }); @@ -282,34 +511,39 @@ function generateXmlFromState() { * Deve ser chamado APÓS alterações significativas no padrão (steps, tracks). */ export function syncPatternStateToServer() { - if (!window.ROOM_NAME) return; // Não faz nada se não estiver em sala + if (!window.ROOM_NAME) return; + const currentXml = generateXmlFromState(); - const currentXml = generateXmlFromState(); - - // NOTA: Usamos um novo tipo de ação para não confundir com o carregamento de arquivo - sendAction({ - type: 'SYNC_PATTERN_STATE', - xml: currentXml - }); + sendAction({ + type: "SYNC_PATTERN_STATE", + xml: currentXml, + }); + + // Salva o estado localmente também! + saveStateToSession(); // <-- ADICIONE ISSO } function createTrackXml(track) { if (track.patterns.length === 0) return ""; - const ticksPerStep = 12; + const ticksPerStep = 12; const lmmsVolume = Math.round(track.volume * 100); const lmmsPan = Math.round(track.pan * 100); - const patternsXml = track.patterns.map(pattern => { - const patternNotes = pattern.steps.map((isActive, index) => { - if (isActive) { + const patternsXml = track.patterns + .map((pattern) => { + const patternNotes = pattern.steps + .map((isActive, index) => { + if (isActive) { const notePos = Math.round(index * ticksPerStep); return ``; - } - return ""; - }).join("\n "); - return ` + } + return ""; + }) + .join("\n "); + return ` ${patternNotes} `; - }).join('\n '); + }) + .join("\n "); return ` @@ -329,14 +563,29 @@ function modifyAndSaveExistingMmp() { if (head) { head.setAttribute("bpm", document.getElementById("bpm-input").value); head.setAttribute("num_bars", document.getElementById("bars-input").value); - head.setAttribute("timesig_numerator", document.getElementById("compasso-a-input").value); - head.setAttribute("timesig_denominator", document.getElementById("compasso-b-input").value); + head.setAttribute( + "timesig_numerator", + document.getElementById("compasso-a-input").value + ); + head.setAttribute( + "timesig_denominator", + document.getElementById("compasso-b-input").value + ); } - const bbTrackContainer = xmlDoc.querySelector('track[type="1"] > bbtrack > trackcontainer'); + const bbTrackContainer = xmlDoc.querySelector( + 'track[type="1"] > bbtrack > trackcontainer' + ); if (bbTrackContainer) { - bbTrackContainer.querySelectorAll('track[type="0"]').forEach(node => node.remove()); - const tracksXml = appState.pattern.tracks.map((track) => createTrackXml(track)).join(""); - const tempDoc = new DOMParser().parseFromString(`${tracksXml}`, "application/xml"); + bbTrackContainer + .querySelectorAll('track[type="0"]') + .forEach((node) => node.remove()); + const tracksXml = appState.pattern.tracks + .map((track) => createTrackXml(track)) + .join(""); + const tempDoc = new DOMParser().parseFromString( + `${tracksXml}`, + "application/xml" + ); Array.from(tempDoc.documentElement.children).forEach((newTrackNode) => { bbTrackContainer.appendChild(newTrackNode); }); @@ -351,7 +600,9 @@ function generateNewMmp() { const sig_num = document.getElementById("compasso-a-input").value; const sig_den = document.getElementById("compasso-b-input").value; const num_bars = document.getElementById("bars-input").value; - const tracksXml = appState.pattern.tracks.map((track) => createTrackXml(track)).join(""); + const tracksXml = appState.pattern.tracks + .map((track) => createTrackXml(track)) + .join(""); const mmpContent = ` @@ -391,3 +642,5 @@ function downloadFile(content, fileName) { document.body.removeChild(a); URL.revokeObjectURL(url); } + +export { BLANK_PROJECT_XML }; diff --git a/assets/js/creations/main.js b/assets/js/creations/main.js index 2e40fc63..291e8d05 100644 --- a/assets/js/creations/main.js +++ b/assets/js/creations/main.js @@ -1,12 +1,12 @@ // js/main.js (ESM com import absoluto de socket.js + ROOM_NAME local) -import { appState, resetProjectState } from "./state.js"; +import { appState } from "./state.js"; import { updateTransportLoop, restartAudioEditorIfPlaying, } from "./audio/audio_audio.js"; import { initializeAudioContext } from "./audio.js"; -import { handleFileLoad, generateMmpFile } from "./file.js"; +import { handleFileLoad, generateMmpFile, BLANK_PROJECT_XML } from "./file.js"; import { renderAll, loadAndRenderSampleBrowser, @@ -16,12 +16,15 @@ import { import { renderAudioEditor } from "./audio/audio_ui.js"; import { adjustValue, enforceNumericInput } from "./utils.js"; import { ZOOM_LEVELS } from "./config.js"; -import { loadProjectFromServer } from "./file.js" +import { loadProjectFromServer } from "./file.js"; // ⚠️ IMPORT ABSOLUTO para evitar 404/text/html quando a página estiver em /creation/ ou fora dela. // Ajuste o prefixo abaixo para o caminho real onde seus assets vivem no servidor: import { sendAction, joinRoom, setUserName } from "./socket.js"; +import { renderActivePatternToBlob } from "./pattern/pattern_audio.js"; // <-- ADICIONE ESTA LINHA +import { showToast } from "./ui.js"; + // Descobre a sala pela URL (local ao main.js) e expõe no window para debug const ROOM_NAME = new URLSearchParams(window.location.search).get("room"); window.ROOM_NAME = ROOM_NAME; @@ -29,17 +32,18 @@ window.ROOM_NAME = ROOM_NAME; const PROJECT_NAME = new URLSearchParams(window.location.search).get("project"); if (PROJECT_NAME) { - // O nome do projeto deve corresponder ao arquivo no servidor, por ex: "mmp/nome-do-seu-projeto-salvo.mmp" - // O arquivo 'file.js' já espera que loadProjectFromServer receba apenas o nome - // do arquivo dentro da pasta 'mmp/' (ex: 'nome-do-projeto.mmp'). - console.log(`[MAIN] Carregando projeto do servidor: ${PROJECT_NAME}`); - // Adicione a extensão se ela não estiver no link - const filename = PROJECT_NAME.endsWith('.mmp') || PROJECT_NAME.endsWith('.mmpz') - ? PROJECT_NAME - : `${PROJECT_NAME}.mmp`; - - // Chama a função de file.js para carregar (que já envia a ação 'LOAD_PROJECT') - loadProjectFromServer(filename); + // O nome do projeto deve corresponder ao arquivo no servidor, por ex: "mmp/nome-do-seu-projeto-salvo.mmp" + // O arquivo 'file.js' já espera que loadProjectFromServer receba apenas o nome + // do arquivo dentro da pasta 'mmp/' (ex: 'nome-do-projeto.mmp'). + console.log(`[MAIN] Carregando projeto do servidor: ${PROJECT_NAME}`); + // Adicione a extensão se ela não estiver no link + const filename = + PROJECT_NAME.endsWith(".mmp") || PROJECT_NAME.endsWith(".mmpz") + ? PROJECT_NAME + : `${PROJECT_NAME}.mmp`; + + // Chama a função de file.js para carregar (que já envia a ação 'LOAD_PROJECT') + loadProjectFromServer(filename); } // ✅ NOVO: se tem sala na URL, entra já na sala (independe do áudio) @@ -106,6 +110,96 @@ document.addEventListener("DOMContentLoaded", () => { const zoomOutBtn = document.getElementById("zoom-out-btn"); const deleteClipBtn = document.getElementById("delete-clip"); + //envia pattern pro editor de áudio + + const bouncePatternBtn = document.getElementById( + "send-pattern-to-playlist-btn" + ); + + bouncePatternBtn?.addEventListener("click", async () => { + // 1. Verifica se existe uma pista de áudio para onde enviar + const targetTrackId = appState.audio.tracks[0]?.id; + if (!targetTrackId) { + showToast( + "Crie uma Pista de Áudio (no editor de amostras) primeiro!", + "error" + ); + return; + } + + showToast("Renderizando pattern...", "info"); + + try { + // 2. Chama a função de renderização que criamos + const audioBlob = await renderActivePatternToBlob(); + + // 3. Cria uma URL local para o áudio renderizado + const audioUrl = URL.createObjectURL(audioBlob); + + // --- INÍCIO DA NOVA MODIFICAÇÃO (Visualização de Steps) --- + + // 4. Pega o índice do pattern que foi renderizado + const activePatternIndex = + appState.pattern.tracks[0]?.activePatternIndex || 0; + + // 5. Coleta os dados de steps de CADA trilha para esse pattern + const patternData = appState.pattern.tracks.map((track) => { + const pattern = track.patterns[activePatternIndex]; + // Retorna o array de steps, ou um array vazio se não houver + return pattern && pattern.steps ? pattern.steps : []; + }); + // --- FIM DA NOVA MODIFICAÇÃO --- + + // 6. Prepara o nome e ID + const patternName = + appState.pattern.tracks[0]?.patterns[activePatternIndex]?.name || + "Pattern"; + const clipName = `${patternName} (Bounced).wav`; + const clipId = `bounced_${Date.now()}`; + + // 7. Envia a ação (a lógica ADD_AUDIO_CLIP já sabe como carregar o áudio) + sendAction({ + type: "ADD_AUDIO_CLIP", + filePath: audioUrl, // O player de áudio sabe ler essa URL 'blob:' + trackId: targetTrackId, // Envia para a primeira pista de áudio + startTimeInSeconds: 0, // Coloca no início da timeline + clipId: clipId, + name: clipName, + patternData: patternData, // <-- AQUI ESTÁ A "PARTITURA" + }); + + showToast("Pattern enviada para a Pista de Áudio!", "success"); + } catch (error) { + console.error("Erro ao renderizar pattern:", error); + showToast("Erro ao renderizar pattern", "error"); + } + }); + + //Seleção de pattern + + const globalPatternSelector = document.getElementById( + "global-pattern-selector" + ); + + // Adiciona o novo "ouvinte" de evento para o seletor de pattern + globalPatternSelector?.addEventListener("change", () => { + // Pega o novo índice (ex: 0, 1, 2...) do seletor + const newPatternIndex = parseInt(globalPatternSelector.value, 10); + + // --- CORREÇÃO DE LÓGICA (não precisamos mais do activeTrackId) --- + // A ação agora é global e afeta TODAS as tracks. + if (isNaN(newPatternIndex)) { + console.warn("Não é possível trocar pattern: índice inválido."); + return; + } + + // Envia a ação para todos (incluindo você) + sendAction({ + type: "SET_ACTIVE_PATTERN", + patternIndex: newPatternIndex, + }); + }); + // ================================================================= // 👇 INÍCIO DA CORREÇÃO (Botão de Sincronia - Agora envia Ação) // ================================================================= @@ -193,7 +287,7 @@ document.addEventListener("DOMContentLoaded", () => { ) ) return; - sendAction({ type: "RESET_PROJECT" }); + sendAction({ type: "LOAD_PROJECT", xml: BLANK_PROJECT_XML }); }); addBarBtn?.addEventListener("click", () => { @@ -399,7 +493,7 @@ document.addEventListener("DOMContentLoaded", () => { alert( `Você já está na sala: ${currentParams.get( "room" - )}\n\nCopie o link da barra de endereços para convidar.` + )}\n\Copie o link da barra de endereços para convidar.` ); return; } diff --git a/assets/js/creations/pattern/pattern_audio.js b/assets/js/creations/pattern/pattern_audio.js index 71356379..5bf37e2b 100644 --- a/assets/js/creations/pattern/pattern_audio.js +++ b/assets/js/creations/pattern/pattern_audio.js @@ -6,13 +6,17 @@ import { highlightStep } from "./pattern_ui.js"; import { getTotalSteps } from "../utils.js"; import { initializeAudioContext } from "../audio.js"; -const timerDisplay = document.getElementById('timer-display'); +const timerDisplay = document.getElementById("timer-display"); function formatTime(milliseconds) { const totalSeconds = Math.floor(milliseconds / 1000); - const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0'); - const seconds = (totalSeconds % 60).toString().padStart(2, '0'); - const centiseconds = Math.floor((milliseconds % 1000) / 10).toString().padStart(2, '0'); + const minutes = Math.floor(totalSeconds / 60) + .toString() + .padStart(2, "0"); + const seconds = (totalSeconds % 60).toString().padStart(2, "0"); + const centiseconds = Math.floor((milliseconds % 1000) / 10) + .toString() + .padStart(2, "0"); return `${minutes}:${seconds}:${centiseconds}`; } @@ -26,7 +30,9 @@ export function playMetronomeSound(isDownbeat) { // Dispara o sample de uma track, garantindo que o player esteja roteado corretamente export function playSample(filePath, trackId) { initializeAudioContext(); - const track = trackId ? appState.pattern.tracks.find((t) => t.id == trackId) : null; + const track = trackId + ? appState.pattern.tracks.find((t) => t.id == trackId) + : null; // Se a faixa existe e tem um player pré-carregado if (track && track.player) { @@ -41,7 +47,9 @@ export function playSample(filePath, trackId) { } // Garante conexão: player -> volumeNode (não usar mais gainNode) - try { track.player.disconnect(); } catch {} + try { + track.player.disconnect(); + } catch {} if (track.volumeNode) { track.player.connect(track.volumeNode); } @@ -49,7 +57,9 @@ export function playSample(filePath, trackId) { // Dispara imediatamente track.player.start(Tone.now()); } else { - console.warn(`Player da trilha "${track.name}" ainda não carregado — pulando este tick.`); + console.warn( + `Player da trilha "${track.name}" ainda não carregado — pulando este tick.` + ); } } // Fallback para preview de sample sem trackId @@ -66,7 +76,10 @@ function tick() { } const totalSteps = getTotalSteps(); - const lastStepIndex = appState.global.currentStep === 0 ? totalSteps - 1 : appState.global.currentStep - 1; + const lastStepIndex = + appState.global.currentStep === 0 + ? totalSteps - 1 + : appState.global.currentStep - 1; highlightStep(lastStepIndex, false); const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120; @@ -78,10 +91,13 @@ function tick() { // Metrônomo if (appState.global.metronomeEnabled) { - const noteValue = parseInt(document.getElementById("compasso-b-input").value, 10) || 4; + const noteValue = + parseInt(document.getElementById("compasso-b-input").value, 10) || 4; const stepsPerBeat = 16 / noteValue; if (appState.global.currentStep % stepsPerBeat === 0) { - playMetronomeSound(appState.global.currentStep % (stepsPerBeat * 4) === 0); + playMetronomeSound( + appState.global.currentStep % (stepsPerBeat * 4) === 0 + ); } } @@ -92,9 +108,11 @@ function tick() { // IMPORTANTE: usar o pattern ativo da PRÓPRIA TRILHA const activePattern = track.patterns[track.activePatternIndex]; - if (activePattern && - activePattern.steps[appState.global.currentStep] && - track.samplePath) { + if ( + activePattern && + activePattern.steps[appState.global.currentStep] && + track.samplePath + ) { playSample(track.samplePath, track.id); } }); @@ -115,7 +133,8 @@ export function startPlayback() { Tone.Transport.bpm.value = bpm; const stepInterval = (60 * 1000) / (bpm * 4); - if (appState.global.playbackIntervalId) clearInterval(appState.global.playbackIntervalId); + if (appState.global.playbackIntervalId) + clearInterval(appState.global.playbackIntervalId); appState.global.isPlaying = true; const playBtn = document.getElementById("play-btn"); @@ -135,9 +154,11 @@ export function stopPlayback() { appState.global.playbackIntervalId = null; appState.global.isPlaying = false; - document.querySelectorAll('.step.playing').forEach(s => s.classList.remove('playing')); + document + .querySelectorAll(".step.playing") + .forEach((s) => s.classList.remove("playing")); appState.global.currentStep = 0; - if (timerDisplay) timerDisplay.textContent = '00:00:00'; + if (timerDisplay) timerDisplay.textContent = "00:00:00"; const playBtn = document.getElementById("play-btn"); if (playBtn) { @@ -147,10 +168,13 @@ export function stopPlayback() { } export function rewindPlayback() { - const lastStep = appState.global.currentStep > 0 ? appState.global.currentStep - 1 : getTotalSteps() - 1; + const lastStep = + appState.global.currentStep > 0 + ? appState.global.currentStep - 1 + : getTotalSteps() - 1; appState.global.currentStep = 0; if (!appState.global.isPlaying) { - if (timerDisplay) timerDisplay.textContent = '00:00:00'; + if (timerDisplay) timerDisplay.textContent = "00:00:00"; highlightStep(lastStep, false); } } @@ -164,3 +188,165 @@ export function togglePlayback() { startPlayback(); } } + +// ========================================================================= +// FUNÇÃO CORRIGIDA v3: Renderizar o Pattern atual para um Blob de Áudio +// ========================================================================= + +export async function renderActivePatternToBlob() { + initializeAudioContext(); // Garante que o contexto de áudio principal existe + + // 1. Obter configs atuais + const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120; + const totalSteps = getTotalSteps(); + const stepInterval = 60 / (bpm * 4); // Duração de 1 step (em segundos) + const duration = totalSteps * stepInterval; // Duração total em segundos + + // 2. Descobrir qual pattern está ativo (assume que todos estão no mesmo) + const activePatternIndex = + appState.pattern.tracks[0]?.activePatternIndex || 0; + + // 3. Renderizar offline usando Tone.Offline + const buffer = await Tone.Offline(async () => { + // ---------------------------------------------------- + // Contexto de Áudio OFFLINE + // ---------------------------------------------------- + const masterGain = new Tone.Gain().toDestination(); + + // --- INÍCIO DA CORREÇÃO (Lógica de Polifonia) --- + + // 1. Criamos as 'Parts'. + const offlineTracksParts = appState.pattern.tracks + .map((track) => { + const pattern = track.patterns[activePatternIndex]; + + // Verificação crucial: Precisamos do 'track.buffer' (áudio carregado) + if (!pattern || !track.buffer || !pattern.steps.includes(true)) { + return null; // Pula trilha se não tiver áudio ou notas + } + + // Obtém o buffer de áudio (que já está carregado) + const trackBuffer = track.buffer; + + // Cria a cadeia de áudio (Volume/Pan) para esta *trilha* + const panner = new Tone.Panner(track.pan).connect(masterGain); + const volume = new Tone.Volume(Tone.gainToDb(track.volume)).connect( + panner + ); + + // Cria a lista de eventos (tempos em que as notas devem tocar) + const events = []; + pattern.steps.forEach((isActive, stepIndex) => { + if (isActive) { + const time = stepIndex * stepInterval; + events.push(time); + } + }); + + // Cria a Tone.Part + const part = new Tone.Part((time) => { + // *** ESTA É A CORREÇÃO CRÍTICA *** + // Para cada nota (cada 'time' na lista de 'events'), + // nós criamos um PLAYER "ONE-SHOT" (descartável). + // Isso permite que vários sons da mesma trilha + // se sobreponham (polifonia). + + new Tone.Player(trackBuffer) // Usa o buffer carregado + .connect(volume) // Conecta na cadeia de áudio (Volume->Pan->Master) + .start(time); // Toca no tempo agendado + }, events); // Passa a lista de tempos [0, 0.25, 0.5, ...] + + return part; // Retorna a Part (que sabe quando disparar) + }) + .filter((t) => t !== null); // Remove trilhas nulas + + // 2. Como estamos usando buffers já carregados, + // não precisamos esperar (remover 'await Tone.loaded()') + + // 3. Agenda todas as 'Parts' para começar + offlineTracksParts.forEach((part) => { + part.start(0); + }); + // --- FIM DA CORREÇÃO --- + + // Define o BPM do transporte offline + Tone.Transport.bpm.value = bpm; + + // Inicia o transporte (para a renderização) + Tone.Transport.start(); + // ---------------------------------------------------- + }, duration); // Duração total da renderização + + // 5. Converte o AudioBuffer resultante em um Blob (arquivo .wav) + const blob = bufferToWave(buffer); + return blob; +} + +// ========================================================================= +// FUNÇÃO UTILITÁRIA: Converte AudioBuffer para Blob WAV +// (Mantenha esta função como está) +// ========================================================================= + +function bufferToWave(abuffer) { + let numOfChan = abuffer.numberOfChannels; + let length = abuffer.length * numOfChan * 2 + 44; + let buffer = new ArrayBuffer(length); + let view = new DataView(buffer); + let channels = [], + i, + sample; + let offset = 0; + let pos = 0; + + // setAll e setString são helpers + function setAll(data) { + for (i = 0; i < data.length; i++) { + view.setUint8(pos + i, data[i]); + } + pos += data.length; + } + function setString(s) { + setAll(s.split("").map((c) => c.charCodeAt(0))); + } + + // Cabeçalho WAV + setString("RIFF"); + view.setUint32(pos, length - 8, true); + pos += 4; + setString("WAVE"); + setString("fmt "); + view.setUint32(pos, 16, true); + pos += 4; // Sub-chunk size + view.setUint16(pos, 1, true); + pos += 2; // Audio format 1 + view.setUint16(pos, numOfChan, true); + pos += 2; + view.setUint32(pos, abuffer.sampleRate, true); + pos += 4; + view.setUint32(pos, abuffer.sampleRate * 2 * numOfChan, true); + pos += 4; // Byte rate + view.setUint16(pos, numOfChan * 2, true); + pos += 2; // Block align + view.setUint16(pos, 16, true); + pos += 2; // Bits per sample + setString("data"); + view.setUint32(pos, length - 44, true); + pos += 4; + + // Pega os dados dos canais + for (i = 0; i < numOfChan; i++) { + channels.push(abuffer.getChannelData(i)); + } + + // Escreve os dados (intercalando canais) + for (i = 0; i < abuffer.length; i++) { + for (let j = 0; j < numOfChan; j++) { + sample = Math.max(-1, Math.min(1, channels[j][i])); + sample = (0.5 + sample * 32767.5) | 0; // Converte para 16-bit PCM + view.setInt16(pos, sample, true); + pos += 2; + } + } + + return new Blob([buffer], { type: "audio/wav" }); +} diff --git a/assets/js/creations/pattern/pattern_ui.js b/assets/js/creations/pattern/pattern_ui.js index caf54f0e..936b1e61 100644 --- a/assets/js/creations/pattern/pattern_ui.js +++ b/assets/js/creations/pattern/pattern_ui.js @@ -98,22 +98,28 @@ export function redrawSequencer() {       sequencerContainer.innerHTML = ""; return;     } - // --- CORRIJA ESTAS DUAS LINHAS --- - // ANTES: - // const activePatternIndex = appState.pattern.activePatternIndex; - // const activePattern = trackData.patterns[activePatternIndex]; - // - // DEPOIS: -    const activePatternIndex = trackData.activePatternIndex; + const activePatternIndex = trackData.activePatternIndex;     const activePattern = trackData.patterns[activePatternIndex];     if (!activePattern) {         sequencerContainer.innerHTML = ""; return;     } -// ... resto da função ... +     const patternSteps = activePattern.steps; -    sequencerContainer.innerHTML = ""; + // --- INÍCIO DA CORREÇÃO --- + // Precisamos verificar se 'patternSteps' é um array real. + // Se for 'null' ou 'undefined' (um bug de dados do .mmp), + // o loop 'for' abaixo quebraria ANTES de limpar a UI. + if (!patternSteps || !Array.isArray(patternSteps)) { + // Limpa a UI (remove os steps antigos) + sequencerContainer.innerHTML = ""; + // E para a execução desta track, deixando o sequenciador vazio. + return; + } + // --- FIM DA CORREÇÃO --- + +    sequencerContainer.innerHTML = ""; // Agora é seguro limpar a UI     for (let i = 0; i < totalGridSteps; i++) {       const stepWrapper = document.createElement("div");       stepWrapper.className = "step-wrapper"; @@ -163,29 +169,6 @@ export function redrawSequencer() {   }); } -export function updateGlobalPatternSelector() { -    const globalPatternSelector = document.getElementById('global-pattern-selector'); -    if (!globalPatternSelector) return; - -    const referenceTrack = appState.pattern.tracks[0]; -    globalPatternSelector.innerHTML = ''; -    if (referenceTrack && referenceTrack.patterns.length > 0) { -        referenceTrack.patterns.forEach((pattern, index) => { -            const option = document.createElement('option'); -            option.value = index; -            option.textContent = pattern.name; -            globalPatternSelector.appendChild(option); -        }); -        globalPatternSelector.selectedIndex = appState.pattern.activePatternIndex; -        globalPatternSelector.disabled = false; -    } else { -        const option = document.createElement('option'); -        option.textContent = 'Sem patterns'; -        globalPatternSelector.appendChild(option); -        globalPatternSelector.disabled = true; -    } -} - export function highlightStep(stepIndex, isActive) {   if (stepIndex < 0) return;   document.querySelectorAll(".track-lane").forEach((track) => { @@ -201,13 +184,26 @@ export function highlightStep(stepIndex, isActive) {   }); } -// (V7) Função de UI "cirúrgica" export function updateStepUI(trackIndex, patternIndex, stepIndex, isActive) { - if (patternIndex !== appState.pattern.activePatternIndex) { - return; - } + // --- INÍCIO DA CORREÇÃO --- + // A lógica antiga (if (patternIndex !== appState.pattern.activePatternIndex)) + // estava errada, pois usava uma variável global. + const trackElement = document.querySelector(`.track-lane[data-track-index="${trackIndex}"]`); if (!trackElement) return; + + const trackData = appState.pattern.tracks[trackIndex]; + if (!trackData) return; + + // A UI só deve ser atualizada cirurgicamente se o pattern clicado + // for o MESMO pattern que está VISÍVEL no sequenciador dessa trilha. + if (patternIndex !== trackData.activePatternIndex) { + // O estado mudou, mas não é o pattern que estamos vendo, + // então não faz nada na UI (mas o estado no appState está correto). + return; + } + // --- FIM DA CORREÇÃO --- + const stepWrapper = trackElement.querySelector( `.step-sequencer .step-wrapper:nth-child(${stepIndex + 1})` ); @@ -215,4 +211,47 @@ export function updateStepUI(trackIndex, patternIndex, stepIndex, isActive) { const stepElement = stepWrapper.querySelector(".step"); if (!stepElement) return; stepElement.classList.toggle("active", isActive); +} + +export function updateGlobalPatternSelector() { + const globalPatternSelector = document.getElementById('global-pattern-selector'); + if (!globalPatternSelector) return; + + // 1. Encontra a track que está ATIVA no momento + const activeTrackId = appState.pattern.activeTrackId; + const activeTrack = appState.pattern.tracks.find(t => t.id === activeTrackId); + + // 2. Usa a track[0] como referência para os NOMES dos patterns + const referenceTrack = appState.pattern.tracks[0]; + + globalPatternSelector.innerHTML = ''; // Limpa as anteriores + + if (referenceTrack && referenceTrack.patterns.length > 0) { + + // 3. Popula a lista de