diff --git a/assets/css/style.css b/assets/css/style.css index c136f745..be2cbeae 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -186,6 +186,52 @@ body.sidebar-hidden .sample-browser { border: 1px solid var(--border-color-light); } +/* Estilo para o novo menu de contexto da régua */ +#ruler-context-menu { + display: none; + position: fixed; + z-index: 10000; + background-color: var(--background-dark); + border: 1px solid var(--border-color-dark); + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + font-size: 0.8rem; + padding: 5px 0; + min-width: 150px; +} + +#ruler-context-menu > div { + padding: 6px 15px; + cursor: pointer; + color: var(--text-light); +} + +#ruler-context-menu > div:hover { + background-color: var(--accent-blue); + color: #fff; +} + +/* Estilo para clipes "recortados" (cut) */ +.timeline-clip.cut { + opacity: 0.5; + border-style: dashed; +} + +/* =============================================== */ +/* SALA COMPARTILHADA +/* =============================================== */ +#create-room-btn { + width: auto; /* Permite que o botão se ajuste ao texto */ + padding-left: 12px; + padding-right: 16px; + color: var(--text-light); /* Ou a cor que preferir */ +} + +#create-room-btn:hover { + background-color: var(--accent-color); /* Destaque ao passar o mouse */ + color: var(--text-dark); +} + /* =============================================== */ /* EDITOR DE BASES (BEAT EDITOR / STEP SEQUENCER) /* =============================================== */ diff --git a/assets/js/creations/audio.js b/assets/js/creations/audio.js index 4dfed836..2cdee54a 100644 --- a/assets/js/creations/audio.js +++ b/assets/js/creations/audio.js @@ -1,5 +1,7 @@ // js/audio.js +import * as Tone from "https://esm.sh/tone"; + // O contexto de áudio agora será gerenciado principalmente pelo Tone.js. // Esta função garante que ele seja iniciado por uma interação do usuário. export function initializeAudioContext() { @@ -9,10 +11,13 @@ export function initializeAudioContext() { } } -// Funções de acesso ao contexto global do Tone.js +// ✅ DEPOIS: devolve o *raw* AudioContext (nativo do Web Audio) export function getAudioContext() { - return Tone.context; + // compatível com versões novas/antigas do Tone + const ctx = typeof Tone.getContext === 'function' ? Tone.getContext() : Tone.context; + return ctx.rawContext || ctx; // rawContext quando existir } + export function getMainGainNode() { return Tone.Destination; } diff --git a/assets/js/creations/audio/audio_audio.js b/assets/js/creations/audio/audio_audio.js index 0c8e8e99..a92d9807 100644 --- a/assets/js/creations/audio/audio_audio.js +++ b/assets/js/creations/audio/audio_audio.js @@ -1,8 +1,10 @@ // js/audio/audio_audio.js import { appState } from "../state.js"; import { updateAudioEditorUI, updatePlayheadVisual, resetPlayheadVisual } from "./audio_ui.js"; -import { initializeAudioContext, getAudioContext } from "../audio.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"; // --- Configurações do Scheduler --- const LOOKAHEAD_INTERVAL_MS = 25.0; @@ -16,16 +18,17 @@ let animationFrameId = null; // Sincronização de Tempo let startTime = 0; -let seekTime = 0; -let logicalPlaybackTime = 0; +// (seek/logical ficam em appState.audio) // Configurações de Loop let isLoopActive = false; let loopStartTimeSec = 0; let loopEndTimeSec = 8; +// estado runtime const runtimeClipState = new Map(); -const scheduledNodes = new Map(); +// ⚠️ agora armazenamos Tone.Player em vez de BufferSource +const scheduledNodes = new Map(); // eventId -> { player, clipId } let nextEventId = 0; const callbacks = { @@ -42,49 +45,78 @@ 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() { if (!audioCtx) { initializeAudioContext(); - audioCtx = getAudioContext(); + audioCtx = getAudioContext(); // deve ser o rawContext do Tone } } -// --- Lógica Principal do Scheduler (sem alterações) --- +// helper: normaliza AudioBuffer → ToneAudioBuffer (mesmo contexto) +function _toToneBuffer(buffer) { + if (!buffer) return null; + if (buffer._buffer) return buffer; // já é Tone.ToneAudioBuffer + const tab = new Tone.ToneAudioBuffer(); + tab._buffer = buffer; // injeta o AudioBuffer (já no rawContext do Tone) + return tab; +} + +// --- Lógica Principal do Scheduler (mantida) --- function _scheduleClip(clip, absolutePlayTime, durationSec) { if (!clip.buffer) { console.warn(`Clip ${clip.id} não possui áudio buffer carregado.`); return; } - if (!clip.gainNode || !clip.pannerNode) { - console.warn(`Clip ${clip.id} não possui gainNode ou pannerNode.`); - return; - } - const source = new Tone.BufferSource(clip.buffer); - source.connect(clip.gainNode); - - // --- CORREÇÃO: Aplica o pitch (que pode ser de stretch ou wheel) --- - if (clip.pitch && clip.pitch !== 0) { - source.playbackRate.value = Math.pow(2, clip.pitch / 12); - } else { - source.playbackRate.value = 1.0; // Garante que o modo 'trim' toque normal - } - // --- FIM DA CORREÇÃO --- + // usamos Player .sync() conectando no mesmo grafo do Tone + const toneBuf = _toToneBuffer(clip.buffer); + 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); + + // conecta no destino principal (é um ToneAudioNode) + try { + gain.disconnect(); // evita duplicatas caso exista de execuções anteriores + } catch {} + try { + pan.disconnect(); + } catch {} + gain.connect(pan).connect(getMainGainNode()); + + // player sincronizado no Transport + 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; + 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 offset = clip.offsetInSeconds ?? clip.offset ?? 0; + const dur = durationSec ?? toneBuf.duration; + + // agenda + player.start(occurrenceInTransportSec, offset, dur); const eventId = nextEventId++; - const clipOffset = clip.offsetInSeconds || clip.offset || 0; - source.start(absolutePlayTime, clipOffset, durationSec); - scheduledNodes.set(eventId, { sourceNode: source, clipId: clip.id }); + scheduledNodes.set(eventId, { player, clipId: clip.id }); if (callbacks.onClipScheduled) { callbacks.onClipScheduled(clip); } - source.onended = () => { + // quando parar naturalmente, limpamos runtime + player.onstop = () => { _handleClipEnd(eventId, clip.id); - source.dispose(); + try { player.unsync(); } catch {} + try { player.dispose(); } catch {} }; } @@ -94,7 +126,7 @@ function _handleClipEnd(eventId, clipId) { if (callbacks.onClipPlayed) { const clip = appState.audio.clips.find(c => c.id == clipId); - if(clip) callbacks.onClipPlayed(clip); + if (clip) callbacks.onClipPlayed(clip); } } @@ -102,26 +134,18 @@ function _schedulerTick() { if (!isPlaying || !audioCtx) return; const now = audioCtx.currentTime; - const logicalTime = (now - startTime) + seekTime; + const logicalTime = (now - startTime) + (appState.audio.audioEditorSeekTime || 0); const scheduleWindowStartSec = logicalTime; const scheduleWindowEndSec = logicalTime + SCHEDULE_AHEAD_TIME_SEC; for (const clip of appState.audio.clips) { const clipRuntime = runtimeClipState.get(clip.id) || { isScheduled: false }; - - if (clipRuntime.isScheduled) { - continue; - } - if (!clip.buffer) { - continue; - } + if (clipRuntime.isScheduled) continue; + if (!clip.buffer) continue; 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; @@ -137,11 +161,12 @@ function _schedulerTick() { occurrenceStartTimeSec += loopsMissed * loopDuration; } } + if ( occurrenceStartTimeSec >= scheduleWindowStartSec && occurrenceStartTimeSec < scheduleWindowEndSec ) { - const absolutePlayTime = startTime + (occurrenceStartTimeSec - seekTime); + const absolutePlayTime = startTime + (occurrenceStartTimeSec - (appState.audio.audioEditorSeekTime || 0)); _scheduleClip(clip, absolutePlayTime, clipDurationSec); clipRuntime.isScheduled = true; runtimeClipState.set(clip.id, clipRuntime); @@ -149,23 +174,26 @@ function _schedulerTick() { } } -// --- Loop de Animação (sem alterações) --- +// --- Loop de Animação (mantido) --- function _animationLoop() { if (!isPlaying) { animationFrameId = null; return; } const now = audioCtx.currentTime; - let newLogicalTime = (now - startTime) + seekTime; + let newLogicalTime = (now - startTime) + (appState.audio.audioEditorSeekTime || 0); + if (isLoopActive) { if (newLogicalTime >= loopEndTimeSec) { const loopDuration = loopEndTimeSec - loopStartTimeSec; newLogicalTime = loopStartTimeSec + ((newLogicalTime - loopStartTimeSec) % loopDuration); startTime = now; - seekTime = newLogicalTime; + appState.audio.audioEditorSeekTime = newLogicalTime; } } - logicalPlaybackTime = newLogicalTime; + + appState.audio.audioEditorLogicalTime = newLogicalTime; + if (!isLoopActive) { let maxTime = 0; appState.audio.clips.forEach(clip => { @@ -174,15 +202,14 @@ function _animationLoop() { const endTime = clipStartTime + clipDuration; if (endTime > maxTime) maxTime = endTime; }); - - if (maxTime > 0 && logicalPlaybackTime >= maxTime) { + if (maxTime > 0 && appState.audio.audioEditorLogicalTime >= maxTime) { stopAudioEditorPlayback(true); // Rebobina no fim resetPlayheadVisual(); return; } } const pixelsPerSecond = getPixelsPerSecond(); - const newPositionPx = logicalPlaybackTime * pixelsPerSecond; + const newPositionPx = appState.audio.audioEditorLogicalTime * pixelsPerSecond; updatePlayheadVisual(newPositionPx); animationFrameId = requestAnimationFrame(_animationLoop); } @@ -196,79 +223,111 @@ export function updateTransportLoop() { runtimeClipState.clear(); - scheduledNodes.forEach(nodeData => { - // --- CORREÇÃO BUG 1: Remove a linha 'onended = null' --- - nodeData.sourceNode.stop(0); - nodeData.sourceNode.dispose(); + // parar e descartar players agendados + scheduledNodes.forEach(({ player }) => { + try { player.unsync(); } catch {} + try { player.stop(); } catch {} + try { player.dispose(); } catch {} }); scheduledNodes.clear(); } -export function startAudioEditorPlayback() { +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') { - audioCtx.resume(); + await audioCtx.resume(); } isPlaying = true; - // --- CORREÇÃO BUG 2: Atualiza o estado global --- appState.global.isAudioEditorPlaying = true; - + + // alinhamento de relógio próprio (mantido para o seu scheduler) startTime = audioCtx.currentTime; + + // ================================================================= + // 👇 INÍCIO DA CORREÇÃO (Bugs 1 & 2) + // ================================================================= - updateTransportLoop(); + // 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 + + // 2. Atualize o estado global (para a agulha pular) + // Isso garante que o estado local E o Tone estejam sincronizados. + appState.audio.audioEditorSeekTime = timeToStart; + + // 3. Alinhe o Tone.Transport a esse tempo + try { + Tone.Transport.seconds = timeToStart; // 👈 Usa o tempo sincronizado + } catch {} + // ================================================================= + // 👆 FIM DA CORREÇÃO + // ================================================================= + + updateTransportLoop(); + console.log("%cIniciando Playback...", "color: #3498db;"); - _schedulerTick(); + // inicia o Transport (para disparar os Players .sync()) + try { + Tone.Transport.start(); + } catch {} + + // mantém seu scheduler/animador + _schedulerTick(); schedulerIntervalId = setInterval(_schedulerTick, LOOKAHEAD_INTERVAL_MS); animationFrameId = requestAnimationFrame(_animationLoop); updateAudioEditorUI(); const playBtn = document.getElementById("audio-editor-play-btn"); - if (playBtn) { - playBtn.className = 'fa-solid fa-pause'; - } + if (playBtn) playBtn.className = "fa-solid fa-pause"; } export function stopAudioEditorPlayback(rewind = false) { if (!isPlaying) return; isPlaying = false; - // --- CORREÇÃO BUG 2: Atualiza o estado global --- appState.global.isAudioEditorPlaying = false; console.log(`%cParando Playback... (Rewind: ${rewind})`, "color: #d9534f;"); + // para o Transport (para Players .sync()) + try { Tone.Transport.stop(); } catch {} + clearInterval(schedulerIntervalId); schedulerIntervalId = null; cancelAnimationFrame(animationFrameId); animationFrameId = null; - seekTime = logicalPlaybackTime; - logicalPlaybackTime = 0; - + appState.audio.audioEditorSeekTime = appState.audio.audioEditorLogicalTime || 0; + appState.audio.audioEditorLogicalTime = 0; if (rewind) { - seekTime = 0; + appState.audio.audioEditorSeekTime = 0; + try { Tone.Transport.seconds = 0; } catch {} } - scheduledNodes.forEach(nodeData => { - // --- CORREÇÃO BUG 1: Remove a linha 'onended = null' --- - nodeData.sourceNode.stop(0); - nodeData.sourceNode.dispose(); + // parar e descartar players agendados + scheduledNodes.forEach(({ player }) => { + 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(); + resetPlayheadVisual(); } } @@ -284,21 +343,26 @@ export function seekAudioEditor(newTime) { if (wasPlaying) { stopAudioEditorPlayback(false); // Pausa } - seekTime = newTime; - logicalPlaybackTime = newTime; + + appState.audio.audioEditorSeekTime = newTime; + appState.audio.audioEditorLogicalTime = newTime; + + try { Tone.Transport.seconds = newTime; } catch {} + const pixelsPerSecond = getPixelsPerSecond(); const newPositionPx = newTime * pixelsPerSecond; updatePlayheadVisual(newPositionPx); + if (wasPlaying) { startAudioEditorPlayback(); } } export function registerCallbacks(newCallbacks) { - if (newCallbacks.onClipScheduled) { - callbacks.onClipScheduled = newCallbacks.onClipScheduled; - } - if (newCallbacks.onClipPlayed) { - callbacks.onClipPlayed = newCallbacks.onClipPlayed; - } -} \ No newline at end of file + if (newCallbacks.onClipScheduled) { + callbacks.onClipScheduled = newCallbacks.onClipScheduled; + } + if (newCallbacks.onClipPlayed) { + callbacks.onClipPlayed = newCallbacks.onClipPlayed; + } +} diff --git a/assets/js/creations/audio/audio_clipboard.js b/assets/js/creations/audio/audio_clipboard.js new file mode 100644 index 00000000..20d0b1b2 --- /dev/null +++ b/assets/js/creations/audio/audio_clipboard.js @@ -0,0 +1,118 @@ +// js/audio/audio_clipboard.js +import { appState } from '../state.js'; +import { removeAudioClip, loadAudioForClip } from './audio_state.js'; +import { renderAudioEditor } from './audio_ui.js'; +import { getMainGainNode } from '../audio.js'; + +/** + * Copia o clipe selecionado para a área de transferência global. + */ +export function copyAudioClip() { + const clipId = appState.global.selectedClipId; + if (!clipId) return; + + const clip = appState.audio.clips.find(c => c.id == clipId); + if (!clip) return; + + // Remove a marca de "recortado" se houver + if (appState.global.clipboard?.cutSourceId) { + appState.global.clipboard.cutSourceId = null; + } + + // Cria uma cópia limpa dos dados do clipe + const clipData = { ...clip }; + // Remove referências a nós de áudio, que devem ser únicos + delete clipData.gainNode; + delete clipData.pannerNode; + delete clipData.player; + + appState.global.clipboard = { + type: 'audio', + clip: clipData, + cutSourceId: null + }; + + console.log("Clipe copiado:", appState.global.clipboard.clip.name); + renderAudioEditor(); // Re-renderiza para remover o visual "cut" +} + +/** + * Recorta o clipe selecionado para a área de transferência global. + */ +export function cutAudioClip() { + const clipId = appState.global.selectedClipId; + if (!clipId) return; + + // Limpa o "cut" anterior + if (appState.global.clipboard?.cutSourceId) { + appState.global.clipboard.cutSourceId = null; + } + + const clip = appState.audio.clips.find(c => c.id == clipId); + if (!clip) return; + + // Cria uma cópia limpa dos dados do clipe + const clipData = { ...clip }; + delete clipData.gainNode; + delete clipData.pannerNode; + delete clipData.player; + + appState.global.clipboard = { + type: 'audio', + clip: clipData, + cutSourceId: clipId // Marca o ID original para exclusão + }; + + console.log("Clipe recortado:", appState.global.clipboard.clip.name); + renderAudioEditor(); // Re-renderiza para adicionar o visual "cut" +} + +/** + * Cola o clipe da área de transferência na timeline. + * @param {number} targetTrackId - O ID da pista onde colar. + * @param {number} targetTimeInSeconds - O tempo (em segundos) onde colar. + */ +export function pasteAudioClip(targetTrackId, targetTimeInSeconds) { + const clipboard = appState.global.clipboard; + if (!clipboard || clipboard.type !== 'audio' || !clipboard.clip) { + console.warn("Área de transferência vazia ou inválida para colar áudio."); + return; + } + + const clipToPaste = clipboard.clip; + + // Se for um "recorte", primeiro remove o clipe original + if (clipboard.cutSourceId) { + removeAudioClip(clipboard.cutSourceId); // + clipboard.cutSourceId = null; // Limpa para que não remova de novo + } + + // Cria um novo clipe a partir dos dados copiados + const newClip = { + ...clipToPaste, + id: Date.now() + Math.random(), // ID novo e único + trackId: targetTrackId, + startTimeInSeconds: targetTimeInSeconds, + + // Cria novos nós de áudio + gainNode: new Tone.Gain(Tone.gainToDb(clipToPaste.volume || 1.0)), + pannerNode: new Tone.Panner(clipToPaste.pan || 0), + + player: null, + // O buffer será copiado/referenciado + }; + + newClip.gainNode.connect(newClip.pannerNode); + newClip.pannerNode.connect(getMainGainNode()); // + + appState.audio.clips.push(newClip); + + // Como o buffer já deve existir no clipe original, + // não precisamos de 'loadAudioForClip', mas chamamos + // para garantir (caso a fonte seja um 'sourcePath'). + // A função 'loadAudioForClip' precisa ser inteligente + // para não recarregar se o buffer já existir. + loadAudioForClip(newClip).then(() => { // + renderAudioEditor(); + }); +} \ No newline at end of file diff --git a/assets/js/creations/audio/audio_state.js b/assets/js/creations/audio/audio_state.js index 5cb416d1..4fe23016 100644 --- a/assets/js/creations/audio/audio_state.js +++ b/assets/js/creations/audio/audio_state.js @@ -1,18 +1,89 @@ -// js/audio_state.js +// js/audio/audio_state.js import { DEFAULT_VOLUME, DEFAULT_PAN } from "../config.js"; import { renderAudioEditor } from "./audio_ui.js"; -import { getMainGainNode } from "../audio.js"; -import { getAudioContext } from "../audio.js"; +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, }; +// ==== 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 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) + startTimeInSeconds: c.startTimeInSeconds || 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), + })); + + return { tracks, clips }; +} + +// ==== SNAPSHOT: aplicação do estado recebido ==== +export async function applyAudioSnapshot(snapshot) { + if (!snapshot) return; + + // 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 })); + } + + // 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; + + // 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); + + // aplica propriedades adicionais (dur/offset/pitch/vol/pan) no mesmo 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; + + // reflete nos nós Tone já criados + if (clip.gainNode) clip.gainNode.gain.value = clip.volume ?? 1; + if (clip.pannerNode) clip.pannerNode.pan.value = clip.pan ?? 0; + } + } + } + + // re-render geral do editor + renderAudioEditor(); +} + + export function initializeAudioState() { audioState.clips.forEach(clip => { if (clip.pannerNode) clip.pannerNode.dispose(); @@ -21,6 +92,10 @@ export function initializeAudioState() { Object.assign(audioState, { tracks: [], clips: [], + // --- ADICIONADO --- + audioEditorSeekTime: 0, + audioEditorLogicalTime: 0, + // --- FIM --- audioEditorStartTime: 0, audioEditorAnimationId: null, audioEditorPlaybackTime: 0, @@ -29,6 +104,16 @@ export function initializeAudioState() { } 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; + } + // --- FIM DA ADIÇÃO --- + if (!clip.sourcePath) return clip; const audioCtx = getAudioContext(); @@ -58,51 +143,82 @@ export async function loadAudioForClip(clip) { return clip; } -export function addAudioClipToTimeline(samplePath, trackId = 1, startTime = 0, clipName = null) { +// helper de id (fallback se o emissor não mandar) +function genClipId() { + 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; + + // 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; + } + + 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; + } + const newClip = { - id: Date.now() + Math.random(), + id: finalId, trackId: trackId, - sourcePath: samplePath, - - // --- MODIFICAÇÃO AQUI --- - // Usa o nome fornecido, ou extrai do caminho se não for fornecido - name: clipName || samplePath.split('/').pop(), + 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, // Será preenchido pelo loadAudioForClip + originalDuration: 0, pitch: 0, volume: DEFAULT_VOLUME, pan: DEFAULT_PAN, - - gainNode: new Tone.Gain(Tone.gainToDb(DEFAULT_VOLUME)), - pannerNode: new Tone.Panner(DEFAULT_PAN), - buffer: null, + buffer: existingBuffer || null, player: null, }; + // volume linear (0–1) + newClip.gainNode = new Tone.Gain(DEFAULT_VOLUME); + newClip.pannerNode = new Tone.Panner(DEFAULT_PAN); + + // conecta tudo no grafo do Tone (mesmo contexto) newClip.gainNode.connect(newClip.pannerNode); newClip.pannerNode.connect(getMainGainNode()); 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 => c.id == clipId); + 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 => c.id == clipId); + const originalClip = audioState.clips.find(c => String(c.id) == String(clipId)); if (!originalClip || sliceTimeInTimeline <= originalClip.startTimeInSeconds || @@ -115,7 +231,7 @@ export function sliceAudioClip(clipId, sliceTimeInTimeline) { const cutPointInClip = sliceTimeInTimeline - originalClip.startTimeInSeconds; const newClip = { - id: Date.now() + Math.random(), + id: genClipId(), trackId: originalClip.trackId, sourcePath: originalClip.sourcePath, name: originalClip.name, @@ -132,7 +248,7 @@ export function sliceAudioClip(clipId, sliceTimeInTimeline) { volume: originalClip.volume, pan: originalClip.pan, - gainNode: new Tone.Gain(Tone.gainToDb(originalClip.volume)), + gainNode: new Tone.Gain(originalClip.volume), pannerNode: new Tone.Panner(originalClip.pan), player: null @@ -148,20 +264,19 @@ export function sliceAudioClip(clipId, sliceTimeInTimeline) { console.log("Clipe dividido. Original:", originalClip, "Novo:", newClip); } -// ... (resto do arquivo 'audio_state.js' sem alterações) ... export function updateClipVolume(clipId, volume) { - const clip = audioState.clips.find((c) => c.id == clipId); + const clip = audioState.clips.find((c) => String(c.id) == String(clipId)); if (clip) { const clampedVolume = Math.max(0, Math.min(1.5, volume)); clip.volume = clampedVolume; if (clip.gainNode) { - clip.gainNode.gain.value = Tone.gainToDb(clampedVolume); + clip.gainNode.gain.value = clampedVolume; } } } export function updateClipPan(clipId, pan) { - const clip = audioState.clips.find((c) => c.id == clipId); + const clip = audioState.clips.find((c) => String(c.id) == String(clipId)); if (clip) { const clampedPan = Math.max(-1, Math.min(1, pan)); clip.pan = clampedPan; @@ -177,19 +292,19 @@ export function addAudioTrackLane() { } export function removeAudioClip(clipId) { - const clipIndex = audioState.clips.findIndex(c => c.id == clipId); + 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]; // 1. Limpa os nós de áudio do Tone.js if (clip.gainNode) { - clip.gainNode.disconnect(); - clip.gainNode.dispose(); + try { clip.gainNode.disconnect(); } catch {} + try { clip.gainNode.dispose(); } catch {} } if (clip.pannerNode) { - clip.pannerNode.disconnect(); - clip.pannerNode.dispose(); + try { clip.pannerNode.disconnect(); } catch {} + try { clip.pannerNode.dispose(); } catch {} } // 2. Remove o clipe do array de estado @@ -197,4 +312,4 @@ export function removeAudioClip(clipId) { // 3. Retorna true para o chamador (Controller) return true; -} \ No newline at end of file +} diff --git a/assets/js/creations/audio/audio_ui.js b/assets/js/creations/audio/audio_ui.js index 3ae0072f..71b50b74 100644 --- a/assets/js/creations/audio/audio_ui.js +++ b/assets/js/creations/audio/audio_ui.js @@ -1,615 +1,819 @@ // js/audio/audio_ui.js import { appState } from "../state.js"; -import { - addAudioClipToTimeline, - updateAudioClipProperties, - sliceAudioClip, +import { + addAudioClipToTimeline, + updateAudioClipProperties, + sliceAudioClip, + removeAudioClip, } from "./audio_state.js"; -import { seekAudioEditor, restartAudioEditorIfPlaying, updateTransportLoop } from "./audio_audio.js"; +import { + seekAudioEditor, + restartAudioEditorIfPlaying, + updateTransportLoop, +} from "./audio_audio.js"; import { drawWaveform } from "../waveform.js"; import { PIXELS_PER_BAR, PIXELS_PER_STEP, ZOOM_LEVELS } from "../config.js"; -import { getPixelsPerSecond, quantizeTime, getBeatsPerBar, getSecondsPerStep } from "../utils.js"; +import { + getPixelsPerSecond, + quantizeTime, + getBeatsPerBar, + getSecondsPerStep, +} from "../utils.js"; +import { sendAction } from "../socket.js"; export function renderAudioEditor() { - const audioEditor = document.querySelector('.audio-editor'); - const existingTrackContainer = document.getElementById('audio-track-container'); - if (!audioEditor || !existingTrackContainer) return; + const audioEditor = document.querySelector(".audio-editor"); + const existingTrackContainer = document.getElementById( + "audio-track-container" + ); + if (!audioEditor || !existingTrackContainer) return; - // --- CRIAÇÃO E RENDERIZAÇÃO DA RÉGUA --- - let rulerWrapper = audioEditor.querySelector('.ruler-wrapper'); - if (!rulerWrapper) { - rulerWrapper = document.createElement('div'); - rulerWrapper.className = 'ruler-wrapper'; - rulerWrapper.innerHTML = ` + // --- CRIAÇÃO E RENDERIZAÇÃO DA RÉGUA --- + let rulerWrapper = audioEditor.querySelector(".ruler-wrapper"); + if (!rulerWrapper) { + rulerWrapper = document.createElement("div"); + rulerWrapper.className = "ruler-wrapper"; + rulerWrapper.innerHTML = `
`; - audioEditor.insertBefore(rulerWrapper, existingTrackContainer); + audioEditor.insertBefore(rulerWrapper, existingTrackContainer); + } + + const ruler = rulerWrapper.querySelector(".timeline-ruler"); + ruler.innerHTML = ""; + + const pixelsPerSecond = getPixelsPerSecond(); + + let maxTime = appState.global.loopEndTime; + appState.audio.clips.forEach((clip) => { + const endTime = + (clip.startTimeInSeconds || 0) + (clip.durationInSeconds || 0); + if (endTime > maxTime) maxTime = endTime; + }); + + const containerWidth = existingTrackContainer.offsetWidth; + const contentWidth = maxTime * pixelsPerSecond; + const totalWidth = Math.max(contentWidth, containerWidth, 2000); + + ruler.style.width = `${totalWidth}px`; + + const zoomFactor = ZOOM_LEVELS[appState.global.zoomLevelIndex]; + const beatsPerBar = getBeatsPerBar(); + const stepWidthPx = PIXELS_PER_STEP * zoomFactor; + const beatWidthPx = stepWidthPx * 4; + const barWidthPx = beatWidthPx * beatsPerBar; + + if (barWidthPx > 0) { + const numberOfBars = Math.ceil(totalWidth / barWidthPx); + for (let i = 1; i <= numberOfBars; i++) { + const marker = document.createElement("div"); + marker.className = "ruler-marker"; + marker.textContent = i; + marker.style.left = `${(i - 1) * barWidthPx}px`; + ruler.appendChild(marker); + } + } + + const loopRegion = document.createElement("div"); + loopRegion.id = "loop-region"; + loopRegion.style.left = `${ + appState.global.loopStartTime * pixelsPerSecond + }px`; + loopRegion.style.width = `${ + (appState.global.loopEndTime - appState.global.loopStartTime) * + pixelsPerSecond + }px`; + loopRegion.innerHTML = `
`; + loopRegion.classList.toggle("visible", appState.global.isLoopActive); + ruler.appendChild(loopRegion); + + // --- LISTENER DA RÉGUA (MODIFICADO para enviar Ações de Loop/Seek) --- + const newRuler = ruler.cloneNode(true); + ruler.parentNode.replaceChild(newRuler, ruler); + + newRuler.addEventListener("mousedown", (e) => { + // Esconde menus + document.getElementById("timeline-context-menu").style.display = "none"; + document.getElementById("ruler-context-menu").style.display = "none"; + + const currentPixelsPerSecond = getPixelsPerSecond(); + const loopHandle = e.target.closest(".loop-handle"); + const loopRegionBody = e.target.closest("#loop-region:not(.loop-handle)"); + + // Drag Handle Loop + if (loopHandle) { + e.preventDefault(); + e.stopPropagation(); + const handleType = loopHandle.classList.contains("left") + ? "left" + : "right"; + const initialMouseX = e.clientX; + const initialStart = appState.global.loopStartTime; + const initialEnd = appState.global.loopEndTime; + const onMouseMove = (moveEvent) => { + const deltaX = moveEvent.clientX - initialMouseX; + const deltaTime = deltaX / currentPixelsPerSecond; + let newStart = appState.global.loopStartTime; + let newEnd = appState.global.loopEndTime; + if (handleType === "left") { + newStart = Math.max(0, initialStart + deltaTime); + newStart = Math.min(newStart, appState.global.loopEndTime - 0.1); + appState.global.loopStartTime = newStart; + } else { + newEnd = Math.max( + appState.global.loopStartTime + 0.1, + initialEnd + deltaTime + ); + appState.global.loopEndTime = newEnd; + } + updateTransportLoop(); + const loopRegionEl = newRuler.querySelector("#loop-region"); + if (loopRegionEl) { + loopRegionEl.style.left = `${newStart * currentPixelsPerSecond}px`; + loopRegionEl.style.width = `${ + (newEnd - newStart) * currentPixelsPerSecond + }px`; + } + }; + const onMouseUp = () => { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + // ================================================================= + // 👇 INÍCIO DA CORREÇÃO (Sincronia de Loop Drag Handle) + // ================================================================= + sendAction({ + type: "SET_LOOP_STATE", + isLoopActive: appState.global.isLoopActive, + loopStartTime: appState.global.loopStartTime, + loopEndTime: appState.global.loopEndTime, + }); + // renderAudioEditor(); // Removido + // ================================================================= + // 👆 FIM DA CORREÇÃO + }; + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + return; } - const ruler = rulerWrapper.querySelector('.timeline-ruler'); - ruler.innerHTML = ''; - - const pixelsPerSecond = getPixelsPerSecond(); - - let maxTime = appState.global.loopEndTime; - appState.audio.clips.forEach(clip => { - const endTime = (clip.startTimeInSeconds || 0) + (clip.durationInSeconds || 0); - if (endTime > maxTime) maxTime = endTime; - }); - - const containerWidth = existingTrackContainer.offsetWidth; - const contentWidth = maxTime * pixelsPerSecond; - const totalWidth = Math.max(contentWidth, containerWidth, 2000); - - ruler.style.width = `${totalWidth}px`; - - const zoomFactor = ZOOM_LEVELS[appState.global.zoomLevelIndex]; - const beatsPerBar = getBeatsPerBar(); - const stepWidthPx = PIXELS_PER_STEP * zoomFactor; - const beatWidthPx = stepWidthPx * 4; - const barWidthPx = beatWidthPx * beatsPerBar; - - if (barWidthPx > 0) { - const numberOfBars = Math.ceil(totalWidth / barWidthPx); - for (let i = 1; i <= numberOfBars; i++) { - const marker = document.createElement('div'); - marker.className = 'ruler-marker'; - marker.textContent = i; - marker.style.left = `${(i - 1) * barWidthPx}px`; - ruler.appendChild(marker); - } + // Drag Body Loop + if (loopRegionBody) { + e.preventDefault(); + e.stopPropagation(); + const initialMouseX = e.clientX; + const initialStart = appState.global.loopStartTime; + const initialEnd = appState.global.loopEndTime; + const initialDuration = initialEnd - initialStart; + const onMouseMove = (moveEvent) => { + const deltaX = moveEvent.clientX - initialMouseX; + const deltaTime = deltaX / currentPixelsPerSecond; + let newStart = Math.max(0, initialStart + deltaTime); + let newEnd = newStart + initialDuration; + appState.global.loopStartTime = newStart; + appState.global.loopEndTime = newEnd; + updateTransportLoop(); + const loopRegionEl = newRuler.querySelector("#loop-region"); + if (loopRegionEl) + loopRegionEl.style.left = `${newStart * currentPixelsPerSecond}px`; + }; + const onMouseUp = () => { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + // ================================================================= + // 👇 INÍCIO DA CORREÇÃO (Sincronia de Loop Drag Body) + // ================================================================= + sendAction({ + type: "SET_LOOP_STATE", + isLoopActive: appState.global.isLoopActive, + loopStartTime: appState.global.loopStartTime, + loopEndTime: appState.global.loopEndTime, + }); + // renderAudioEditor(); // Removido + // ================================================================= + // 👆 FIM DA CORREÇÃO + }; + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + return; } - const loopRegion = document.createElement('div'); - loopRegion.id = 'loop-region'; - loopRegion.style.left = `${appState.global.loopStartTime * pixelsPerSecond}px`; - loopRegion.style.width = `${(appState.global.loopEndTime - appState.global.loopStartTime) * pixelsPerSecond}px`; - loopRegion.innerHTML = `
`; - loopRegion.classList.toggle("visible", appState.global.isLoopActive); - ruler.appendChild(loopRegion); + // Seek na Régua + e.preventDefault(); + const handleSeek = (event) => { + const rect = newRuler.getBoundingClientRect(); + const scrollLeft = newRuler.scrollLeft; + const clickX = event.clientX - rect.left; + const absoluteX = clickX + scrollLeft; + const newTime = absoluteX / currentPixelsPerSecond; + // ================================================================= + // 👇 INÍCIO DA CORREÇÃO (Sincronia de Seek na Régua) + // ================================================================= + sendAction({ type: "SET_SEEK_TIME", seekTime: newTime }); + // seekAudioEditor(newTime); // 👈 Substituído + // ================================================================= + // 👆 FIM DA CORREÇÃO + }; + handleSeek(e); // Aplica no mousedown + const onMouseMoveSeek = (moveEvent) => handleSeek(moveEvent); + const onMouseUpSeek = () => { + document.removeEventListener("mousemove", onMouseMoveSeek); + document.removeEventListener("mouseup", onMouseUpSeek); + }; + document.addEventListener("mousemove", onMouseMoveSeek); + document.addEventListener("mouseup", onMouseUpSeek); + }); - // --- LISTENER DA RÉGUA (sem alterações) --- - ruler.addEventListener('mousedown', (e) => { - const currentPixelsPerSecond = getPixelsPerSecond(); - const loopHandle = e.target.closest('.loop-handle'); - const loopRegionBody = e.target.closest('#loop-region:not(.loop-handle)'); + // Menu Contexto Régua (sem alterações) + newRuler.addEventListener("contextmenu", (e) => { + e.preventDefault(); + document.getElementById("timeline-context-menu").style.display = "none"; + const menu = document.getElementById("ruler-context-menu"); + const currentPixelsPerSecond = getPixelsPerSecond(); + const rect = newRuler.getBoundingClientRect(); + const scrollLeft = newRuler.scrollLeft; + const clickX = e.clientX - rect.left; + const absoluteX = clickX + scrollLeft; + const clickTime = absoluteX / currentPixelsPerSecond; + appState.global.lastRulerClickTime = clickTime; + menu.style.display = "block"; + menu.style.left = `${e.clientX}px`; + menu.style.top = `${e.clientY}px`; + }); - if (loopHandle) { /* ... lógica de loop ... */ - e.preventDefault(); e.stopPropagation(); - const handleType = loopHandle.classList.contains('left') ? 'left' : 'right'; - const initialMouseX = e.clientX; - const initialStart = appState.global.loopStartTime; - const initialEnd = appState.global.loopEndTime; + // Recriação Container Pistas (sem alterações) + const newTrackContainer = existingTrackContainer.cloneNode(false); + audioEditor.replaceChild(newTrackContainer, existingTrackContainer); - const onMouseMove = (moveEvent) => { - const deltaX = moveEvent.clientX - initialMouseX; - const deltaTime = deltaX / currentPixelsPerSecond; - let newStart = appState.global.loopStartTime; - let newEnd = appState.global.loopEndTime; - - if (handleType === 'left') { - newStart = Math.max(0, initialStart + deltaTime); - newStart = Math.min(newStart, appState.global.loopEndTime - 0.1); - appState.global.loopStartTime = newStart; - } else { - newEnd = Math.max(appState.global.loopStartTime + 0.1, initialEnd + deltaTime); - appState.global.loopEndTime = newEnd; - } - - updateTransportLoop(); - loopRegion.style.left = `${newStart * currentPixelsPerSecond}px`; - loopRegion.style.width = `${(newEnd - newStart) * currentPixelsPerSecond}px`; - }; - - const onMouseUp = () => { - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - renderAudioEditor(); - }; - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - return; - } - if (loopRegionBody) { /* ... lógica de mover loop ... */ - e.preventDefault(); e.stopPropagation(); - const initialMouseX = e.clientX; - const initialStart = appState.global.loopStartTime; - const initialEnd = appState.global.loopEndTime; - const initialDuration = initialEnd - initialStart; - - const onMouseMove = (moveEvent) => { - const deltaX = moveEvent.clientX - initialMouseX; - const deltaTime = deltaX / currentPixelsPerSecond; - let newStart = Math.max(0, initialStart + deltaTime); - let newEnd = newStart + initialDuration; - appState.global.loopStartTime = newStart; - appState.global.loopEndTime = newEnd; - updateTransportLoop(); - loopRegion.style.left = `${newStart * currentPixelsPerSecond}px`; - }; - - const onMouseUp = () => { - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - renderAudioEditor(); - }; - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - return; - } - - e.preventDefault(); - const handleSeek = (event) => { /* ... lógica de seek ... */ - const rect = ruler.getBoundingClientRect(); - const scrollLeft = ruler.scrollLeft; - const clickX = event.clientX - rect.left; - const absoluteX = clickX + scrollLeft; - const newTime = absoluteX / currentPixelsPerSecond; - seekAudioEditor(newTime); - }; - handleSeek(e); - const onMouseMoveSeek = (moveEvent) => handleSeek(moveEvent); - const onMouseUpSeek = () => { document.removeEventListener('mousemove', onMouseMoveSeek); document.removeEventListener('mouseup', onMouseUpSeek); }; - document.addEventListener('mousemove', onMouseMoveSeek); - document.addEventListener('mouseup', onMouseUpSeek); - }); - - // --- RECRIAÇÃO DO CONTAINER DE PISTAS (sem alterações) --- - const newTrackContainer = existingTrackContainer.cloneNode(false); - audioEditor.replaceChild(newTrackContainer, existingTrackContainer); - - if (appState.audio.tracks.length === 0) { - appState.audio.tracks.push({ id: Date.now(), name: "Pista de Áudio 1" }); - } - - // --- RENDERIZAÇÃO DAS PISTAS INDIVIDUAIS (sem alterações) --- - appState.audio.tracks.forEach(trackData => { - const audioTrackLane = document.createElement('div'); - audioTrackLane.className = 'audio-track-lane'; - audioTrackLane.dataset.trackId = trackData.id; - audioTrackLane.innerHTML = ` + // Render Pistas (sem alterações) + appState.audio.tracks.forEach((trackData) => { + const audioTrackLane = document.createElement("div"); + audioTrackLane.className = "audio-track-lane"; + audioTrackLane.dataset.trackId = trackData.id; + audioTrackLane.innerHTML = `
-
- - ${trackData.name} -
-
+
${trackData.name}
-
-
- VOL -
-
-
- PAN -
+
VOL
+
PAN
-
-
-
- `; - newTrackContainer.appendChild(audioTrackLane); - - const timelineContainer = audioTrackLane.querySelector('.timeline-container'); - - timelineContainer.addEventListener("dragover", (e) => { e.preventDefault(); audioTrackLane.classList.add('drag-over'); }); - timelineContainer.addEventListener("dragleave", () => audioTrackLane.classList.remove('drag-over')); - - timelineContainer.addEventListener("drop", (e) => { - e.preventDefault(); - audioTrackLane.classList.remove('drag-over'); - const filePath = e.dataTransfer.getData("text/plain"); - if (!filePath) return; - const rect = timelineContainer.getBoundingClientRect(); - const dropX = e.clientX - rect.left + timelineContainer.scrollLeft; - let startTimeInSeconds = dropX / pixelsPerSecond; - startTimeInSeconds = quantizeTime(startTimeInSeconds); - addAudioClipToTimeline(filePath, trackData.id, startTimeInSeconds); +
+ `; + newTrackContainer.appendChild(audioTrackLane); + const timelineContainer = audioTrackLane.querySelector( + ".timeline-container" + ); + timelineContainer.addEventListener("dragover", (e) => { + e.preventDefault(); + audioTrackLane.classList.add("drag-over"); + }); + timelineContainer.addEventListener("dragleave", () => + audioTrackLane.classList.remove("drag-over") + ); + timelineContainer.addEventListener("drop", (e) => { + e.preventDefault(); + audioTrackLane.classList.remove("drag-over"); + const filePath = e.dataTransfer.getData("text/plain"); + if (!filePath) return; + const rect = timelineContainer.getBoundingClientRect(); + const dropX = e.clientX - rect.left + timelineContainer.scrollLeft; + let startTimeInSeconds = dropX / pixelsPerSecond; + startTimeInSeconds = quantizeTime(startTimeInSeconds); + if ( + !trackData.id || + startTimeInSeconds == null || + isNaN(startTimeInSeconds) + ) { + console.error("Drop inválido. Ignorando.", { + id: trackData.id, + time: startTimeInSeconds, }); - - const grid = timelineContainer.querySelector('.spectrogram-view-grid'); - grid.style.setProperty('--step-width', `${stepWidthPx}px`); - grid.style.setProperty('--beat-width', `${beatWidthPx}px`); - grid.style.setProperty('--bar-width', `${barWidthPx}px`); - }); - - // --- RENDERIZAÇÃO DOS CLIPS (COM MODIFICAÇÃO PARA SELEÇÃO) --- - appState.audio.clips.forEach(clip => { - const parentGrid = newTrackContainer.querySelector(`.audio-track-lane[data-track-id="${clip.trackId}"] .spectrogram-view-grid`); - if (!parentGrid) return; - - const clipElement = document.createElement('div'); - clipElement.className = 'timeline-clip'; - clipElement.dataset.clipId = clip.id; - - // --- INÍCIO DA MODIFICAÇÃO --- - // Adiciona a classe 'selected' se o ID corresponder ao estado global - if (clip.id === appState.global.selectedClipId) { - clipElement.classList.add('selected'); - } - // --- FIM DA MODIFICAÇÃO --- - - clipElement.style.left = `${(clip.startTimeInSeconds || 0) * pixelsPerSecond}px`; - clipElement.style.width = `${(clip.durationInSeconds || 0) * pixelsPerSecond}px`; - - let pitchStr = clip.pitch > 0 ? `+${clip.pitch.toFixed(1)}` : `${clip.pitch.toFixed(1)}`; - if (clip.pitch === 0) pitchStr = ''; - clipElement.innerHTML = ` -
- ${clip.name} ${pitchStr} - -
- `; - parentGrid.appendChild(clipElement); - - if (clip.buffer) { - const canvas = clipElement.querySelector('.waveform-canvas-clip'); - const canvasWidth = (clip.durationInSeconds || 0) * pixelsPerSecond; - if (canvasWidth > 0) { - canvas.width = canvasWidth; - canvas.height = 40; - const audioBuffer = clip.buffer; - - // --- INÍCIO DA CORREÇÃO --- - // Verifica se o clipe está esticado (pitch != 0) ou aparado (pitch == 0). - const isStretched = clip.pitch !== 0; - - // Se for 'stretch', devemos usar a duração original do buffer como fonte. - // Se for 'trim', usamos a duração e offset atuais do clipe. - const sourceOffset = isStretched ? 0 : (clip.offset || 0); - const sourceDuration = isStretched ? clip.originalDuration : clip.durationInSeconds; - - // Chama drawWaveform para desenhar o segmento-fonte (source) - // dentro do canvas (que já tem a largura de destino). - drawWaveform(canvas, audioBuffer, 'var(--accent-green)', sourceOffset, sourceDuration); - // --- FIM DA CORREÇÃO --- - } - } - - clipElement.addEventListener('wheel', (e) => { - e.preventDefault(); - const clipToUpdate = appState.audio.clips.find(c => c.id == clipElement.dataset.clipId); - if (!clipToUpdate) return; - const direction = e.deltaY < 0 ? 1 : -1; - let newPitch = clipToUpdate.pitch + direction; - newPitch = Math.max(-24, Math.min(24, newPitch)); - updateAudioClipProperties(clipToUpdate.id, { pitch: newPitch }); - renderAudioEditor(); - restartAudioEditorIfPlaying(); + return; + } + const clipId = + crypto?.randomUUID?.() || + `clip_${Date.now()}_${Math.floor(Math.random() * 1e6)}`; + addAudioClipToTimeline( + filePath, + trackData.id, + startTimeInSeconds, + clipId + ); + try { + sendAction({ + type: "ADD_AUDIO_CLIP", + filePath, + trackId: trackData.id, + startTimeInSeconds, + clipId, + name: String(filePath).split(/[\\/]/).pop(), }); + } catch (err) { + console.warn("[SYNC] Falha ao emitir ADD_AUDIO_CLIP", err); + } }); - - // --- SINCRONIZAÇÃO DE SCROLL (sem alterações) --- - newTrackContainer.addEventListener('scroll', () => { - const scrollPos = newTrackContainer.scrollLeft; - if (ruler.scrollLeft !== scrollPos) { - ruler.scrollLeft = scrollPos; - } - }); + const grid = timelineContainer.querySelector(".spectrogram-view-grid"); + grid.style.setProperty("--step-width", `${stepWidthPx}px`); + grid.style.setProperty("--beat-width", `${beatWidthPx}px`); + grid.style.setProperty("--bar-width", `${barWidthPx}px`); + }); - // --- EVENT LISTENER PRINCIPAL (COM MODIFICAÇÃO PARA SELEÇÃO) --- - newTrackContainer.addEventListener('mousedown', (e) => { - // --- INÍCIO DA MODIFICAÇÃO --- - // Esconde o menu de contexto se estiver aberto - const menu = document.getElementById('timeline-context-menu'); - if (menu) menu.style.display = 'none'; - - const clipElement = e.target.closest('.timeline-clip'); - - // Desseleciona se clicar fora de um clipe (e não for clique direito) - if (!clipElement && e.button !== 2) { - if (appState.global.selectedClipId) { - appState.global.selectedClipId = null; - - // Remove a classe de todos os clipes (para resposta visual imediata) - newTrackContainer.querySelectorAll('.timeline-clip.selected').forEach(c => { - c.classList.remove('selected'); - }); - } - } - // --- FIM DA MODIFICAÇÃO --- - - const currentPixelsPerSecond = getPixelsPerSecond(); - const handle = e.target.closest('.clip-resize-handle'); - // const clipElement = e.target.closest('.timeline-clip'); // <-- Já definido acima - - if (appState.global.sliceToolActive && clipElement) { - e.preventDefault(); - e.stopPropagation(); - const clipId = clipElement.dataset.clipId; - const timelineContainer = clipElement.closest('.timeline-container'); - const rect = timelineContainer.getBoundingClientRect(); - const clickX = e.clientX - rect.left; - const absoluteX = clickX + timelineContainer.scrollLeft; - let sliceTimeInTimeline = absoluteX / currentPixelsPerSecond; - sliceTimeInTimeline = quantizeTime(sliceTimeInTimeline); - sliceAudioClip(clipId, sliceTimeInTimeline); - renderAudioEditor(); - return; - } - - // --- CORREÇÃO: LÓGICA DE REDIMENSIONAMENTO DIVIDIDA --- - if (handle) { - e.preventDefault(); - e.stopPropagation(); - - const clipId = clipElement.dataset.clipId; - const clip = appState.audio.clips.find(c => c.id == clipId); - if (!clip || !clip.buffer) return; - - const handleType = handle.classList.contains('left') ? 'left' : 'right'; - const initialMouseX = e.clientX; - const secondsPerStep = getSecondsPerStep(); - - const initialLeftPx = clipElement.offsetLeft; - const initialWidthPx = clipElement.offsetWidth; - - const initialStartTime = clip.startTimeInSeconds; - const initialDuration = clip.durationInSeconds; - const initialOffset = clip.offset || 0; - const initialPitch = clip.pitch || 0; - const initialOriginalDuration = clip.originalDuration || clip.buffer.duration; - - // O tempo "zero" absoluto do buffer (seu início) - const bufferStartTime = initialStartTime - initialOffset; - - const onMouseMove = (moveEvent) => { - const deltaX = moveEvent.clientX - initialMouseX; - - // --- MODO 2: TRIMMING --- - if (appState.global.resizeMode === 'trim') { - if (handleType === 'right') { - let newWidthPx = initialWidthPx + deltaX; - let newDuration = newWidthPx / currentPixelsPerSecond; - let newEndTime = quantizeTime(initialStartTime + newDuration); - - newEndTime = Math.max(initialStartTime + secondsPerStep, newEndTime); - - const maxEndTime = bufferStartTime + initialOriginalDuration; - newEndTime = Math.min(newEndTime, maxEndTime); - - clipElement.style.width = `${(newEndTime - initialStartTime) * currentPixelsPerSecond}px`; - - } else if (handleType === 'left') { - let newLeftPx = initialLeftPx + deltaX; - let newStartTime = newLeftPx / currentPixelsPerSecond; - newStartTime = quantizeTime(newStartTime); - - const minStartTime = (initialStartTime + initialDuration) - secondsPerStep; - newStartTime = Math.min(newStartTime, minStartTime); - - newStartTime = Math.max(bufferStartTime, newStartTime); - - const newLeftFinalPx = newStartTime * currentPixelsPerSecond; - const newWidthFinalPx = ((initialStartTime + initialDuration) - newStartTime) * currentPixelsPerSecond; - clipElement.style.left = `${newLeftFinalPx}px`; - clipElement.style.width = `${newWidthFinalPx}px`; - } - } - // --- MODO 1: STRETCHING --- - else if (appState.global.resizeMode === 'stretch') { - if (handleType === 'right') { - let newWidthPx = initialWidthPx + deltaX; - let newDuration = newWidthPx / currentPixelsPerSecond; - let newEndTime = quantizeTime(initialStartTime + newDuration); - - newEndTime = Math.max(initialStartTime + secondsPerStep, newEndTime); - - clipElement.style.width = `${(newEndTime - initialStartTime) * currentPixelsPerSecond}px`; - - } else if (handleType === 'left') { - let newLeftPx = initialLeftPx + deltaX; - let newStartTime = newLeftPx / currentPixelsPerSecond; - newStartTime = quantizeTime(newStartTime); - - const minStartTime = (initialStartTime + initialDuration) - secondsPerStep; - newStartTime = Math.min(newStartTime, minStartTime); - - const newLeftFinalPx = newStartTime * currentPixelsPerSecond; - const newWidthFinalPx = ((initialStartTime + initialDuration) - newStartTime) * currentPixelsPerSecond; - clipElement.style.left = `${newLeftFinalPx}px`; - clipElement.style.width = `${newWidthFinalPx}px`; - } - } - }; - - const onMouseUp = (upEvent) => { - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - - const finalLeftPx = clipElement.offsetLeft; - const finalWidthPx = clipElement.offsetWidth; - - const newStartTime = finalLeftPx / currentPixelsPerSecond; - const newDuration = finalWidthPx / currentPixelsPerSecond; - - // --- MODO 2: TRIMMING --- - if (appState.global.resizeMode === 'trim') { - const newOffset = newStartTime - bufferStartTime; - - if (handleType === 'right') { - updateAudioClipProperties(clipId, { - durationInSeconds: newDuration, - pitch: 0 // Reseta o pitch - }); - } else if (handleType === 'left') { - updateAudioClipProperties(clipId, { - startTimeInSeconds: newStartTime, - durationInSeconds: newDuration, - offset: newOffset, - pitch: 0 // Reseta o pitch - }); - } - } - // --- MODO 1: STRETCHING --- - else if (appState.global.resizeMode === 'stretch') { - // Calcula o novo pitch baseado na mudança de duração - // Usa a duração *do buffer* (originalDuration) como base - const newPlaybackRate = initialOriginalDuration / newDuration; - const newPitch = 12 * Math.log2(newPlaybackRate); - - if (handleType === 'right') { - updateAudioClipProperties(clipId, { - durationInSeconds: newDuration, - pitch: newPitch, - offset: 0 // Stretch sempre reseta o offset - }); - } else if (handleType === 'left') { - updateAudioClipProperties(clipId, { - startTimeInSeconds: newStartTime, - durationInSeconds: newDuration, - pitch: newPitch, - offset: 0 // Stretch sempre reseta o offset - }); - } - } - - restartAudioEditorIfPlaying(); - renderAudioEditor(); - }; - - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - return; - } - // --- FIM DA CORREÇÃO --- - - - if (clipElement) { - // --- INÍCIO DA MODIFICAÇÃO (SELEÇÃO NO DRAG) --- - const clipId = clipElement.dataset.clipId; - // Se o clipe clicado não for o já selecionado, atualiza a seleção - if (appState.global.selectedClipId !== clipId) { - appState.global.selectedClipId = clipId; // Define o estado global - - // Atualiza visualmente - newTrackContainer.querySelectorAll('.timeline-clip.selected').forEach(c => { - c.classList.remove('selected'); - }); - clipElement.classList.add('selected'); - } - // --- FIM DA MODIFICAÇÃO --- - - // Lógica de 'drag' (sem alterações) - e.preventDefault(); - // const clipId = clipElement.dataset.clipId; // <-- Já definido acima - const clickOffsetInClip = e.clientX - clipElement.getBoundingClientRect().left; - clipElement.classList.add('dragging'); - let lastOverLane = clipElement.closest('.audio-track-lane'); - const onMouseMove = (moveEvent) => { - const deltaX = moveEvent.clientX - e.clientX; - clipElement.style.transform = `translateX(${deltaX}px)`; - const overElement = document.elementFromPoint(moveEvent.clientX, moveEvent.clientY); - const overLane = overElement ? overElement.closest('.audio-track-lane') : null; - if (overLane && overLane !== lastOverLane) { - if(lastOverLane) lastOverLane.classList.remove('drag-over'); - overLane.classList.add('drag-over'); - lastOverLane = overLane; - } - }; - const onMouseUp = (upEvent) => { - clipElement.classList.remove('dragging'); - if (lastOverLane) lastOverLane.classList.remove('drag-over'); - clipElement.style.transform = ''; - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - const finalLane = lastOverLane; - if (!finalLane) return; - const newTrackId = finalLane.dataset.trackId; - const timelineContainer = finalLane.querySelector('.timeline-container'); - const wrapperRect = timelineContainer.getBoundingClientRect(); - const newLeftPx = (upEvent.clientX - wrapperRect.left) - clickOffsetInClip + timelineContainer.scrollLeft; - - const constrainedLeftPx = Math.max(0, newLeftPx); - let newStartTime = constrainedLeftPx / currentPixelsPerSecond; - newStartTime = quantizeTime(newStartTime); - - updateAudioClipProperties(clipId, { trackId: Number(newTrackId), startTimeInSeconds: newStartTime }); - renderAudioEditor(); - }; - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - return; - } - - const timelineContainer = e.target.closest('.timeline-container'); - if (timelineContainer) { - // Lógica de 'seek' (sem alterações) - e.preventDefault(); - const handleSeek = (event) => { - const rect = timelineContainer.getBoundingClientRect(); - const scrollLeft = timelineContainer.scrollLeft; - const clickX = event.clientX - rect.left; - const absoluteX = clickX + scrollLeft; - const newTime = absoluteX / currentPixelsPerSecond; - seekAudioEditor(newTime); - }; - handleSeek(e); - const onMouseMoveSeek = (moveEvent) => handleSeek(moveEvent); - const onMouseUpSeek = () => { document.removeEventListener('mousemove', onMouseMoveSeek); document.removeEventListener('mouseup', onMouseUpSeek); }; - document.addEventListener('mousemove', onMouseMoveSeek); - document.addEventListener('mouseup', onMouseUpSeek); - } - }); - - // --- LISTENER ADICIONADO (Menu de Contexto) --- - newTrackContainer.addEventListener('contextmenu', (e) => { - e.preventDefault(); // Impede o menu de contexto padrão do navegador - const menu = document.getElementById('timeline-context-menu'); - if (!menu) return; - - const clipElement = e.target.closest('.timeline-clip'); - - if (clipElement) { - // 1. Seleciona o clipe clicado (define o estado) - const clipId = clipElement.dataset.clipId; - if (appState.global.selectedClipId !== clipId) { - appState.global.selectedClipId = clipId; - - // Atualiza visualmente - newTrackContainer.querySelectorAll('.timeline-clip.selected').forEach(c => { - c.classList.remove('selected'); - }); - clipElement.classList.add('selected'); - } - - // 2. Posiciona e mostra o menu - menu.style.display = 'block'; - menu.style.left = `${e.clientX}px`; - menu.style.top = `${e.clientY}px`; - } else { - // Esconde o menu se clicar com o botão direito fora de um clipe - menu.style.display = 'none'; - } - }); - // --- FIM DO LISTENER ADICIONADO --- -} - -// --- Funções de UI (sem alterações) --- - -export function updateAudioEditorUI() { - const playBtn = document.getElementById('audio-editor-play-btn'); - if (!playBtn) return; - if (appState.global.isAudioEditorPlaying) { - playBtn.classList.remove('fa-play'); - playBtn.classList.add('fa-pause'); - } else { - playBtn.classList.remove('fa-pause'); - playBtn.classList.add('fa-play'); + // Render Clips (sem alterações) + appState.audio.clips.forEach((clip) => { + const parentGrid = newTrackContainer.querySelector( + `.audio-track-lane[data-track-id="${clip.trackId}"] .spectrogram-view-grid` + ); + if (!parentGrid) return; + const clipElement = document.createElement("div"); + clipElement.className = "timeline-clip"; + clipElement.dataset.clipId = clip.id; + if (clip.id === appState.global.selectedClipId) + clipElement.classList.add("selected"); + if (appState.global.clipboard?.cutSourceId === clip.id) + clipElement.classList.add("cut"); + clipElement.style.left = `${ + (clip.startTimeInSeconds || 0) * pixelsPerSecond + }px`; + clipElement.style.width = `${ + (clip.durationInSeconds || 0) * pixelsPerSecond + }px`; + let pitchStr = + clip.pitch > 0 ? `+${clip.pitch.toFixed(1)}` : `${clip.pitch.toFixed(1)}`; + if (clip.pitch === 0) pitchStr = ""; + clipElement.innerHTML = `
${clip.name} ${pitchStr}
`; + parentGrid.appendChild(clipElement); + if (clip.buffer) { + const canvas = clipElement.querySelector(".waveform-canvas-clip"); + const canvasWidth = (clip.durationInSeconds || 0) * pixelsPerSecond; + if (canvasWidth > 0) { + canvas.width = canvasWidth; + canvas.height = 40; + const audioBuffer = clip.buffer; + const isStretched = clip.pitch !== 0; + const sourceOffset = isStretched ? 0 : clip.offset || 0; + const sourceDuration = isStretched + ? clip.originalDuration + : clip.durationInSeconds; + drawWaveform( + canvas, + audioBuffer, + "var(--accent-green)", + sourceOffset, + sourceDuration + ); + } } + clipElement.addEventListener("wheel", (e) => { + e.preventDefault(); + const clipToUpdate = appState.audio.clips.find( + (c) => c.id == clipElement.dataset.clipId + ); + if (!clipToUpdate) return; + const direction = e.deltaY < 0 ? 1 : -1; + let newPitch = clipToUpdate.pitch + direction; + newPitch = Math.max(-24, Math.min(24, newPitch)); + updateAudioClipProperties(clipToUpdate.id, { pitch: newPitch }); + try { + sendAction({ + type: "UPDATE_AUDIO_CLIP", + clipId: clipToUpdate.id, + props: { pitch: newPitch }, + }); + } catch (err) { + console.warn("[SYNC] Falha ao emitir UPDATE_AUDIO_CLIP (wheel)", err); + } + renderAudioEditor(); + restartAudioEditorIfPlaying(); + }); + }); + + // Sync Scroll (sem alterações) + newTrackContainer.addEventListener("scroll", () => { + const scrollPos = newTrackContainer.scrollLeft; + const mainRuler = document.querySelector(".timeline-ruler"); + if (mainRuler && mainRuler.scrollLeft !== scrollPos) { + mainRuler.scrollLeft = scrollPos; + } + }); + + // Event Listener Principal (mousedown no container de pistas) + newTrackContainer.addEventListener("mousedown", (e) => { + // Esconde menus + document.getElementById("timeline-context-menu").style.display = "none"; + document.getElementById("ruler-context-menu").style.display = "none"; + const clipElement = e.target.closest(".timeline-clip"); + // Desseleciona se clicar fora + if (!clipElement && e.button !== 2) { + if (appState.global.selectedClipId) { + appState.global.selectedClipId = null; + newTrackContainer + .querySelectorAll(".timeline-clip.selected") + .forEach((c) => c.classList.remove("selected")); + } + } + + const currentPixelsPerSecond = getPixelsPerSecond(); + const handle = e.target.closest(".clip-resize-handle"); + + // Slice Tool + if (appState.global.sliceToolActive && clipElement) { + e.preventDefault(); + e.stopPropagation(); + const clipId = clipElement.dataset.clipId; + const timelineContainer = clipElement.closest(".timeline-container"); + const rect = timelineContainer.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + const absoluteX = clickX + timelineContainer.scrollLeft; + let sliceTimeInTimeline = absoluteX / currentPixelsPerSecond; + sliceTimeInTimeline = quantizeTime(sliceTimeInTimeline); + sliceAudioClip(clipId, sliceTimeInTimeline); + try { + sendAction({ + type: "UPDATE_AUDIO_CLIP", + clipId, + props: { __operation: "slice", sliceTimeInTimeline }, + }); + } catch (err) { + console.warn("[SYNC] Falha ao emitir UPDATE_AUDIO_CLIP (slice)", err); + } + renderAudioEditor(); + return; + } + + // Resize Handle + if (handle) { + e.preventDefault(); + e.stopPropagation(); + const clipId = clipElement.dataset.clipId; + const clip = appState.audio.clips.find((c) => c.id == clipId); + if (!clip || !clip.buffer) return; + const handleType = handle.classList.contains("left") ? "left" : "right"; + const initialMouseX = e.clientX; + const secondsPerStep = getSecondsPerStep(); + const initialLeftPx = clipElement.offsetLeft; + const initialWidthPx = clipElement.offsetWidth; + const initialStartTime = clip.startTimeInSeconds; + const initialDuration = clip.durationInSeconds; + const initialOffset = clip.offset || 0; + const initialOriginalDuration = + clip.originalDuration || clip.buffer.duration; + const bufferStartTime = initialStartTime - initialOffset; + const onMouseMove = (moveEvent) => { + const deltaX = moveEvent.clientX - initialMouseX; + // Trim Mode + if (appState.global.resizeMode === "trim") { + if (handleType === "right") { + let newWidthPx = initialWidthPx + deltaX; + let newDuration = newWidthPx / currentPixelsPerSecond; + let newEndTime = quantizeTime(initialStartTime + newDuration); + newEndTime = Math.max( + initialStartTime + secondsPerStep, + newEndTime + ); + const maxEndTime = bufferStartTime + initialOriginalDuration; + newEndTime = Math.min(newEndTime, maxEndTime); + clipElement.style.width = `${ + (newEndTime - initialStartTime) * currentPixelsPerSecond + }px`; + } else if (handleType === "left") { + let newLeftPx = initialLeftPx + deltaX; + let newStartTime = newLeftPx / currentPixelsPerSecond; + newStartTime = quantizeTime(newStartTime); + const minStartTime = + initialStartTime + initialDuration - secondsPerStep; + newStartTime = Math.min(newStartTime, minStartTime); + newStartTime = Math.max(bufferStartTime, newStartTime); + const newLeftFinalPx = newStartTime * currentPixelsPerSecond; + const newWidthFinalPx = + (initialStartTime + initialDuration - newStartTime) * + currentPixelsPerSecond; + clipElement.style.left = `${newLeftFinalPx}px`; + clipElement.style.width = `${newWidthFinalPx}px`; + } + } + // Stretch Mode + else if (appState.global.resizeMode === "stretch") { + if (handleType === "right") { + let newWidthPx = initialWidthPx + deltaX; + let newDuration = newWidthPx / currentPixelsPerSecond; + let newEndTime = quantizeTime(initialStartTime + newDuration); + newEndTime = Math.max( + initialStartTime + secondsPerStep, + newEndTime + ); + clipElement.style.width = `${ + (newEndTime - initialStartTime) * currentPixelsPerSecond + }px`; + } else if (handleType === "left") { + let newLeftPx = initialLeftPx + deltaX; + let newStartTime = newLeftPx / currentPixelsPerSecond; + newStartTime = quantizeTime(newStartTime); + const minStartTime = + initialStartTime + initialDuration - secondsPerStep; + newStartTime = Math.min(newStartTime, minStartTime); + const newLeftFinalPx = newStartTime * currentPixelsPerSecond; + const newWidthFinalPx = + (initialStartTime + initialDuration - newStartTime) * + currentPixelsPerSecond; + clipElement.style.left = `${newLeftFinalPx}px`; + clipElement.style.width = `${newWidthFinalPx}px`; + } + } + }; + const onMouseUp = (upEvent) => { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + const finalLeftPx = clipElement.offsetLeft; + const finalWidthPx = clipElement.offsetWidth; + const newStartTime = finalLeftPx / currentPixelsPerSecond; + const newDuration = finalWidthPx / currentPixelsPerSecond; + // Trim Mode + if (appState.global.resizeMode === "trim") { + const newOffset = newStartTime - bufferStartTime; + if (handleType === "right") { + updateAudioClipProperties(clipId, { + durationInSeconds: newDuration, + pitch: 0, + }); + try { + sendAction({ + type: "UPDATE_AUDIO_CLIP", + clipId, + props: { durationInSeconds: newDuration, pitch: 0 }, + }); + } catch (err) { + console.warn( + "[SYNC] Falha ao emitir UPDATE_AUDIO_CLIP (trim-right)", + err + ); + } + } else if (handleType === "left") { + updateAudioClipProperties(clipId, { + startTimeInSeconds: newStartTime, + durationInSeconds: newDuration, + offset: newOffset, + pitch: 0, + }); + try { + sendAction({ + type: "UPDATE_AUDIO_CLIP", + clipId, + props: { + startTimeInSeconds: newStartTime, + durationInSeconds: newDuration, + offset: newOffset, + pitch: 0, + }, + }); + } catch (err) { + console.warn( + "[SYNC] Falha ao emitir UPDATE_AUDIO_CLIP (trim-left)", + err + ); + } + } + } + // Stretch Mode + else if (appState.global.resizeMode === "stretch") { + const newPlaybackRate = initialOriginalDuration / newDuration; + const newPitch = 12 * Math.log2(newPlaybackRate); + if (handleType === "right") { + updateAudioClipProperties(clipId, { + durationInSeconds: newDuration, + pitch: newPitch, + offset: 0, + }); + try { + sendAction({ + type: "UPDATE_AUDIO_CLIP", + clipId, + props: { + durationInSeconds: newDuration, + pitch: newPitch, + offset: 0, + }, + }); + } catch (err) { + console.warn( + "[SYNC] Falha ao emitir UPDATE_AUDIO_CLIP (stretch-right)", + err + ); + } + } else if (handleType === "left") { + updateAudioClipProperties(clipId, { + startTimeInSeconds: newStartTime, + durationInSeconds: newDuration, + pitch: newPitch, + offset: 0, + }); + try { + sendAction({ + type: "UPDATE_AUDIO_CLIP", + clipId, + props: { + startTimeInSeconds: newStartTime, + durationInSeconds: newDuration, + pitch: newPitch, + offset: 0, + }, + }); + } catch (err) { + console.warn( + "[SYNC] Falha ao emitir UPDATE_AUDIO_CLIP (stretch-left)", + err + ); + } + } + } + restartAudioEditorIfPlaying(); + renderAudioEditor(); + }; + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + return; + } + + // Drag Clip + if (clipElement) { + const clipId = clipElement.dataset.clipId; + if (appState.global.selectedClipId !== clipId) { + appState.global.selectedClipId = clipId; + newTrackContainer + .querySelectorAll(".timeline-clip.selected") + .forEach((c) => c.classList.remove("selected")); + clipElement.classList.add("selected"); + } + e.preventDefault(); + const clickOffsetInClip = + e.clientX - clipElement.getBoundingClientRect().left; + clipElement.classList.add("dragging"); + let lastOverLane = clipElement.closest(".audio-track-lane"); + const onMouseMove = (moveEvent) => { + const deltaX = moveEvent.clientX - e.clientX; + clipElement.style.transform = `translateX(${deltaX}px)`; + const overElement = document.elementFromPoint( + moveEvent.clientX, + moveEvent.clientY + ); + const overLane = overElement + ? overElement.closest(".audio-track-lane") + : null; + if (overLane && overLane !== lastOverLane) { + if (lastOverLane) lastOverLane.classList.remove("drag-over"); + overLane.classList.add("drag-over"); + lastOverLane = overLane; + } + }; + const onMouseUp = (upEvent) => { + clipElement.classList.remove("dragging"); + if (lastOverLane) lastOverLane.classList.remove("drag-over"); + clipElement.style.transform = ""; + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + const finalLane = lastOverLane; + if (!finalLane) return; + const newTrackId = finalLane.dataset.trackId; // (é uma string) + const timelineContainer = finalLane.querySelector( + ".timeline-container" + ); + const wrapperRect = timelineContainer.getBoundingClientRect(); + const newLeftPx = + upEvent.clientX - + wrapperRect.left - + clickOffsetInClip + + timelineContainer.scrollLeft; + const constrainedLeftPx = Math.max(0, newLeftPx); + let newStartTime = constrainedLeftPx / currentPixelsPerSecond; + newStartTime = quantizeTime(newStartTime); + // (Correção Bug 4 - remove Number()) + updateAudioClipProperties(clipId, { + trackId: newTrackId, + startTimeInSeconds: newStartTime, + }); + try { + sendAction({ + type: "UPDATE_AUDIO_CLIP", + clipId, + props: { trackId: newTrackId, startTimeInSeconds: newStartTime }, + }); + } catch (err) { + console.warn("[SYNC] Falha ao emitir UPDATE_AUDIO_CLIP (move)", err); + } + renderAudioEditor(); + }; + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + return; + } + + // Seek na Pista + const timelineContainer = e.target.closest(".timeline-container"); + if (timelineContainer) { + e.preventDefault(); + const handleSeek = (event) => { + const rect = timelineContainer.getBoundingClientRect(); + const scrollLeft = timelineContainer.scrollLeft; + const clickX = event.clientX - rect.left; + const absoluteX = clickX + scrollLeft; + const newTime = absoluteX / currentPixelsPerSecond; + // ================================================================= + // 👇 INÍCIO DA CORREÇÃO (Sincronia de Seek na Pista) + // ================================================================= + sendAction({ type: "SET_SEEK_TIME", seekTime: newTime }); + // seekAudioEditor(newTime); // 👈 Substituído + // ================================================================= + // 👆 FIM DA CORREÇÃO + }; + handleSeek(e); // Aplica no mousedown + const onMouseMoveSeek = (moveEvent) => handleSeek(moveEvent); + const onMouseUpSeek = () => { + document.removeEventListener("mousemove", onMouseMoveSeek); + document.removeEventListener("mouseup", onMouseUpSeek); + }; + document.addEventListener("mousemove", onMouseMoveSeek); + document.addEventListener("mouseup", onMouseUpSeek); + } + }); + + // Menu Contexto Pista (sem alterações) + newTrackContainer.addEventListener("contextmenu", (e) => { + e.preventDefault(); + document.getElementById("ruler-context-menu").style.display = "none"; + const menu = document.getElementById("timeline-context-menu"); + if (!menu) return; + const clipElement = e.target.closest(".timeline-clip"); + const copyItem = document.getElementById("copy-clip"); + const cutItem = document.getElementById("cut-clip"); + const pasteItem = document.getElementById("paste-clip"); + const deleteItem = document.getElementById("delete-clip"); + const canPaste = appState.global.clipboard?.type === "audio"; + pasteItem.style.display = canPaste ? "block" : "none"; + if (clipElement) { + const clipId = clipElement.dataset.clipId; + if (appState.global.selectedClipId !== clipId) { + appState.global.selectedClipId = clipId; + newTrackContainer + .querySelectorAll(".timeline-clip.selected") + .forEach((c) => c.classList.remove("selected")); + clipElement.classList.add("selected"); + } + copyItem.style.display = "block"; + cutItem.style.display = "block"; + deleteItem.style.display = "block"; + menu.style.display = "block"; + menu.style.left = `${e.clientX}px`; + menu.style.top = `${e.clientY}px`; + if (!deleteItem.__synced) { + deleteItem.__synced = true; + deleteItem.addEventListener("click", () => { + const id = appState.global.selectedClipId; + if (!id) return; + const ok = removeAudioClip(id); + try { + sendAction({ type: "REMOVE_AUDIO_CLIP", clipId: id }); + } catch (err) { + console.warn("[SYNC] Falha ao emitir REMOVE_AUDIO_CLIP", err); + } + if (ok) renderAudioEditor(); + menu.style.display = "none"; + }); + } + } else { + copyItem.style.display = "none"; + cutItem.style.display = "none"; + deleteItem.style.display = "none"; + if (canPaste) { + menu.style.display = "block"; + menu.style.left = `${e.clientX}px`; + menu.style.top = `${e.clientY}px`; + } else { + menu.style.display = "none"; + } + } + }); } +// Funções de UI (sem alterações) +export function updateAudioEditorUI() { + const playBtn = document.getElementById("audio-editor-play-btn"); + if (!playBtn) return; + if (appState.global.isAudioEditorPlaying) { + playBtn.classList.remove("fa-play"); + playBtn.classList.add("fa-pause"); + } else { + playBtn.classList.remove("fa-pause"); + playBtn.classList.add("fa-play"); + } +} export function updatePlayheadVisual(pixels) { - document.querySelectorAll('.audio-track-lane .playhead').forEach(ph => { - ph.style.left = `${pixels}px`; - }); + document.querySelectorAll(".audio-track-lane .playhead").forEach((ph) => { + ph.style.left = `${pixels}px`; + }); } - export function resetPlayheadVisual() { - document.querySelectorAll('.audio-track-lane .playhead').forEach(ph => { - ph.style.left = '0px'; - }); -} \ No newline at end of file + document.querySelectorAll(".audio-track-lane .playhead").forEach((ph) => { + ph.style.left = "0px"; + }); +} diff --git a/assets/js/creations/config.js b/assets/js/creations/config.js index f264d340..b7a5a2c4 100644 --- a/assets/js/creations/config.js +++ b/assets/js/creations/config.js @@ -8,7 +8,6 @@ export const NOTE_LENGTH = 12; export const DEFAULT_VOLUME = 0.8; export const DEFAULT_PAN = 0.0; -// --- ADICIONADO --- // Constantes para o layout do editor de áudio export const PIXELS_PER_STEP = 32; // Cada step (1/16) terá 32px de largura export const PIXELS_PER_BAR = 512; // 16 steps * 32px/step = 512px por compasso (bar) diff --git a/assets/js/creations/file.js b/assets/js/creations/file.js index 2c41b630..79acc0be 100644 --- a/assets/js/creations/file.js +++ b/assets/js/creations/file.js @@ -4,6 +4,10 @@ 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 * as Tone from "https://esm.sh/tone"; + +// --- NOVA IMPORTAÇÃO --- +import { sendAction } from "./socket.js"; export async function handleFileLoad(file) { let xmlContent = ""; @@ -20,7 +24,12 @@ export async function handleFileLoad(file) { } else { xmlContent = await file.text(); } - await parseMmpContent(xmlContent); + + // 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 }); + } catch (error) { console.error("Erro ao carregar o projeto:", error); alert(`Erro ao carregar projeto: ${error.message}`); @@ -34,8 +43,16 @@ export async function loadProjectFromServer(fileName) { throw new Error(`Não foi possível carregar o arquivo ${fileName}`); const xmlContent = await response.text(); - await parseMmpContent(xmlContent); - return true; + + // ANTES: + // await parseMmpContent(xmlContent); + // return true; + + // DEPOIS: + // Envia o XML para o servidor + 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); @@ -44,6 +61,10 @@ export async function loadProjectFromServer(fileName) { } } +// --- NENHUMA MUDANÇA DAQUI PARA BAIXO --- +// 'parseMmpContent' agora é chamado pelo 'socket.js' +// quando ele recebe a ação 'LOAD_PROJECT' ou 'load_project_state'. + export async function parseMmpContent(xmlString) { resetProjectState(); initializeAudioContext(); @@ -175,12 +196,12 @@ export async function parseMmpContent(xmlString) { let isFirstTrackWithNotes = true; newTracks.forEach(track => { // --- INÍCIO DA CORREÇÃO --- - // Cria os nós de áudio usando os construtores do Tone.js - track.gainNode = new Tone.Gain(Tone.gainToDb(track.volume)); + // Agora usando Volume em dB (Opção B) + track.volumeNode = new Tone.Volume(Tone.gainToDb(track.volume)); track.pannerNode = new Tone.Panner(track.pan); - // Conecta a cadeia de áudio: Gain -> Panner -> Saída Principal (Destination) - track.gainNode.connect(track.pannerNode); + // Cadeia de áudio: Volume(dB) -> Panner -> Saída Principal + track.volumeNode.connect(track.pannerNode); track.pannerNode.connect(getMainGainNode()); // --- FIM DA CORREÇÃO --- @@ -206,7 +227,13 @@ export async function parseMmpContent(xmlString) { appState.pattern.tracks = newTracks; appState.pattern.activeTrackId = appState.pattern.tracks[0]?.id || null; + + // força atualização total da UI e dos editores de pattern + await Promise.resolve(); // garante que os tracks estejam no estado renderAll(); + + console.log('[UI] Projeto renderizado após parseMmpContent'); + } export function generateMmpFile() { @@ -314,4 +341,4 @@ function downloadFile(content, fileName) { a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); -} \ No newline at end of file +} diff --git a/assets/js/creations/main.js b/assets/js/creations/main.js index 93b5c92f..cc801841 100644 --- a/assets/js/creations/main.js +++ b/assets/js/creations/main.js @@ -1,43 +1,65 @@ -// js/main.js +// js/main.js (ESM com import absoluto de socket.js + ROOM_NAME local) + import { appState, resetProjectState } from "./state.js"; -import { addTrackToState, removeLastTrackFromState } from "./pattern/pattern_state.js"; -// --- CORREÇÃO AQUI --- -import { toggleRecording } from "./recording.js"; -import { addAudioTrackLane, removeAudioClip } from "./audio/audio_state.js"; -import { updateTransportLoop } from "./audio/audio_audio.js"; import { - togglePlayback, - stopPlayback, - rewindPlayback, -} from "./pattern/pattern_audio.js"; -import { - startAudioEditorPlayback, - stopAudioEditorPlayback, + updateTransportLoop, restartAudioEditorIfPlaying, } from "./audio/audio_audio.js"; import { initializeAudioContext } from "./audio.js"; import { handleFileLoad, generateMmpFile } from "./file.js"; -import { renderAll, loadAndRenderSampleBrowser, showOpenProjectModal, closeOpenProjectModal } from "./ui.js"; +import { + renderAll, + loadAndRenderSampleBrowser, + showOpenProjectModal, + closeOpenProjectModal, +} from "./ui.js"; import { renderAudioEditor } from "./audio/audio_ui.js"; import { adjustValue, enforceNumericInput } from "./utils.js"; import { ZOOM_LEVELS } from "./config.js"; -// --- NOVA FUNÇÃO --- -// Atualiza a aparência dos botões de ferramenta +// ⚠️ 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"; + +// 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; + +// ✅ NOVO: se tem sala na URL, entra já na sala (independe do áudio) + +if (ROOM_NAME) { + // entra na sala para receber estado/broadcasts imediatamente + joinRoom(); +} + +// Função util para alternar estado dos botões de ferramenta function updateToolButtons() { - const sliceToolBtn = document.getElementById("slice-tool-btn"); - const trimToolBtn = document.getElementById("resize-tool-trim"); - const stretchToolBtn = document.getElementById("resize-tool-stretch"); + const sliceToolBtn = document.getElementById("slice-tool-btn"); + const trimToolBtn = document.getElementById("resize-tool-trim"); + const stretchToolBtn = document.getElementById("resize-tool-stretch"); - if(sliceToolBtn) sliceToolBtn.classList.toggle("active", appState.global.sliceToolActive); - if(trimToolBtn) trimToolBtn.classList.toggle("active", !appState.global.sliceToolActive && appState.global.resizeMode === 'trim'); - if(stretchToolBtn) stretchToolBtn.classList.toggle("active", !appState.global.sliceToolActive && appState.global.resizeMode === 'stretch'); + if (sliceToolBtn) + sliceToolBtn.classList.toggle("active", appState.global.sliceToolActive); + if (trimToolBtn) + trimToolBtn.classList.toggle( + "active", + !appState.global.sliceToolActive && appState.global.resizeMode === "trim" + ); + if (stretchToolBtn) + stretchToolBtn.classList.toggle( + "active", + !appState.global.sliceToolActive && + appState.global.resizeMode === "stretch" + ); - // Desativa a ferramenta de corte se outra for selecionada - document.body.classList.toggle("slice-tool-active", appState.global.sliceToolActive); + document.body.classList.toggle( + "slice-tool-active", + appState.global.sliceToolActive + ); } document.addEventListener("DOMContentLoaded", () => { + // Botões e elementos const newProjectBtn = document.getElementById("new-project-btn"); const openMmpBtn = document.getElementById("open-mmp-btn"); const saveMmpBtn = document.getElementById("save-mmp-btn"); @@ -53,12 +75,9 @@ document.addEventListener("DOMContentLoaded", () => { const rewindBtn = document.getElementById("rewind-btn"); const metronomeBtn = document.getElementById("metronome-btn"); const sliceToolBtn = document.getElementById("slice-tool-btn"); - - // --- NOVOS BOTÕES --- const resizeToolTrimBtn = document.getElementById("resize-tool-trim"); const resizeToolStretchBtn = document.getElementById("resize-tool-stretch"); - const recordBtn = document.getElementById("record-btn"); - + const createRoomBtn = document.getElementById("create-room-btn"); const mmpFileInput = document.getElementById("mmp-file-input"); const sampleFileInput = document.getElementById("sample-file-input"); const openProjectModal = document.getElementById("open-project-modal"); @@ -68,192 +87,354 @@ document.addEventListener("DOMContentLoaded", () => { const addBarBtn = document.getElementById("add-bar-btn"); const zoomInBtn = document.getElementById("zoom-in-btn"); const zoomOutBtn = document.getElementById("zoom-out-btn"); + const deleteClipBtn = document.getElementById("delete-clip"); - // --- LISTENERS ADICIONADOS (COM LÓGICA DE CONTROLLER) --- - - // --- NOVO LISTENER PARA O BOTÃO DE GRAVAR --- - if (recordBtn) { - recordBtn.addEventListener("click", () => { - // Garante que o contexto de áudio foi iniciado por um gesto do usuário - initializeAudioContext(); - toggleRecording(); + // ================================================================= + // 👇 INÍCIO DA CORREÇÃO (Botão de Sincronia - Agora envia Ação) + // ================================================================= + const syncModeBtn = document.getElementById("sync-mode-btn"); // + if (syncModeBtn) { + // + // Define o estado inicial (global por padrão) + appState.global.syncMode = "global"; // + syncModeBtn.classList.add("active"); // + syncModeBtn.textContent = "Global"; // + + syncModeBtn.addEventListener("click", () => { + // + // 1. Determina qual será o *novo* modo + const newMode = + appState.global.syncMode === "global" ? "local" : "global"; // + + // 2. Envia a ação para sincronizar. O handleActionBroadcast + // cuidará de atualizar o appState, o botão e mostrar o toast. + sendAction({ + type: "SET_SYNC_MODE", + mode: newMode, }); - } - // Listener para o botão "Excluir Clipe" no menu de contexto - const deleteClipBtn = document.getElementById('delete-clip'); - if (deleteClipBtn) { - deleteClipBtn.addEventListener('click', () => { - const clipId = appState.global.selectedClipId; // 1. Lê o estado - if (clipId) { - if (removeAudioClip(clipId)) { // 2. Chama a função de state - appState.global.selectedClipId = null; // 3. Atualiza o estado global - renderAudioEditor(); // 4. Renderiza a mudança - } - } - // Esconde o menu - const menu = document.getElementById('timeline-context-menu'); - if (menu) menu.style.display = 'none'; - }); - } - - // Listener global para a tecla Delete/Backspace - document.addEventListener('keydown', (e) => { - // Ignora se estiver digitando em um input - if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { - return; - } - - const clipId = appState.global.selectedClipId; // 1. Lê o estado - // Verifica se há um clipe selecionado e a tecla pressionada é Delete ou Backspace - if ((e.key === 'Delete' || e.key === 'Backspace') && clipId) { - e.preventDefault(); // Impede o navegador de voltar a página (ação do Backspace) - - if (removeAudioClip(clipId)) { // 2. Chama a função de state - appState.global.selectedClipId = null; // 3. Atualiza o estado global - renderAudioEditor(); // 4. Renderiza a mudança - } - } + // Lógica antiga removida daqui (movida para o handler) + /* + const isNowLocal = appState.global.syncMode === "global"; + appState.global.syncMode = isNowLocal ? "local" : "global"; + syncModeBtn.classList.toggle("active", !isNowLocal); + syncModeBtn.textContent = isNowLocal ? "Local" : "Global"; + showToast( `🎧 Modo de Playback: ${isNowLocal ? "Local" : "Global"}`, "info" ); + */ }); - // Listener global para fechar menu de contexto ou desselecionar clipe - document.addEventListener('click', (e) => { - // Esconde o menu de contexto se clicar fora dele - const menu = document.getElementById('timeline-context-menu'); - if (menu && !e.target.closest('#timeline-context-menu')) { - menu.style.display = 'none'; - } - }); - - // --- FIM DOS LISTENERS ADICIONADOS --- + // Esconde o botão se não estiver em uma sala (lógica movida do socket.js) + if (!ROOM_NAME) { + // + //syncModeBtn.style.display = 'none'; // REMOVIDO PARA TESTE VISUAL + } + } + // ================================================================= + // 👆 FIM DA CORREÇÃO + // ================================================================= - newProjectBtn.addEventListener("click", () => { - if ((appState.pattern.tracks.length > 0 || appState.audio.clips.length > 0) && !confirm("Você tem certeza? Alterações não salvas serão perdidas.")) return; - resetProjectState(); - 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; - const titleElement = document.getElementById('beat-bassline-title'); - if(titleElement) titleElement.textContent = 'Novo Projeto'; - renderAll(); + // Excluir clipe + if (deleteClipBtn) { + deleteClipBtn.addEventListener("click", () => { + initializeAudioContext(); + const clipId = appState.global.selectedClipId; + if (clipId) { + sendAction({ type: "REMOVE_AUDIO_CLIP", clipId }); + appState.global.selectedClipId = null; + } + const menu = document.getElementById("timeline-context-menu"); + if (menu) menu.style.display = "none"; + }); + } + + // Delete/Backspace + document.addEventListener("keydown", (e) => { + if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return; + const clipId = appState.global.selectedClipId; + if ((e.key === "Delete" || e.key === "Backspace") && clipId) { + e.preventDefault(); + sendAction({ type: "REMOVE_AUDIO_CLIP", clipId }); + appState.global.selectedClipId = null; + } }); - addBarBtn.addEventListener("click", () => { + // Fechar menu contexto + document.addEventListener("click", (e) => { + const menu = document.getElementById("timeline-context-menu"); + if (menu && !e.target.closest("#timeline-context-menu")) { + menu.style.display = "none"; + } + }); + + // Ações principais (broadcast) + newProjectBtn?.addEventListener("click", () => { + initializeAudioContext(); + if ( + (appState.pattern.tracks.length > 0 || appState.audio.clips.length > 0) && + !confirm( + "Você tem certeza? Isso irá resetar o projeto para TODOS na sala." + ) + ) + return; + sendAction({ type: "RESET_PROJECT" }); + }); + + addBarBtn?.addEventListener("click", () => { const barsInput = document.getElementById("bars-input"); - if (barsInput) adjustValue(barsInput, 1); - }); - - openMmpBtn.addEventListener("click", showOpenProjectModal); - loadFromComputerBtn.addEventListener("click", () => mmpFileInput.click()); - mmpFileInput.addEventListener("change", (event) => { const file = event.target.files[0]; if (file) { handleFileLoad(file).then(() => closeOpenProjectModal()); } }); - uploadSampleBtn.addEventListener("click", () => sampleFileInput.click()); - saveMmpBtn.addEventListener("click", generateMmpFile); - addInstrumentBtn.addEventListener("click", addTrackToState); - removeInstrumentBtn.addEventListener("click", removeLastTrackFromState); - playBtn.addEventListener("click", togglePlayback); - stopBtn.addEventListener("click", stopPlayback); - rewindBtn.addEventListener("click", rewindPlayback); - metronomeBtn.addEventListener("click", () => { initializeAudioContext(); appState.global.metronomeEnabled = !appState.global.metronomeEnabled; metronomeBtn.classList.toggle("active", appState.global.metronomeEnabled); }); - - // --- LISTENERS DE FERRAMENTAS ATUALIZADOS --- - if(sliceToolBtn) { - sliceToolBtn.addEventListener("click", () => { - appState.global.sliceToolActive = !appState.global.sliceToolActive; - updateToolButtons(); - }); - } - if(resizeToolTrimBtn) { - resizeToolTrimBtn.addEventListener("click", () => { - appState.global.resizeMode = 'trim'; - appState.global.sliceToolActive = false; - updateToolButtons(); - }); - } - if(resizeToolStretchBtn) { - resizeToolStretchBtn.addEventListener("click", () => { - appState.global.resizeMode = 'stretch'; - appState.global.sliceToolActive = false; - updateToolButtons(); - }); - } - - openModalCloseBtn.addEventListener("click", closeOpenProjectModal); - - sidebarToggle.addEventListener("click", () => { - document.body.classList.toggle("sidebar-hidden"); - const icon = sidebarToggle.querySelector("i"); - if (icon) { - icon.className = document.body.classList.contains("sidebar-hidden") ? "fa-solid fa-caret-right" : "fa-solid fa-caret-left"; + if (barsInput) { + adjustValue(barsInput, 1); + barsInput.dispatchEvent(new Event("change", { bubbles: true })); } }); - + + openMmpBtn?.addEventListener("click", showOpenProjectModal); + loadFromComputerBtn?.addEventListener("click", () => mmpFileInput?.click()); + mmpFileInput?.addEventListener("change", (event) => { + const file = event.target.files[0]; + if (file) handleFileLoad(file).then(() => closeOpenProjectModal()); + }); + uploadSampleBtn?.addEventListener("click", () => sampleFileInput?.click()); + saveMmpBtn?.addEventListener("click", generateMmpFile); + + addInstrumentBtn?.addEventListener("click", () => { + initializeAudioContext(); + sendAction({ type: "ADD_TRACK" }); + }); + removeInstrumentBtn?.addEventListener("click", () => { + initializeAudioContext(); + sendAction({ type: "REMOVE_LAST_TRACK" }); + }); + + playBtn?.addEventListener("click", () => { + initializeAudioContext(); + sendAction({ type: "TOGGLE_PLAYBACK" }); + }); + stopBtn?.addEventListener("click", () => { + initializeAudioContext(); + sendAction({ type: "STOP_PLAYBACK" }); + }); + rewindBtn?.addEventListener("click", () => { + initializeAudioContext(); + sendAction({ type: "REWIND_PLAYBACK" }); + }); + + metronomeBtn?.addEventListener("click", () => { + initializeAudioContext(); + appState.global.metronomeEnabled = !appState.global.metronomeEnabled; + metronomeBtn.classList.toggle("active", appState.global.metronomeEnabled); + }); + + // Ferramentas locais + if (sliceToolBtn) { + sliceToolBtn.addEventListener("click", () => { + appState.global.sliceToolActive = !appState.global.sliceToolActive; + updateToolButtons(); + }); + } + if (resizeToolTrimBtn) { + resizeToolTrimBtn.addEventListener("click", () => { + appState.global.resizeMode = "trim"; + appState.global.sliceToolActive = false; + updateToolButtons(); + }); + } + if (resizeToolStretchBtn) { + resizeToolStretchBtn.addEventListener("click", () => { + appState.global.resizeMode = "stretch"; + appState.global.sliceToolActive = false; + updateToolButtons(); + }); + } + + openModalCloseBtn?.addEventListener("click", closeOpenProjectModal); + sidebarToggle?.addEventListener("click", () => { + document.body.classList.toggle("sidebar-hidden"); + const icon = sidebarToggle.querySelector("i"); + if (icon) { + icon.className = document.body.classList.contains("sidebar-hidden") + ? "fa-solid fa-caret-right" + : "fa-solid fa-caret-left"; + } + }); + const inputs = document.querySelectorAll(".value-input"); inputs.forEach((input) => { input.addEventListener("input", (event) => { enforceNumericInput(event); - if (appState.global.isPlaying && (event.target.id.startsWith("compasso-") || event.target.id === 'bars-input')) { stopPlayback(); } - if (event.target.id.startsWith("compasso-") || event.target.id === 'bars-input' || event.target.id === 'bpm-input') { renderAll(); } + if ( + appState.global.isPlaying && + (event.target.id.startsWith("compasso-") || + event.target.id === "bars-input") + ) { + sendAction({ type: "STOP_PLAYBACK" }); + } + }); + + input.addEventListener("change", (event) => { + const target = event.target; + if (target.id === "bpm-input") { + sendAction({ type: "SET_BPM", value: target.value }); + } else if (target.id === "bars-input") { + sendAction({ type: "SET_BARS", value: target.value }); + } else if (target.id === "compasso-a-input") { + sendAction({ type: "SET_TIMESIG_A", value: target.value }); + } else if (target.id === "compasso-b-input") { + sendAction({ type: "SET_TIMESIG_B", value: target.value }); + } + }); + + input.addEventListener("wheel", (event) => { + event.preventDefault(); + const step = event.deltaY < 0 ? 1 : -1; + adjustValue(event.target, step); + event.target.dispatchEvent(new Event("change", { bubbles: true })); }); - input.addEventListener("wheel", (event) => { event.preventDefault(); const step = event.deltaY < 0 ? 1 : -1; adjustValue(event.target, step); }); }); const buttons = document.querySelectorAll(".adjust-btn"); - buttons.forEach((button) => { button.addEventListener("click", () => { const targetId = button.dataset.target + "-input"; const targetInput = document.getElementById(targetId); const step = parseInt(button.dataset.step, 10) || 1; if (targetInput) { adjustValue(targetInput, step); } }); }); - - if (zoomInBtn) { - zoomInBtn.addEventListener("click", () => { - if (appState.global.zoomLevelIndex < ZOOM_LEVELS.length - 1) { - appState.global.zoomLevelIndex++; - renderAll(); - } + buttons.forEach((button) => { + button.addEventListener("click", () => { + const targetId = button.dataset.target + "-input"; + const targetInput = document.getElementById(targetId); + const step = parseInt(button.dataset.step, 10) || 1; + if (targetInput) { + adjustValue(targetInput, step); + targetInput.dispatchEvent(new Event("change", { bubbles: true })); + } }); - } - if (zoomOutBtn) { - zoomOutBtn.addEventListener("click", () => { - if (appState.global.zoomLevelIndex > 0) { - appState.global.zoomLevelIndex--; - renderAll(); - } - }); - } - - audioEditorPlayBtn.addEventListener("click", () => { - if (appState.global.isAudioEditorPlaying) { - stopAudioEditorPlayback(false); // Pausa - } else { - startAudioEditorPlayback(); - } }); - audioEditorStopBtn.addEventListener("click", () => stopAudioEditorPlayback(true)); // Stop (rebobina) - - audioEditorLoopBtn.addEventListener("click", () => { - appState.global.isLoopActive = !appState.global.isLoopActive; - appState.audio.isAudioEditorLoopEnabled = appState.global.isLoopActive; - audioEditorLoopBtn.classList.toggle("active", appState.global.isLoopActive); - updateTransportLoop(); - const loopArea = document.getElementById("loop-region"); - if (loopArea) { - loopArea.classList.toggle("visible", appState.global.isLoopActive); + + // Zoom local + zoomInBtn?.addEventListener("click", () => { + if (appState.global.zoomLevelIndex < ZOOM_LEVELS.length - 1) { + appState.global.zoomLevelIndex++; + renderAll(); + } + }); + zoomOutBtn?.addEventListener("click", () => { + if (appState.global.zoomLevelIndex > 0) { + appState.global.zoomLevelIndex--; + renderAll(); } - restartAudioEditorIfPlaying(); }); - - if (addAudioTrackBtn) { addAudioTrackBtn.addEventListener("click", () => { addAudioTrackLane(); renderAudioEditor(); }); } + // Editor de Áudio + audioEditorPlayBtn?.addEventListener("click", () => { + initializeAudioContext(); + if (appState.global.isAudioEditorPlaying) { + sendAction({ type: "STOP_AUDIO_PLAYBACK", rewind: false }); + } else { + sendAction({ + type: "START_AUDIO_PLAYBACK", + seekTime: appState.audio.audioEditorSeekTime, // Corrigido + loopState: { + isLoopActive: appState.global.isLoopActive, + loopStartTime: appState.global.loopStartTime, + loopEndTime: appState.global.loopEndTime, + }, + }); + } + }); + audioEditorStopBtn?.addEventListener("click", () => { + initializeAudioContext(); + sendAction({ type: "STOP_AUDIO_PLAYBACK", rewind: true }); + }); + + // Loop Button (agora envia ação) + audioEditorLoopBtn?.addEventListener("click", () => { + initializeAudioContext(); // Garante contexto + const newLoopState = !appState.global.isLoopActive; + sendAction({ + type: "SET_LOOP_STATE", + isLoopActive: newLoopState, + loopStartTime: appState.global.loopStartTime, + loopEndTime: appState.global.loopEndTime, + }); + }); + + if (addAudioTrackBtn) { + addAudioTrackBtn.addEventListener("click", () => { + initializeAudioContext(); + sendAction({ type: "ADD_AUDIO_LANE" }); + }); + } + + // Navegador de Samples (local) loadAndRenderSampleBrowser(); - const browserContent = document.getElementById('browser-content'); + const browserContent = document.getElementById("browser-content"); if (browserContent) { - browserContent.addEventListener('click', function(event) { - const folderName = event.target.closest('.folder-name'); - if (folderName) { - const folderItem = folderName.parentElement; - folderItem.classList.toggle('open'); - } - }); + browserContent.addEventListener("click", function (event) { + const folderName = event.target.closest(".folder-name"); + if (folderName) { + const folderItem = folderName.parentElement; + folderItem.classList.toggle("open"); + } + }); + } + + // Criar sala (gera link com ?room=...) + if (createRoomBtn) { + createRoomBtn.addEventListener("click", () => { + initializeAudioContext(); + const currentParams = new URLSearchParams(window.location.search); + if (currentParams.has("room")) { + alert( + `Você já está na sala: ${currentParams.get( + "room" + )}\n\nCopie o link da barra de endereços para convidar.` + ); + return; + } + const defaultName = `sessao-${Math.random() + .toString(36) + .substring(2, 7)}`; + const roomName = prompt( + "Digite um nome para a sala compartilhada:", + defaultName + ); + if (!roomName) return; + const currentUrl = window.location.origin + window.location.pathname; + const shareableLink = `${currentUrl}?room=${encodeURIComponent( + roomName + )}`; + try { + navigator.clipboard.writeText(shareableLink); + alert( + `Link da sala copiado para a área de transferência!\n\n${shareableLink}\n\nA página será recarregada agora para entrar na nova sala.` + ); + } catch (err) { + alert( + `Link da sala: ${shareableLink}\n\nA página será recarregada agora para entrar na nova sala.` + ); + } + window.location.href = shareableLink; + }); + } + + // Modal “destravar áudio” + entrar na sala + const audioUnlockModal = document.getElementById("audio-unlock-modal"); + const audioUnlockBtn = document.getElementById("audio-unlock-btn"); + + if (ROOM_NAME && audioUnlockModal && audioUnlockBtn) { + audioUnlockModal.style.display = "flex"; + audioUnlockBtn.addEventListener("click", () => { + const userName = prompt( + "Qual o seu nome?", + `Alicer-${Math.floor(Math.random() * 999)}` + ); + if (!userName) return; + setUserName(userName); + initializeAudioContext(); + // joinRoom() já foi chamado no início se ROOM_NAME existe + audioUnlockModal.style.display = "none"; + }); + } else { + console.log("Modo local. Áudio será iniciado no primeiro clique."); + // Comentado para permitir teste visual + // if (syncModeBtn) syncModeBtn.style.display = "none"; } renderAll(); - updateToolButtons(); // Define o estado inicial dos botões -}); \ No newline at end of file + updateToolButtons(); +}); diff --git a/assets/js/creations/pattern/pattern_audio.js b/assets/js/creations/pattern/pattern_audio.js index 3eca9f67..71356379 100644 --- a/assets/js/creations/pattern/pattern_audio.js +++ b/assets/js/creations/pattern/pattern_audio.js @@ -1,4 +1,6 @@ // js/pattern_audio.js +import * as Tone from "https://esm.sh/tone"; + import { appState } from "../state.js"; import { highlightStep } from "./pattern_ui.js"; import { getTotalSteps } from "../utils.js"; @@ -21,24 +23,39 @@ export function playMetronomeSound(isDownbeat) { synth.triggerAttackRelease(freq, "8n", Tone.now()); } -// --- FUNÇÃO CORRIGIDA E EFICIENTE --- +// 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; - // Se a faixa existe e tem um player pré-carregado, apenas o dispara. + // Se a faixa existe e tem um player pré-carregado if (track && track.player) { - // Atualiza o volume/pan caso tenham sido alterados - track.gainNode.gain.value = Tone.gainToDb(track.volume); - track.pannerNode.pan.value = track.pan; - - // Dispara o som imediatamente. Esta operação é instantânea. - track.player.start(Tone.now()); - } - // Fallback para preview de samples no navegador (sem trackId) + if (track.player.loaded) { + // Ajusta volume/pan sempre que tocar (robustez a alterações em tempo real) + if (track.volumeNode) { + track.volumeNode.volume.value = + track.volume === 0 ? -Infinity : Tone.gainToDb(track.volume); + } + if (track.pannerNode) { + track.pannerNode.pan.value = track.pan ?? 0; + } + + // Garante conexão: player -> volumeNode (não usar mais gainNode) + try { track.player.disconnect(); } catch {} + if (track.volumeNode) { + track.player.connect(track.volumeNode); + } + + // Dispara imediatamente + track.player.start(Tone.now()); + } else { + console.warn(`Player da trilha "${track.name}" ainda não carregado — pulando este tick.`); + } + } + // Fallback para preview de sample sem trackId else if (!trackId && filePath) { - const previewPlayer = new Tone.Player(filePath).toDestination(); - previewPlayer.autostart = true; + const previewPlayer = new Tone.Player(filePath).toDestination(); + previewPlayer.autostart = true; } } @@ -59,6 +76,7 @@ function tick() { timerDisplay.textContent = formatTime(currentTime); } + // Metrônomo if (appState.global.metronomeEnabled) { const noteValue = parseInt(document.getElementById("compasso-b-input").value, 10) || 4; const stepsPerBeat = 16 / noteValue; @@ -67,12 +85,16 @@ function tick() { } } + // Percorre tracks e toca o step atual se ativo appState.pattern.tracks.forEach((track) => { if (!track.patterns || track.patterns.length === 0) return; - - const activePattern = track.patterns[appState.pattern.activePatternIndex]; - if (activePattern && activePattern.steps[appState.global.currentStep] && track.samplePath) { + // IMPORTANTE: usar o pattern ativo da PRÓPRIA TRILHA + const activePattern = track.patterns[track.activePatternIndex]; + + if (activePattern && + activePattern.steps[appState.global.currentStep] && + track.samplePath) { playSample(track.samplePath, track.id); } }); @@ -84,9 +106,9 @@ function tick() { export function startPlayback() { if (appState.global.isPlaying || appState.pattern.tracks.length === 0) return; initializeAudioContext(); - + if (appState.global.currentStep === 0) { - rewindPlayback(); + rewindPlayback(); } const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120; @@ -96,15 +118,18 @@ export function startPlayback() { if (appState.global.playbackIntervalId) clearInterval(appState.global.playbackIntervalId); appState.global.isPlaying = true; - document.getElementById("play-btn").classList.remove("fa-play"); - document.getElementById("play-btn").classList.add("fa-pause"); + const playBtn = document.getElementById("play-btn"); + if (playBtn) { + playBtn.classList.remove("fa-play"); + playBtn.classList.add("fa-pause"); + } tick(); appState.global.playbackIntervalId = setInterval(tick, stepInterval); } export function stopPlayback() { - if(appState.global.playbackIntervalId) { + if (appState.global.playbackIntervalId) { clearInterval(appState.global.playbackIntervalId); } appState.global.playbackIntervalId = null; @@ -138,4 +163,4 @@ export function togglePlayback() { appState.global.currentStep = 0; startPlayback(); } -} \ No newline at end of file +} diff --git a/assets/js/creations/pattern/pattern_bounce.js b/assets/js/creations/pattern/pattern_bounce.js new file mode 100644 index 00000000..76347795 --- /dev/null +++ b/assets/js/creations/pattern/pattern_bounce.js @@ -0,0 +1,108 @@ +// js/pattern/pattern_bounce.js +import { appState } from '../state.js'; +import { getSecondsPerStep } from '../utils.js'; +import { addAudioClipToTimeline, addAudioTrackLane } from '../audio/audio_state.js'; +import { getAudioContext } from '../audio.js'; +import { renderAudioEditor } from '../audio/audio_ui.js'; + +/** + * Renderiza (bounce) o pattern de beat atualmente ativo para uma nova pista de áudio. + */ +export async function bounceActivePatternToAudio() { + console.log("Iniciando 'bounce' do pattern..."); + + // 1. Encontrar o pattern ativo + const activeTrackId = appState.pattern.activeTrackId; + + // --- DEBUG --- + console.log(`[DEBUG bounce] activeTrackId lido do estado: ${activeTrackId}`); + + const activeTrack = appState.pattern.tracks.find(t => t.id === activeTrackId); + + // --- DEBUG --- + if (activeTrack) { + console.log(`[DEBUG bounce] Pista ativa encontrada:`, activeTrack.name); + console.log(`[DEBUG bounce] Verificando track.buffer:`, activeTrack.buffer); + } else { + console.error(`[DEBUG bounce] NENHUMA PISTA ATIVA ENCONTRADA. activeTrackId é nulo ou inválido.`); + console.log(`[DEBUG bounce] Pistas disponíveis no estado:`, appState.pattern.tracks.map(t => ({id: t.id, name: t.name}))); + } + // --- FIM DEBUG --- + + if (!activeTrack) { + alert('Nenhuma pista de pattern selecionada para renderizar.'); + return; + } + + const activePattern = activeTrack.patterns[activeTrack.activePatternIndex]; + if (!activePattern) { + alert('Nenhum pattern ativo encontrado na pista.'); + return; + } + + const trackBuffer = activeTrack.buffer; + if (!trackBuffer) { + alert('O áudio (sample) desta pista ainda não foi carregado.'); + return; + } + + // 2. Calcular a duração do pattern + const steps = activePattern.steps; + const totalSteps = steps.length; + const secondsPerStep = getSecondsPerStep(); // + const duration = totalSteps * secondsPerStep; + + if (duration <= 0) { + alert("Pattern está vazio ou com duração zero."); + return; + } + + try { + // 3. Usar Tone.Offline para renderizar o áudio + const audioBuffer = await Tone.Offline(async (offlineCtx) => { + + const gainNode = new Tone.Gain(Tone.gainToDb(activeTrack.volume)).connect(offlineCtx.destination); + const pannerNode = new Tone.Panner(activeTrack.pan).connect(gainNode); // + + const now = offlineCtx.currentTime; + + // --- INÍCIO DA CORREÇÃO (que estava na versão anterior) --- + const offlineBuffer = offlineCtx.createBuffer( + trackBuffer.numberOfChannels, + trackBuffer.length, + trackBuffer.sampleRate + ); + + for (let i = 0; i < trackBuffer.numberOfChannels; i++) { + offlineBuffer.copyToChannel(trackBuffer.getChannelData(i), i); + } + // --- FIM DA CORREÇÃO --- + + // Agendar todas as notas (steps) + steps.forEach((isActive, index) => { + if (isActive) { + const time = now + (index * secondsPerStep); + const source = new Tone.BufferSource(offlineBuffer).connect(pannerNode); + source.start(time); + } + }); + + }, duration); + + // 4. Áudio renderizado. Agora, adicione-o ao editor de áudio. + + addAudioTrackLane(); // + const newTrack = appState.audio.tracks[appState.audio.tracks.length - 1]; // + const newTrackId = newTrack.id; + + const clipName = `${activeTrack.name}_(${activePattern.name})`; + + addAudioClipToTimeline(null, newTrackId, 0, clipName, audioBuffer); // + + console.log("Pattern renderizado com sucesso!"); + + } catch (err) { + console.error("Erro ao renderizar o pattern:", err); + alert(`Erro ao renderizar pattern: ${err.message}`); + } +} \ No newline at end of file diff --git a/assets/js/creations/pattern/pattern_state.js b/assets/js/creations/pattern/pattern_state.js index c584c6de..df65e212 100644 --- a/assets/js/creations/pattern/pattern_state.js +++ b/assets/js/creations/pattern/pattern_state.js @@ -1,97 +1,148 @@ // js/pattern_state.js +import * as Tone from "https://esm.sh/tone"; + +import { appState } from "../state.js"; import { DEFAULT_VOLUME, DEFAULT_PAN } from "../config.js"; -import { getAudioContext, getMainGainNode } from "../audio.js"; -import { renderPatternEditor } from "./pattern_ui.js"; +import { getMainGainNode } from "../audio.js"; import { getTotalSteps } from "../utils.js"; -const initialState = { - tracks: [], - activeTrackId: null, - activePatternIndex: 0, -}; - -export let patternState = { ...initialState }; - export function initializePatternState() { - Object.assign(patternState, initialState, { tracks: [] }); + // Limpa players/buffers existentes + appState.pattern.tracks.forEach(track => { + try { track.player?.dispose(); } catch {} + try { track.buffer?.dispose?.(); } catch {} + }); + + // Reseta estado do editor de pattern + appState.pattern.tracks = []; + appState.pattern.activeTrackId = null; + appState.pattern.activePatternIndex = 0; } -// --- FUNÇÃO CORRIGIDA --- -// Agora, esta função cria e pré-carrega um Tone.Player para a faixa. export async function loadAudioForTrack(track) { if (!track.samplePath) return track; + try { - // Se já existir um player antigo, o descartamos para liberar memória. - if (track.player) { - track.player.dispose(); - } - - // Cria um novo Tone.Player e o conecta à cadeia de áudio da faixa. - // O 'await' garante que o áudio seja totalmente carregado antes de prosseguirmos. - track.player = await new Tone.Player(track.samplePath).toDestination(); - track.player.chain(track.gainNode, track.pannerNode, getMainGainNode()); - - } catch (error) { - console.error(`Falha ao carregar áudio para a trilha ${track.name}:`, error); + // Descartar player/buffer anteriores com segurança + try { track.player?.dispose(); } catch {} track.player = null; + try { track.buffer?.dispose?.(); } catch {} + track.buffer = null; + + // Garante nós de volume/pan (Opção B: Volume em dB) + if (!track.volumeNode) { + track.volumeNode = new Tone.Volume( + track.volume === 0 ? -Infinity : Tone.gainToDb(track.volume) + ); + } else { + track.volumeNode.volume.value = + track.volume === 0 ? -Infinity : Tone.gainToDb(track.volume); + } + + if (!track.pannerNode) { + track.pannerNode = new Tone.Panner(track.pan ?? 0); + } else { + track.pannerNode.pan.value = track.pan ?? 0; + } + + // Encadeia: Volume(dB) -> Panner -> Master + try { track.volumeNode.disconnect(); } catch {} + try { track.pannerNode.disconnect(); } catch {} + track.volumeNode.connect(track.pannerNode); + track.pannerNode.connect(getMainGainNode()); + + // Cria e carrega o Player + const player = new Tone.Player({ url: track.samplePath, autostart: false }); + await player.load(track.samplePath); // garante buffer carregado + + // Conecta o player ao volumeNode + player.connect(track.volumeNode); + + // Buffer separado (se você usar waveform em outro lugar) + const buffer = new Tone.Buffer(); + await buffer.load(track.samplePath); + + // Atribuições finais + track.player = player; + track.buffer = buffer; + + } catch (error) { + console.error('Erro ao carregar sample:', track.samplePath); + console.error(`Falha ao carregar áudio para a trilha "${track.name}":`, error); + try { track.player?.dispose(); } catch {} + try { track.buffer?.dispose?.(); } catch {} + track.player = null; + track.buffer = null; } return track; } export function addTrackToState() { - const mainGainNode = getMainGainNode(); const totalSteps = getTotalSteps(); - const referenceTrack = patternState.tracks[0]; + const referenceTrack = appState.pattern.tracks[0]; const newTrack = { - id: Date.now(), + id: Date.now() + Math.random(), name: "novo instrumento", samplePath: null, - player: null, // <-- ADICIONADO: O player começará como nulo - patterns: referenceTrack - ? referenceTrack.patterns.map(p => ({ name: p.name, steps: new Array(p.steps.length).fill(false), pos: p.pos })) + player: null, + buffer: null, + patterns: referenceTrack + ? referenceTrack.patterns.map(p => ({ + name: p.name, + steps: new Array(p.steps.length).fill(false), + pos: p.pos + })) : [{ name: "Pattern 1", steps: new Array(totalSteps).fill(false), pos: 0 }], activePatternIndex: 0, volume: DEFAULT_VOLUME, pan: DEFAULT_PAN, - gainNode: new Tone.Gain(Tone.gainToDb(DEFAULT_VOLUME)), + // Opção B: controlar volume em dB + volumeNode: new Tone.Volume(Tone.gainToDb(DEFAULT_VOLUME)), pannerNode: new Tone.Panner(DEFAULT_PAN), }; - newTrack.gainNode.chain(newTrack.pannerNode, mainGainNode); + // Cadeia de áudio nova + newTrack.volumeNode.connect(newTrack.pannerNode); + newTrack.pannerNode.connect(getMainGainNode()); - patternState.tracks.push(newTrack); - renderPatternEditor(); + appState.pattern.tracks.push(newTrack); + appState.pattern.activeTrackId = newTrack.id; } export function removeLastTrackFromState() { - if (patternState.tracks.length > 0) { - const trackToRemove = patternState.tracks[patternState.tracks.length - 1]; - if (trackToRemove.player) trackToRemove.player.dispose(); - if (trackToRemove.pannerNode) trackToRemove.pannerNode.dispose(); - if (trackToRemove.gainNode) trackToRemove.gainNode.dispose(); - - patternState.tracks.pop(); - renderPatternEditor(); + if (appState.pattern.tracks.length > 0) { + const trackToRemove = appState.pattern.tracks[appState.pattern.tracks.length - 1]; + + try { trackToRemove.player?.dispose(); } catch {} + try { trackToRemove.buffer?.dispose?.(); } catch {} + try { trackToRemove.pannerNode?.disconnect(); } catch {} + try { trackToRemove.volumeNode?.disconnect(); } catch {} + + appState.pattern.tracks.pop(); + if (appState.pattern.activeTrackId === trackToRemove.id) { + appState.pattern.activeTrackId = appState.pattern.tracks[0]?.id ?? null; + } } } -export async function updateTrackSample(trackId, samplePath) { - const track = patternState.tracks.find((t) => t.id == trackId); +export async function updateTrackSample(trackIndex, samplePath) { + const track = appState.pattern.tracks[trackIndex]; if (track) { track.samplePath = samplePath; track.name = samplePath.split("/").pop(); - await loadAudioForTrack(track); // Carrega o novo player - renderPatternEditor(); + + // (re)carrega e reconecta corretamente o player nesta trilha + await loadAudioForTrack(track); } } -export function toggleStepState(trackId, stepIndex) { - const track = patternState.tracks.find((t) => t.id == trackId); +export function toggleStepState(trackIndex, stepIndex) { + const track = appState.pattern.tracks[trackIndex]; if (track && track.patterns && track.patterns.length > 0) { const activePattern = track.patterns[track.activePatternIndex]; if (activePattern && activePattern.steps.length > stepIndex) { activePattern.steps[stepIndex] = !activePattern.steps[stepIndex]; } } -} \ No newline at end of file +} diff --git a/assets/js/creations/pattern/pattern_ui.js b/assets/js/creations/pattern/pattern_ui.js index 746e4745..caf54f0e 100644 --- a/assets/js/creations/pattern/pattern_ui.js +++ b/assets/js/creations/pattern/pattern_ui.js @@ -1,171 +1,218 @@ // js/pattern_ui.js import { appState } from "../state.js"; import { - toggleStepState, - updateTrackSample +    updateTrackSample } from "./pattern_state.js"; -import { playSample, stopPlayback } from "./pattern_audio.js"; // Será criado no próximo passo +import { playSample, stopPlayback } from "./pattern_audio.js"; import { getTotalSteps } from "../utils.js"; +import { sendAction } from '../socket.js'; +import { initializeAudioContext } from '../audio.js'; // Função principal de renderização para o editor de patterns export function renderPatternEditor() { - const trackContainer = document.getElementById("track-container"); - trackContainer.innerHTML = ""; +  const trackContainer = document.getElementById("track-container"); +  trackContainer.innerHTML = ""; - appState.pattern.tracks.forEach((trackData) => { - const trackLane = document.createElement("div"); - trackLane.className = "track-lane"; - trackLane.dataset.trackId = trackData.id; + // (V7) Adicionado 'trackIndex' +  appState.pattern.tracks.forEach((trackData, trackIndex) => { +    const trackLane = document.createElement("div"); +    trackLane.className = "track-lane"; +    trackLane.dataset.trackIndex = trackIndex; // (V7) Usando índice - if (trackData.id === appState.pattern.activeTrackId) { - trackLane.classList.add('active-track'); - } +    if (trackData.id === appState.pattern.activeTrackId) { +        trackLane.classList.add('active-track'); +    } - trackLane.innerHTML = ` -
- -
- ${trackData.name} -
-
-
-
- VOL -
-
-
- PAN -
-
-
- `; +    trackLane.innerHTML = ` +     
+        +       
+        ${trackData.name} +     
+     
+       
+         
+          VOL +       
+       
+         
+          PAN +       
+     
+     
+    `; - trackLane.addEventListener('click', () => { - if (appState.pattern.activeTrackId === trackData.id) return; - stopPlayback(); - appState.pattern.activeTrackId = trackData.id; - document.querySelectorAll('.track-lane').forEach(lane => lane.classList.remove('active-track')); - trackLane.classList.add('active-track'); - updateGlobalPatternSelector(); - redrawSequencer(); - }); + // (Listener de clique da track é local, sem mudanças) +    trackLane.addEventListener('click', () => { +        if (appState.pattern.activeTrackId === trackData.id) return; +        stopPlayback(); +        appState.pattern.activeTrackId = trackData.id; +        document.querySelectorAll('.track-lane').forEach(lane => lane.classList.remove('active-track')); +        trackLane.classList.add('active-track'); +        updateGlobalPatternSelector(); +        redrawSequencer(); +    }); - trackLane.addEventListener("dragover", (e) => { e.preventDefault(); trackLane.classList.add("drag-over"); }); - trackLane.addEventListener("dragleave", () => trackLane.classList.remove("drag-over")); - trackLane.addEventListener("drop", (e) => { - e.preventDefault(); - trackLane.classList.remove("drag-over"); - const filePath = e.dataTransfer.getData("text/plain"); - if (filePath) { - updateTrackSample(trackData.id, filePath); - } - }); +    trackLane.addEventListener("dragover", (e) => { e.preventDefault(); trackLane.classList.add("drag-over"); }); +    trackLane.addEventListener("dragleave", () => trackLane.classList.remove("drag-over")); - trackContainer.appendChild(trackLane); - // A lógica dos knobs precisará ser reimplementada ou movida para um arquivo de componentes - }); - - updateGlobalPatternSelector(); - redrawSequencer(); + // (V9) Listener de "drop" (arrastar) agora usa 'sendAction' +    trackLane.addEventListener("drop", (e) => { +      e.preventDefault(); +      trackLane.classList.remove("drag-over"); +      const filePath = e.dataTransfer.getData("text/plain"); +      +      if (filePath) { + sendAction({ + type: 'SET_TRACK_SAMPLE', + trackIndex: trackIndex, + filePath: filePath + }); +      } +    }); + +    trackContainer.appendChild(trackLane); +  }); +  +  updateGlobalPatternSelector(); +  redrawSequencer(); } export function redrawSequencer() { - const totalGridSteps = getTotalSteps(); - document.querySelectorAll(".step-sequencer-wrapper").forEach((wrapper) => { - let sequencerContainer = wrapper.querySelector(".step-sequencer"); - if (!sequencerContainer) { - sequencerContainer = document.createElement("div"); - sequencerContainer.className = "step-sequencer"; - wrapper.appendChild(sequencerContainer); - } - - const parentTrackElement = wrapper.closest(".track-lane"); - const trackId = parentTrackElement.dataset.trackId; - const trackData = appState.pattern.tracks.find((t) => t.id == trackId); +  const totalGridSteps = getTotalSteps(); +  document.querySelectorAll(".step-sequencer-wrapper").forEach((wrapper) => { +    let sequencerContainer = wrapper.querySelector(".step-sequencer"); +    if (!sequencerContainer) { +      sequencerContainer = document.createElement("div"); +      sequencerContainer.className = "step-sequencer"; +      wrapper.appendChild(sequencerContainer); +    } +    +    const parentTrackElement = wrapper.closest(".track-lane"); +    const trackIndex = parseInt(parentTrackElement.dataset.trackIndex, 10); // (V7) +    // ... dentro da função redrawSequencer() ... - if (!trackData || !trackData.patterns || trackData.patterns.length === 0) { - sequencerContainer.innerHTML = ""; return; - } +    const trackData = appState.pattern.tracks[trackIndex]; - const activePattern = trackData.patterns[appState.pattern.activePatternIndex]; - if (!activePattern) { - sequencerContainer.innerHTML = ""; return; - } - const patternSteps = activePattern.steps; +    if (!trackData || !trackData.patterns || trackData.patterns.length === 0) { +      sequencerContainer.innerHTML = ""; return; +    } - sequencerContainer.innerHTML = ""; - for (let i = 0; i < totalGridSteps; i++) { - const stepWrapper = document.createElement("div"); - stepWrapper.className = "step-wrapper"; - const stepElement = document.createElement("div"); - stepElement.className = "step"; - - if (patternSteps[i] === true) { - stepElement.classList.add("active"); - } + // --- CORRIJA ESTAS DUAS LINHAS --- + // ANTES: + // const activePatternIndex = appState.pattern.activePatternIndex; + // const activePattern = trackData.patterns[activePatternIndex]; + // + // DEPOIS: +    const activePatternIndex = trackData.activePatternIndex; +    const activePattern = trackData.patterns[activePatternIndex]; - stepElement.addEventListener("click", () => { - toggleStepState(trackData.id, i); - stepElement.classList.toggle("active"); - if (trackData && trackData.samplePath) { - playSample(trackData.samplePath, trackData.id); - } - }); +    if (!activePattern) { +        sequencerContainer.innerHTML = ""; return; +    } +// ... resto da função ... +    const patternSteps = activePattern.steps; - const beatsPerBar = parseInt(document.getElementById("compasso-a-input").value, 10) || 4; - const groupIndex = Math.floor(i / beatsPerBar); - if (groupIndex % 2 === 0) { - stepElement.classList.add("step-dark"); - } +    sequencerContainer.innerHTML = ""; +    for (let i = 0; i < totalGridSteps; i++) { +      const stepWrapper = document.createElement("div"); +      stepWrapper.className = "step-wrapper"; +      const stepElement = document.createElement("div"); +      stepElement.className = "step"; +      +      if (patternSteps[i] === true) { +        stepElement.classList.add("active"); +      } - const stepsPerBar = 16; - if (i > 0 && i % stepsPerBar === 0) { - const marker = document.createElement("div"); - marker.className = "step-marker"; - marker.textContent = Math.floor(i / stepsPerBar) + 1; - stepWrapper.appendChild(marker); - } - - stepWrapper.appendChild(stepElement); - sequencerContainer.appendChild(stepWrapper); - } - }); +      stepElement.addEventListener("click", () => { + initializeAudioContext(); // (V8) + + const currentState = activePattern.steps[i] || false; + const isActive = !currentState; + + sendAction({ // (V7) + type: 'TOGGLE_NOTE', + trackIndex: trackIndex, + patternIndex: activePatternIndex, + stepIndex: i, + isActive: isActive + }); + +        if (isActive && trackData && trackData.samplePath) { +          playSample(trackData.samplePath, trackData.id); +        } +      }); + +      const beatsPerBar = parseInt(document.getElementById("compasso-a-input").value, 10) || 4; +      const groupIndex = Math.floor(i / beatsPerBar); +      if (groupIndex % 2 === 0) { +        stepElement.classList.add("step-dark"); +      } + +      const stepsPerBar = 16; +      if (i > 0 && i % stepsPerBar === 0) { +        const marker = document.createElement("div"); +        marker.className = "step-marker"; +        marker.textContent = Math.floor(i / stepsPerBar) + 1; +        stepWrapper.appendChild(marker); +      } +      +      stepWrapper.appendChild(stepElement); +      sequencerContainer.appendChild(stepWrapper); +    } +  }); } export function updateGlobalPatternSelector() { - const globalPatternSelector = document.getElementById('global-pattern-selector'); - if (!globalPatternSelector) return; +    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; - } +    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) => { - const stepWrapper = track.querySelector( - `.step-sequencer .step-wrapper:nth-child(${stepIndex + 1})` - ); - if (stepWrapper) { - const stepElement = stepWrapper.querySelector(".step"); - if (stepElement) { - stepElement.classList.toggle("playing", isActive); - } - } - }); +  if (stepIndex < 0) return; +  document.querySelectorAll(".track-lane").forEach((track) => { +    const stepWrapper = track.querySelector( +      `.step-sequencer .step-wrapper:nth-child(${stepIndex + 1})` +    ); +    if (stepWrapper) { +      const stepElement = stepWrapper.querySelector(".step"); +      if (stepElement) { +        stepElement.classList.toggle("playing", isActive); +      } +    } +  }); +} + +// (V7) Função de UI "cirúrgica" +export function updateStepUI(trackIndex, patternIndex, stepIndex, isActive) { + if (patternIndex !== appState.pattern.activePatternIndex) { + return; + } + const trackElement = document.querySelector(`.track-lane[data-track-index="${trackIndex}"]`); + if (!trackElement) return; + const stepWrapper = trackElement.querySelector( + `.step-sequencer .step-wrapper:nth-child(${stepIndex + 1})` + ); + if (!stepWrapper) return; + const stepElement = stepWrapper.querySelector(".step"); + if (!stepElement) return; + stepElement.classList.toggle("active", isActive); } \ No newline at end of file diff --git a/assets/js/creations/recording.js b/assets/js/creations/recording.js index 83868c79..04a31d68 100644 --- a/assets/js/creations/recording.js +++ b/assets/js/creations/recording.js @@ -145,7 +145,7 @@ async function _startRecording() { } catch (err) { console.error("Erro ao iniciar a gravação:", err); appState.global.isRecording = false; // - _updateRecordButtonUI(false); // + _updateRecordButtonUI(false); } } @@ -188,7 +188,7 @@ async function _stopRecording() { } catch (err) { console.error("Erro ao parar a gravação:", err); appState.global.isRecording = false; // - _updateRecordButtonUI(false); // + _updateRecordButtonUI(false); } } @@ -215,12 +215,9 @@ function _processRecording(blob) { const clipName = `Rec_${new Date().toISOString().slice(11, 19).replace(/:/g, '-')}`; // Adiciona o clipe à pista que já criamos - addAudioClipToTimeline(blobUrl, targetTrackId, 0, clipName); // + addAudioClipToTimeline(blobUrl, targetTrackId, 0, clipName, null); // - // addAudioClipToTimeline já chama o render, mas como o estado mudou - // (o clipe foi adicionado), renderizar de novo garante que o - // waveform *final* (do blob) seja desenhado corretamente. - renderAudioEditor(); // + // addAudioClipToTimeline já chama o render } /** diff --git a/assets/js/creations/server/data/2025-10-26_12-23-07_teste.log b/assets/js/creations/server/data/2025-10-26_12-23-07_teste.log new file mode 100644 index 00000000..7e78de47 --- /dev/null +++ b/assets/js/creations/server/data/2025-10-26_12-23-07_teste.log @@ -0,0 +1,112 @@ +{"level":30,"time":1761492187196,"pid":354004,"hostname":"ubuntu","timestamp":1761492187195,"socketId":"PLrva9NwvwbkRt8zAAAD","action":{"type":"AUDIO_SNAPSHOT_REQUEST","__token":"1","__senderId":"PLrva9NwvwbkRt8zAAAD","__senderName":"Alicer-PLrv"},"msg":"action_received"} +{"level":30,"time":1761492196982,"pid":354004,"hostname":"ubuntu","timestamp":1761492196982,"socketId":"PLrva9NwvwbkRt8zAAAD","action":{"type":"TOGGLE_NOTE","trackIndex":0,"patternIndex":0,"stepIndex":0,"isActive":true,"__token":"2","__senderId":"PLrva9NwvwbkRt8zAAAD","__senderName":"Alicer-PLrv"},"msg":"action_received"} +{"level":30,"time":1761492198269,"pid":354004,"hostname":"ubuntu","timestamp":1761492198269,"socketId":"PLrva9NwvwbkRt8zAAAD","action":{"type":"TOGGLE_NOTE","trackIndex":0,"patternIndex":0,"stepIndex":0,"isActive":false,"__token":"3","__senderId":"PLrva9NwvwbkRt8zAAAD","__senderName":"Alicer-PLrv"},"msg":"action_received"} +{"level":30,"time":1761492301931,"pid":354004,"hostname":"ubuntu","timestamp":1761492301930,"socketId":"PLrva9NwvwbkRt8zAAAD","action":{"type":"TOGGLE_NOTE","trackIndex":0,"patternIndex":0,"stepIndex":3,"isActive":true,"__token":"4","__senderId":"PLrva9NwvwbkRt8zAAAD","__senderName":"Alicer-PLrv"},"msg":"action_received"} +{"level":30,"time":1761492315357,"pid":354004,"hostname":"ubuntu","timestamp":1761492315357,"socketId":"h9OzByrZPHiMwolxAAAL","action":{"type":"AUDIO_SNAPSHOT_REQUEST","__token":"1","__senderId":"h9OzByrZPHiMwolxAAAL","__senderName":"Alicer-h9Oz"},"msg":"action_received"} +{"level":30,"time":1761492316108,"pid":354004,"hostname":"ubuntu","timestamp":1761492316108,"socketId":"h9OzByrZPHiMwolxAAAL","action":{"type":"TOGGLE_NOTE","trackIndex":0,"patternIndex":0,"stepIndex":4,"isActive":true,"__token":"2","__senderId":"h9OzByrZPHiMwolxAAAL","__senderName":"Alicer-h9Oz"},"msg":"action_received"} +{"level":30,"time":1761492317525,"pid":354004,"hostname":"ubuntu","timestamp":1761492317525,"socketId":"h9OzByrZPHiMwolxAAAL","action":{"type":"TOGGLE_NOTE","trackIndex":0,"patternIndex":0,"stepIndex":6,"isActive":true,"__token":"3","__senderId":"h9OzByrZPHiMwolxAAAL","__senderName":"Alicer-h9Oz"},"msg":"action_received"} +{"level":30,"time":1761492318772,"pid":354004,"hostname":"ubuntu","timestamp":1761492318772,"socketId":"h9OzByrZPHiMwolxAAAL","action":{"type":"TOGGLE_NOTE","trackIndex":0,"patternIndex":0,"stepIndex":1,"isActive":true,"__token":"4","__senderId":"h9OzByrZPHiMwolxAAAL","__senderName":"Alicer-h9Oz"},"msg":"action_received"} +{"level":30,"time":1761492321132,"pid":354004,"hostname":"ubuntu","timestamp":1761492321132,"socketId":"h9OzByrZPHiMwolxAAAL","action":{"type":"TOGGLE_NOTE","trackIndex":0,"patternIndex":0,"stepIndex":0,"isActive":true,"__token":"5","__senderId":"h9OzByrZPHiMwolxAAAL","__senderName":"Alicer-h9Oz"},"msg":"action_received"} +{"level":30,"time":1761492341747,"pid":354004,"hostname":"ubuntu","timestamp":1761492341747,"socketId":"h9OzByrZPHiMwolxAAAL","action":{"type":"RESET_PROJECT","__token":"6","__senderId":"h9OzByrZPHiMwolxAAAL","__senderName":"Alicer-h9Oz"},"msg":"action_received"} +{"level":30,"time":1761492843662,"pid":354004,"hostname":"ubuntu","timestamp":1761492843662,"socketId":"PLrva9NwvwbkRt8zAAAD","action":{"type":"LOAD_PROJECT","xml":"\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n Volume\" len=\"9216\" mute=\"0\">\n