From 69038396431ee1ff4b2424d2ba164804c4019c6c Mon Sep 17 00:00:00 2001 From: JotaChina Date: Wed, 22 Oct 2025 19:21:51 -0300 Subject: [PATCH] =?UTF-8?q?vers=C3=A3o=202.0.1=20-=20Corre=C3=A7=C3=A3o=20?= =?UTF-8?q?do=20grid=20(Editor=20de=20Samples),=20loop,=20delete,=20resize?= =?UTF-8?q?,=20trim=20e=20streching=20funcionais?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/css/style.css | 15 +- assets/js/creations/audio/audio_audio.js | 398 ++++++++++++++++------- assets/js/creations/audio/audio_state.js | 119 +++++-- assets/js/creations/audio/audio_ui.js | 360 ++++++++++++++++---- assets/js/creations/main.js | 134 ++++++-- assets/js/creations/state.js | 12 +- assets/js/creations/ui.js | 4 +- assets/js/creations/utils.js | 59 +++- creation.html | 5 + 9 files changed, 856 insertions(+), 250 deletions(-) diff --git a/assets/css/style.css b/assets/css/style.css index 6592aa6..a8bfced 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -246,6 +246,7 @@ body.sidebar-hidden .sample-browser { height: 100%; position: relative; display: block; + /* Estas variáveis são agora definidas dinamicamente por audio_ui.js */ --step-width: 32px; --beat-width: 128px; --bar-width: 512px; @@ -305,7 +306,8 @@ body.sidebar-hidden .sample-browser { box-shadow: 0 3px 8px rgba(0,0,0,0.5); display: flex; align-items: center; - padding: 0 8px; + /* --- CORREÇÃO: Remove o padding horizontal --- */ + padding: 0; overflow: hidden; cursor: grab; user-select: none; @@ -342,13 +344,22 @@ body.sidebar-hidden .sample-browser { .loop-handle { position: absolute; top: 0; bottom: 0; width: 10px; cursor: ew-resize; } .loop-handle.left { left: -5px; } .loop-handle.right { right: -5px; } -#slice-tool-btn.active { color: var(--accent-blue); } + +/* --- ESTILOS ADICIONADOS --- */ +#slice-tool-btn.active, +#resize-tool-trim.active, +#resize-tool-stretch.active { + color: var(--accent-blue); + background-color: var(--bg-editor); + border-radius: 3px; +} #audio-editor-loop-btn.active { color: var(--accent-green); } /* =============================================== */ /* COMPONENTES GERAIS (KNOBS, BOTÕES, INPUTS) /* =============================================== */ .knob-container { text-align: center; font-size: .7rem; color: var(--text-dark); } +.knob-container { text-align: center; font-size: .7rem; color: var(--text-dark); } .knob { width: 28px; height: 28px; background-color: var(--bg-toolbar); border-radius: 50%; border: 1px solid var(--border-color); margin-bottom: 2px; cursor: grab; box-shadow: inset 0 0 4px #222; position: relative; } .knob:active { cursor: grabbing; } .knob-indicator { width: 2px; height: 8px; background-color: var(--text-light); position: absolute; top: 2px; left: 50%; transform-origin: bottom center; transform: translateX(-50%) rotate(0deg); border-radius: 1px; } diff --git a/assets/js/creations/audio/audio_audio.js b/assets/js/creations/audio/audio_audio.js index 3b7fdcf..0c8e8e9 100644 --- a/assets/js/creations/audio/audio_audio.js +++ b/assets/js/creations/audio/audio_audio.js @@ -1,154 +1,304 @@ -// js/audio_audio.js +// js/audio/audio_audio.js import { appState } from "../state.js"; import { updateAudioEditorUI, updatePlayheadVisual, resetPlayheadVisual } from "./audio_ui.js"; -import { PIXELS_PER_STEP } from "../config.js"; -import { initializeAudioContext } from "../audio.js"; +import { initializeAudioContext, getAudioContext } from "../audio.js"; import { getPixelsPerSecond } from "../utils.js"; -function animationLoop() { - if (!appState.global.isAudioEditorPlaying) return; +// --- Configurações do Scheduler --- +const LOOKAHEAD_INTERVAL_MS = 25.0; +const SCHEDULE_AHEAD_TIME_SEC = 0.5; // 500ms - const pixelsPerSecond = getPixelsPerSecond(); - const totalElapsedTime = Tone.Transport.seconds; - - let maxTime = 0; - appState.audio.clips.forEach(clip => { - const endTime = clip.startTime + clip.duration; - if (endTime > maxTime) maxTime = endTime; - }); +// --- Estado Interno do Engine --- +let audioCtx = null; +let isPlaying = false; +let schedulerIntervalId = null; +let animationFrameId = null; - if (!appState.global.isLoopActive && totalElapsedTime >= maxTime && maxTime > 0) { - stopAudioEditorPlayback(); - resetPlayheadVisual(); - return; - } - - const newPositionPx = totalElapsedTime * pixelsPerSecond; - updatePlayheadVisual(newPositionPx); +// Sincronização de Tempo +let startTime = 0; +let seekTime = 0; +let logicalPlaybackTime = 0; - // ##### CORREÇÃO 1 ##### - // Salva o ID da animação para que o stop possa cancelá-lo - appState.audio.audioEditorAnimationId = requestAnimationFrame(animationLoop); +// Configurações de Loop +let isLoopActive = false; +let loopStartTimeSec = 0; +let loopEndTimeSec = 8; + +const runtimeClipState = new Map(); +const scheduledNodes = new Map(); +let nextEventId = 0; + +const callbacks = { + onClipScheduled: null, + onClipPlayed: null, +}; + +// --- Funções Auxiliares de Tempo (sem alterações) --- +function _getBpm() { + const bpmInput = document.getElementById("bpm-input"); + return parseFloat(bpmInput.value) || 120; +} +function _getSecondsPerBeat() { return 60.0 / _getBpm(); } +function _convertBeatToSeconds(beat) { return beat * _getSecondsPerBeat(); } +function _convertSecondsToBeat(seconds) { return seconds / _getSecondsPerBeat(); } + + +function _initContext() { + if (!audioCtx) { + initializeAudioContext(); + audioCtx = getAudioContext(); + } } +// --- Lógica Principal do Scheduler (sem alterações) --- + +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 --- + + const eventId = nextEventId++; + const clipOffset = clip.offsetInSeconds || clip.offset || 0; + source.start(absolutePlayTime, clipOffset, durationSec); + scheduledNodes.set(eventId, { sourceNode: source, clipId: clip.id }); + + if (callbacks.onClipScheduled) { + callbacks.onClipScheduled(clip); + } + + source.onended = () => { + _handleClipEnd(eventId, clip.id); + source.dispose(); + }; +} + +function _handleClipEnd(eventId, clipId) { + scheduledNodes.delete(eventId); + runtimeClipState.delete(clipId); + + if (callbacks.onClipPlayed) { + const clip = appState.audio.clips.find(c => c.id == clipId); + if(clip) callbacks.onClipPlayed(clip); + } +} + +function _schedulerTick() { + if (!isPlaying || !audioCtx) return; + + const now = audioCtx.currentTime; + const logicalTime = (now - startTime) + seekTime; + 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; + } + + const clipStartTimeSec = clip.startTimeInSeconds; + const clipDurationSec = clip.durationInSeconds; + + if (typeof clipStartTimeSec === 'undefined' || typeof clipDurationSec === 'undefined') { + continue; + } + + let occurrenceStartTimeSec = clipStartTimeSec; + + if (isLoopActive) { + const loopDuration = loopEndTimeSec - loopStartTimeSec; + if (loopDuration <= 0) continue; + if (occurrenceStartTimeSec < loopStartTimeSec && logicalTime >= loopStartTimeSec) { + const offsetFromLoopStart = (occurrenceStartTimeSec - loopStartTimeSec) % loopDuration; + occurrenceStartTimeSec = loopStartTimeSec + (offsetFromLoopStart < 0 ? offsetFromLoopStart + loopDuration : offsetFromLoopStart); + } + if (occurrenceStartTimeSec < logicalTime) { + const loopsMissed = Math.floor((logicalTime - occurrenceStartTimeSec) / loopDuration) + 1; + occurrenceStartTimeSec += loopsMissed * loopDuration; + } + } + if ( + occurrenceStartTimeSec >= scheduleWindowStartSec && + occurrenceStartTimeSec < scheduleWindowEndSec + ) { + const absolutePlayTime = startTime + (occurrenceStartTimeSec - seekTime); + _scheduleClip(clip, absolutePlayTime, clipDurationSec); + clipRuntime.isScheduled = true; + runtimeClipState.set(clip.id, clipRuntime); + } + } +} + +// --- Loop de Animação (sem alterações) --- +function _animationLoop() { + if (!isPlaying) { + animationFrameId = null; + return; + } + const now = audioCtx.currentTime; + let newLogicalTime = (now - startTime) + seekTime; + if (isLoopActive) { + if (newLogicalTime >= loopEndTimeSec) { + const loopDuration = loopEndTimeSec - loopStartTimeSec; + newLogicalTime = loopStartTimeSec + ((newLogicalTime - loopStartTimeSec) % loopDuration); + startTime = now; + seekTime = newLogicalTime; + } + } + logicalPlaybackTime = newLogicalTime; + if (!isLoopActive) { + let maxTime = 0; + appState.audio.clips.forEach(clip => { + const clipStartTime = clip.startTimeInSeconds || 0; + const clipDuration = clip.durationInSeconds || 0; + const endTime = clipStartTime + clipDuration; + if (endTime > maxTime) maxTime = endTime; + }); + + if (maxTime > 0 && logicalPlaybackTime >= maxTime) { + stopAudioEditorPlayback(true); // Rebobina no fim + resetPlayheadVisual(); + return; + } + } + const pixelsPerSecond = getPixelsPerSecond(); + const newPositionPx = logicalPlaybackTime * pixelsPerSecond; + updatePlayheadVisual(newPositionPx); + animationFrameId = requestAnimationFrame(_animationLoop); +} + +// --- API Pública --- + export function updateTransportLoop() { - Tone.Transport.loop = appState.global.isLoopActive; - Tone.Transport.loopStart = appState.global.loopStartTime; - Tone.Transport.loopEnd = appState.global.loopEndTime; + isLoopActive = appState.global.isLoopActive; + loopStartTimeSec = appState.global.loopStartTime; + loopEndTimeSec = appState.global.loopEndTime; + + runtimeClipState.clear(); + + scheduledNodes.forEach(nodeData => { + // --- CORREÇÃO BUG 1: Remove a linha 'onended = null' --- + nodeData.sourceNode.stop(0); + nodeData.sourceNode.dispose(); + }); + scheduledNodes.clear(); } export function startAudioEditorPlayback() { - if (appState.global.isAudioEditorPlaying) return; - initializeAudioContext(); - Tone.Transport.cancel(); // Limpa eventos agendados anteriormente + if (isPlaying) return; - updateTransportLoop(); // Isso deve definir Tone.Transport.loop = true e Tone.Transport.loopEnd - - // 1. Pegue a duração total do loop que a função acima definiu - const loopInterval = Tone.Transport.loopEnd; - - // Se loopEnd não foi definido (ex: 0 ou undefined), o loop não funcionará. - if (!loopInterval || loopInterval === 0) { - console.error("LoopEnd não está definido no Tone.Transport! O áudio não repetirá."); - // Você pode querer definir um padrão aqui, mas o ideal é - // garantir que 'updateTransportLoop' esteja definindo 'loopEnd' corretamente. - // ex: const loopInterval = "1m"; (se for um compasso por padrão) + _initContext(); + if (audioCtx.state === 'suspended') { + audioCtx.resume(); } - appState.audio.clips.forEach(clip => { - if (!clip.player || !clip.player.loaded) return; - - // 2. CORREÇÃO: Use scheduleRepeat no lugar de scheduleOnce - Tone.Transport.scheduleRepeat((time) => { - // Sua lógica de parâmetros está correta - clip.gainNode.gain.value = Tone.gainToDb(clip.volume); - clip.pannerNode.pan.value = clip.pan; - clip.player.playbackRate = Math.pow(2, clip.pitch / 12); - - // Inicia o player no tempo agendado - clip.player.start(time, clip.offset, clip.duration); - - }, - loopInterval, // <--- O intervalo de repetição (ex: "4m", "8m") - clip.startTime // <--- Onde o clip começa dentro da linha do tempo - ); - }); - - // 3. ADIÇÃO CRÍTICA: Inicie o transporte e atualize o estado - Tone.Transport.start(); + isPlaying = true; + // --- CORREÇÃO BUG 2: Atualiza o estado global --- appState.global.isAudioEditorPlaying = true; - - // 4. (CORRIGIDO) Atualize a UI do botão de play - const playBtn = document.getElementById("audio-editor-play-btn"); - if (playBtn) { - playBtn.classList.add("active"); - // Verifica se o ícone existe antes de tentar mudá-lo - const icon = playBtn.querySelector('i'); - if (icon) { - icon.className = 'fa-solid fa-pause'; - } - } - - // ##### CORREÇÃO 2 ##### - // Inicia o loop de animação da agulha - animationLoop(); -} - -export function stopAudioEditorPlayback() { - if (!appState.global.isAudioEditorPlaying) return; - Tone.Transport.stop(); - - appState.audio.clips.forEach(clip => { - if (clip.player && clip.player.state === 'started') { - clip.player.stop(); - } - }); - - appState.audio.audioEditorPlaybackTime = Tone.Transport.seconds; - // Esta lógica agora funcionará corretamente graças à Correção 1 - if (appState.audio.audioEditorAnimationId) { - cancelAnimationFrame(appState.audio.audioEditorAnimationId); - appState.audio.audioEditorAnimationId = null; - } + startTime = audioCtx.currentTime; - // (CORRIGIDO) Atualiza a UI do botão de play - const playBtn = document.getElementById("audio-editor-play-btn"); - if (playBtn) { - playBtn.classList.remove("active"); - // Verifica se o ícone existe antes de tentar mudá-lo - const icon = playBtn.querySelector('i'); - if (icon) { - icon.className = 'fa-solid fa-play'; // Muda de volta para "play" - } - } + updateTransportLoop(); + + console.log("%cIniciando Playback...", "color: #3498db;"); - appState.global.isAudioEditorPlaying = false; + _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'; + } } -export function seekAudioEditor(newTime) { - const wasPlaying = appState.global.isAudioEditorPlaying; - if (wasPlaying) { - stopAudioEditorPlayback(); - } - - appState.audio.audioEditorPlaybackTime = newTime; - Tone.Transport.seconds = newTime; +export function stopAudioEditorPlayback(rewind = false) { + if (!isPlaying) return; - const pixelsPerSecond = getPixelsPerSecond(); - const newPositionPx = newTime * pixelsPerSecond; - updatePlayheadVisual(newPositionPx); + isPlaying = false; + // --- CORREÇÃO BUG 2: Atualiza o estado global --- + appState.global.isAudioEditorPlaying = false; + + console.log(`%cParando Playback... (Rewind: ${rewind})`, "color: #d9534f;"); - if (wasPlaying) { - startAudioEditorPlayback(); - } + clearInterval(schedulerIntervalId); + schedulerIntervalId = null; + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + + seekTime = logicalPlaybackTime; + logicalPlaybackTime = 0; + + if (rewind) { + seekTime = 0; + } + + scheduledNodes.forEach(nodeData => { + // --- CORREÇÃO BUG 1: Remove a linha 'onended = null' --- + nodeData.sourceNode.stop(0); + nodeData.sourceNode.dispose(); + }); + scheduledNodes.clear(); + runtimeClipState.clear(); + + updateAudioEditorUI(); + const playBtn = document.getElementById("audio-editor-play-btn"); + if (playBtn) { + playBtn.className = 'fa-solid fa-play'; + } + + if (rewind) { + resetPlayheadVisual(); + } } export function restartAudioEditorIfPlaying() { - if (appState.global.isAudioEditorPlaying) { - appState.audio.audioEditorPlaybackTime = Tone.Transport.seconds; - stopAudioEditorPlayback(); - startAudioEditorPlayback(); + if (isPlaying) { + stopAudioEditorPlayback(false); // Pausa + startAudioEditorPlayback(); + } +} + +export function seekAudioEditor(newTime) { + const wasPlaying = isPlaying; + if (wasPlaying) { + stopAudioEditorPlayback(false); // Pausa + } + seekTime = newTime; + logicalPlaybackTime = newTime; + 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 diff --git a/assets/js/creations/audio/audio_state.js b/assets/js/creations/audio/audio_state.js index f5f2cc2..ad1f91a 100644 --- a/assets/js/creations/audio/audio_state.js +++ b/assets/js/creations/audio/audio_state.js @@ -2,8 +2,9 @@ import { DEFAULT_VOLUME, DEFAULT_PAN } from "../config.js"; import { renderAudioEditor } from "./audio_ui.js"; import { getMainGainNode } from "../audio.js"; +import { getAudioContext } from "../audio.js"; -const initialState = { +export let audioState = { tracks: [], clips: [], audioEditorStartTime: 0, @@ -12,33 +13,47 @@ const initialState = { isAudioEditorLoopEnabled: false, }; -export let audioState = { ...initialState }; - export function initializeAudioState() { audioState.clips.forEach(clip => { - if (clip.player) clip.player.dispose(); if (clip.pannerNode) clip.pannerNode.dispose(); if (clip.gainNode) clip.gainNode.dispose(); }); - Object.assign(audioState, initialState, { tracks: [], clips: [] }); + Object.assign(audioState, { + tracks: [], + clips: [], + audioEditorStartTime: 0, + audioEditorAnimationId: null, + audioEditorPlaybackTime: 0, + isAudioEditorLoopEnabled: false, + }); } export async function loadAudioForClip(clip) { if (!clip.sourcePath) return clip; + + const audioCtx = getAudioContext(); + if (!audioCtx) { + console.error("AudioContext não disponível para carregar áudio."); + return clip; + } + try { - // Cria o player e o conecta à cadeia de áudio do clipe - clip.player = new Tone.Player(); - clip.player.chain(clip.gainNode, clip.pannerNode, getMainGainNode()); + const response = await fetch(clip.sourcePath); + if (!response.ok) throw new Error(`Falha ao buscar áudio: ${clip.sourcePath}`); + const arrayBuffer = await response.arrayBuffer(); + const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer); - // Carrega o áudio e espera a conclusão - await clip.player.load(clip.sourcePath); - - if (clip.duration === 0) { - clip.duration = clip.player.buffer.duration; + clip.buffer = audioBuffer; + + // --- CORREÇÃO: Salva a duração original --- + if (clip.durationInSeconds === 0) { + clip.durationInSeconds = audioBuffer.duration; } + // Salva a duração real do buffer para cálculos de stretch + clip.originalDuration = audioBuffer.duration; + } catch (error) { console.error(`Falha ao carregar áudio para o clipe ${clip.name}:`, error); - clip.player = null; } return clip; } @@ -49,18 +64,25 @@ export function addAudioClipToTimeline(samplePath, trackId = 1, startTime = 0) { trackId: trackId, sourcePath: samplePath, name: samplePath.split('/').pop(), - player: null, - startTime: startTime, + + startTimeInSeconds: startTime, offset: 0, - duration: 0, + durationInSeconds: 0, + originalDuration: 0, // Será preenchido pelo loadAudioForClip + pitch: 0, volume: DEFAULT_VOLUME, pan: DEFAULT_PAN, - isSoloed: true, - // --- ADICIONADO: Nós de áudio para cada clipe --- + gainNode: new Tone.Gain(Tone.gainToDb(DEFAULT_VOLUME)), pannerNode: new Tone.Panner(DEFAULT_PAN), + + buffer: null, + player: null, }; + + newClip.gainNode.connect(newClip.pannerNode); + newClip.pannerNode.connect(getMainGainNode()); audioState.clips.push(newClip); @@ -78,35 +100,52 @@ export function updateAudioClipProperties(clipId, properties) { export function sliceAudioClip(clipId, sliceTimeInTimeline) { const originalClip = audioState.clips.find(c => c.id == clipId); - if (!originalClip || sliceTimeInTimeline <= originalClip.startTime || sliceTimeInTimeline >= originalClip.startTime + originalClip.duration) { + + if (!originalClip || + sliceTimeInTimeline <= originalClip.startTimeInSeconds || + sliceTimeInTimeline >= (originalClip.startTimeInSeconds + originalClip.durationInSeconds)) { + console.warn("Corte inválido: fora dos limites do clipe."); return; } - const cutPointInClip = sliceTimeInTimeline - originalClip.startTime; + const originalOffset = originalClip.offset || 0; + const cutPointInClip = sliceTimeInTimeline - originalClip.startTimeInSeconds; const newClip = { id: Date.now() + Math.random(), trackId: originalClip.trackId, sourcePath: originalClip.sourcePath, name: originalClip.name, - player: originalClip.player, - startTime: sliceTimeInTimeline, - offset: originalClip.offset + cutPointInClip, - duration: originalClip.duration - cutPointInClip, + buffer: originalClip.buffer, + + startTimeInSeconds: sliceTimeInTimeline, + offset: originalOffset + cutPointInClip, + durationInSeconds: originalClip.durationInSeconds - cutPointInClip, + + // --- CORREÇÃO: Propaga a duração original --- + originalDuration: originalClip.originalDuration, + pitch: originalClip.pitch, volume: originalClip.volume, pan: originalClip.pan, - isSoloed: false, + gainNode: new Tone.Gain(Tone.gainToDb(originalClip.volume)), pannerNode: new Tone.Panner(originalClip.pan), + + player: null }; - newClip.player.chain(newClip.gainNode, newClip.pannerNode, getMainGainNode()); + newClip.gainNode.connect(newClip.pannerNode); + newClip.pannerNode.connect(getMainGainNode()); + + originalClip.durationInSeconds = cutPointInClip; - originalClip.duration = cutPointInClip; audioState.clips.push(newClip); + + 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); if (clip) { @@ -132,5 +171,27 @@ export function updateClipPan(clipId, pan) { export function addAudioTrackLane() { const newTrackName = `Pista de Áudio ${audioState.tracks.length + 1}`; audioState.tracks.push({ id: Date.now(), name: newTrackName }); - // A UI será re-renderizada a partir do main.js +} + +export function removeAudioClip(clipId) { + const clipIndex = audioState.clips.findIndex(c => c.id == 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(); + } + if (clip.pannerNode) { + clip.pannerNode.disconnect(); + clip.pannerNode.dispose(); + } + + // 2. Remove o clipe do array de estado + audioState.clips.splice(clipIndex, 1); + + // 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 a6594a1..3ae0072 100644 --- a/assets/js/creations/audio/audio_ui.js +++ b/assets/js/creations/audio/audio_ui.js @@ -7,15 +7,15 @@ import { } from "./audio_state.js"; import { seekAudioEditor, restartAudioEditorIfPlaying, updateTransportLoop } from "./audio_audio.js"; import { drawWaveform } from "../waveform.js"; -import { PIXELS_PER_BAR, ZOOM_LEVELS } from "../config.js"; -import { getPixelsPerSecond } from "../utils.js"; +import { PIXELS_PER_BAR, PIXELS_PER_STEP, ZOOM_LEVELS } from "../config.js"; +import { getPixelsPerSecond, quantizeTime, getBeatsPerBar, getSecondsPerStep } from "../utils.js"; export function renderAudioEditor() { 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 (AGORA COM WRAPPER E SPACER) --- + // --- CRIAÇÃO E RENDERIZAÇÃO DA RÉGUA --- let rulerWrapper = audioEditor.querySelector('.ruler-wrapper'); if (!rulerWrapper) { rulerWrapper = document.createElement('div'); @@ -28,32 +28,35 @@ export function renderAudioEditor() { } const ruler = rulerWrapper.querySelector('.timeline-ruler'); - ruler.innerHTML = ''; // Limpa a régua para redesenhar + ruler.innerHTML = ''; const pixelsPerSecond = getPixelsPerSecond(); let maxTime = appState.global.loopEndTime; appState.audio.clips.forEach(clip => { - const endTime = clip.startTime + clip.duration; + 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); // Garante uma largura mínima + const totalWidth = Math.max(contentWidth, containerWidth, 2000); ruler.style.width = `${totalWidth}px`; const zoomFactor = ZOOM_LEVELS[appState.global.zoomLevelIndex]; - const scaledBarWidth = PIXELS_PER_BAR * zoomFactor; + const beatsPerBar = getBeatsPerBar(); + const stepWidthPx = PIXELS_PER_STEP * zoomFactor; + const beatWidthPx = stepWidthPx * 4; + const barWidthPx = beatWidthPx * beatsPerBar; - if (scaledBarWidth > 0) { - const numberOfBars = Math.ceil(totalWidth / scaledBarWidth); + 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) * scaledBarWidth}px`; + marker.style.left = `${(i - 1) * barWidthPx}px`; ruler.appendChild(marker); } } @@ -66,13 +69,13 @@ export function renderAudioEditor() { loopRegion.classList.toggle("visible", appState.global.isLoopActive); ruler.appendChild(loopRegion); - // --- LISTENER DA RÉGUA PARA INTERAÇÕES (LOOP E SEEK) --- + // --- 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)'); - if (loopHandle) { + if (loopHandle) { /* ... lógica de loop ... */ e.preventDefault(); e.stopPropagation(); const handleType = loopHandle.classList.contains('left') ? 'left' : 'right'; const initialMouseX = e.clientX; @@ -87,35 +90,28 @@ export function renderAudioEditor() { if (handleType === 'left') { newStart = Math.max(0, initialStart + deltaTime); - newStart = Math.min(newStart, appState.global.loopEndTime - 0.1); // Não deixa passar do fim + newStart = Math.min(newStart, appState.global.loopEndTime - 0.1); appState.global.loopStartTime = newStart; } else { - newEnd = Math.max(appState.global.loopStartTime + 0.1, initialEnd + deltaTime); // Não deixa ser antes do início + newEnd = Math.max(appState.global.loopStartTime + 0.1, initialEnd + deltaTime); appState.global.loopEndTime = newEnd; } updateTransportLoop(); - - // ### CORREÇÃO DE PERFORMANCE 1 ### - // Remove a chamada para renderAudioEditor() - // Em vez disso, atualiza o estilo do elemento 'loopRegion' diretamente loopRegion.style.left = `${newStart * currentPixelsPerSecond}px`; loopRegion.style.width = `${(newEnd - newStart) * currentPixelsPerSecond}px`; - // ### FIM DA CORREÇÃO 1 ### }; const onMouseUp = () => { document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); - // Opcional: chamar renderAudioEditor() UMA VEZ no final para garantir a sincronia renderAudioEditor(); }; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); return; } - - if (loopRegionBody) { + if (loopRegionBody) { /* ... lógica de mover loop ... */ e.preventDefault(); e.stopPropagation(); const initialMouseX = e.clientX; const initialStart = appState.global.loopStartTime; @@ -127,23 +123,15 @@ export function renderAudioEditor() { const deltaTime = deltaX / currentPixelsPerSecond; let newStart = Math.max(0, initialStart + deltaTime); let newEnd = newStart + initialDuration; - appState.global.loopStartTime = newStart; appState.global.loopEndTime = newEnd; - updateTransportLoop(); - - // ### CORREÇÃO DE PERFORMANCE 2 ### - // Remove a chamada para renderAudioEditor() - // Atualiza apenas a posição 'left' do elemento - loopRegion.style.left = `${newStart * currentPixelsPerSecond}px`; - // ### FIM DA CORREÇÃO 2 ### + loopRegion.style.left = `${newStart * currentPixelsPerSecond}px`; }; const onMouseUp = () => { document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); - // Opcional: chamar renderAudioEditor() UMA VEZ no final renderAudioEditor(); }; document.addEventListener('mousemove', onMouseMove); @@ -151,9 +139,8 @@ export function renderAudioEditor() { return; } - // Se o clique não foi em um handle ou no corpo do loop, faz o "seek" e.preventDefault(); - const handleSeek = (event) => { + const handleSeek = (event) => { /* ... lógica de seek ... */ const rect = ruler.getBoundingClientRect(); const scrollLeft = ruler.scrollLeft; const clickX = event.clientX - rect.left; @@ -168,7 +155,7 @@ export function renderAudioEditor() { document.addEventListener('mouseup', onMouseUpSeek); }); - // --- RECRIAÇÃO DO CONTAINER DE PISTAS PARA EVITAR LISTENERS DUPLICADOS --- + // --- RECRIAÇÃO DO CONTAINER DE PISTAS (sem alterações) --- const newTrackContainer = existingTrackContainer.cloneNode(false); audioEditor.replaceChild(newTrackContainer, existingTrackContainer); @@ -176,7 +163,7 @@ export function renderAudioEditor() { appState.audio.tracks.push({ id: Date.now(), name: "Pista de Áudio 1" }); } - // --- RENDERIZAÇÃO DAS PISTAS INDIVIDUAIS --- + // --- RENDERIZAÇÃO DAS PISTAS INDIVIDUAIS (sem alterações) --- appState.audio.tracks.forEach(trackData => { const audioTrackLane = document.createElement('div'); audioTrackLane.className = 'audio-track-lane'; @@ -210,6 +197,7 @@ export function renderAudioEditor() { 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'); @@ -217,25 +205,37 @@ export function renderAudioEditor() { if (!filePath) return; const rect = timelineContainer.getBoundingClientRect(); const dropX = e.clientX - rect.left + timelineContainer.scrollLeft; - const startTimeInSeconds = dropX / pixelsPerSecond; + let startTimeInSeconds = dropX / pixelsPerSecond; + startTimeInSeconds = quantizeTime(startTimeInSeconds); addAudioClipToTimeline(filePath, trackData.id, startTimeInSeconds); }); const grid = timelineContainer.querySelector('.spectrogram-view-grid'); - grid.style.setProperty('--bar-width', `${scaledBarWidth}px`); - grid.style.setProperty('--four-bar-width', `${scaledBarWidth * 4}px`); + 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 --- + // --- 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; - clipElement.style.left = `${clip.startTime * pixelsPerSecond}px`; - clipElement.style.width = `${clip.duration * pixelsPerSecond}px`; - let pitchStr = clip.pitch > 0 ? `+${clip.pitch}` : `${clip.pitch}`; + + // --- 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 = `
@@ -244,13 +244,31 @@ export function renderAudioEditor() {
`; parentGrid.appendChild(clipElement); - if (clip.player && clip.player.loaded) { + + if (clip.buffer) { const canvas = clipElement.querySelector('.waveform-canvas-clip'); - canvas.width = clip.duration * pixelsPerSecond; - canvas.height = 40; - const audioBuffer = clip.player.buffer.get(); - drawWaveform(canvas, audioBuffer, 'var(--accent-green)', clip.offset, clip.duration); + 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); @@ -264,7 +282,7 @@ export function renderAudioEditor() { }); }); - // --- SINCRONIZAÇÃO DE SCROLL ENTRE A RÉGUA E AS PISTAS --- + // --- SINCRONIZAÇÃO DE SCROLL (sem alterações) --- newTrackContainer.addEventListener('scroll', () => { const scrollPos = newTrackContainer.scrollLeft; if (ruler.scrollLeft !== scrollPos) { @@ -272,18 +290,212 @@ export function renderAudioEditor() { } }); - // --- EVENT LISTENER PRINCIPAL PARA INTERAÇÕES (MOVER, REDIMENSIONAR, ETC.) --- + // --- EVENT LISTENER PRINCIPAL (COM MODIFICAÇÃO PARA SELEÇÃO) --- newTrackContainer.addEventListener('mousedown', (e) => { - const currentPixelsPerSecond = getPixelsPerSecond(); - const handle = e.target.closest('.clip-resize-handle'); + // --- 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'); - if (appState.global.sliceToolActive && clipElement) { /* ... lógica de corte ... */ return; } - if (handle) { /* ... lógica de redimensionamento de clipe ... */ return; } + // 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) { - e.preventDefault(); + // --- 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'); @@ -312,9 +524,10 @@ export function renderAudioEditor() { const newLeftPx = (upEvent.clientX - wrapperRect.left) - clickOffsetInClip + timelineContainer.scrollLeft; const constrainedLeftPx = Math.max(0, newLeftPx); - const newStartTime = constrainedLeftPx / currentPixelsPerSecond; + let newStartTime = constrainedLeftPx / currentPixelsPerSecond; + newStartTime = quantizeTime(newStartTime); - updateAudioClipProperties(clipId, { trackId: Number(newTrackId), startTime: newStartTime }); + updateAudioClipProperties(clipId, { trackId: Number(newTrackId), startTimeInSeconds: newStartTime }); renderAudioEditor(); }; document.addEventListener('mousemove', onMouseMove); @@ -324,6 +537,7 @@ export function renderAudioEditor() { 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(); @@ -340,8 +554,42 @@ export function renderAudioEditor() { 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; diff --git a/assets/js/creations/main.js b/assets/js/creations/main.js index af3cd12..80233e2 100644 --- a/assets/js/creations/main.js +++ b/assets/js/creations/main.js @@ -1,7 +1,8 @@ // js/main.js import { appState, resetProjectState } from "./state.js"; import { addTrackToState, removeLastTrackFromState } from "./pattern/pattern_state.js"; -import { addAudioTrackLane } from "./audio/audio_state.js"; +// --- CORREÇÃO AQUI --- +import { addAudioTrackLane, removeAudioClip } from "./audio/audio_state.js"; import { updateTransportLoop } from "./audio/audio_audio.js"; import { togglePlayback, @@ -20,6 +21,21 @@ 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 +function updateToolButtons() { + 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'); + + // Desativa a ferramenta de corte se outra for selecionada + document.body.classList.toggle("slice-tool-active", appState.global.sliceToolActive); +} + document.addEventListener("DOMContentLoaded", () => { const newProjectBtn = document.getElementById("new-project-btn"); const openMmpBtn = document.getElementById("open-mmp-btn"); @@ -36,6 +52,11 @@ 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 mmpFileInput = document.getElementById("mmp-file-input"); const sampleFileInput = document.getElementById("sample-file-input"); const openProjectModal = document.getElementById("open-project-modal"); @@ -46,6 +67,55 @@ document.addEventListener("DOMContentLoaded", () => { const zoomInBtn = document.getElementById("zoom-in-btn"); const zoomOutBtn = document.getElementById("zoom-out-btn"); + // --- LISTENERS ADICIONADOS (COM LÓGICA DE CONTROLLER) --- + + // 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 + } + } + }); + + // 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 --- + 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(); @@ -74,10 +144,31 @@ document.addEventListener("DOMContentLoaded", () => { 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); }); - if(sliceToolBtn) { sliceToolBtn.addEventListener("click", () => { appState.global.sliceToolActive = !appState.global.sliceToolActive; sliceToolBtn.classList.toggle("active", appState.global.sliceToolActive); document.body.classList.toggle("slice-tool-active", appState.global.sliceToolActive); }); } + + // --- 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); - // ### CORREÇÃO 2: Adicionada verificação 'if (icon)' ### sidebarToggle.addEventListener("click", () => { document.body.classList.toggle("sidebar-hidden"); const icon = sidebarToggle.querySelector("i"); @@ -116,50 +207,31 @@ document.addEventListener("DOMContentLoaded", () => { }); } - audioEditorPlayBtn.addEventListener("click", () => { if (appState.global.isAudioEditorPlaying) { stopAudioEditorPlayback(); } else { startAudioEditorPlayback(); } }); - audioEditorStopBtn.addEventListener("click", stopAudioEditorPlayback); + audioEditorPlayBtn.addEventListener("click", () => { + if (appState.global.isAudioEditorPlaying) { + stopAudioEditorPlayback(false); // Pausa + } else { + startAudioEditorPlayback(); + } + }); + audioEditorStopBtn.addEventListener("click", () => stopAudioEditorPlayback(true)); // Stop (rebobina) - // ### CORREÇÃO 1: Listeners duplicados combinados em um só ### - // No main.js audioEditorLoopBtn.addEventListener("click", () => { - console.log("--- Botão de Loop Clicado ---"); // DEBUG 1 - - // 1. Altera o estado global de loop appState.global.isLoopActive = !appState.global.isLoopActive; - console.log("Estado appState.global.isLoopActive:", appState.global.isLoopActive); // DEBUG 2 - - // 2. Sincroniza o estado do loop do editor appState.audio.isAudioEditorLoopEnabled = appState.global.isLoopActive; - - // 3. Atualiza a aparência do botão audioEditorLoopBtn.classList.toggle("active", appState.global.isLoopActive); - - // 4. Sincroniza o Tone.Transport updateTransportLoop(); - - // 5. Mostra/esconde a área de loop const loopArea = document.getElementById("loop-region"); - - // ESTE É O TESTE MAIS IMPORTANTE: if (loopArea) { - console.log("Elemento #loop-region ENCONTRADO. Alterando classe 'visible'."); // DEBUG 3 loopArea.classList.toggle("visible", appState.global.isLoopActive); - } else { - console.error("ERRO GRAVE: Elemento #loop-region NÃO FOI ENCONTRADO!"); // DEBUG 4 } - - // 6. Reinicia o playback se estiver tocando restartAudioEditorIfPlaying(); }); if (addAudioTrackBtn) { addAudioTrackBtn.addEventListener("click", () => { addAudioTrackLane(); renderAudioEditor(); }); } - // ### CORREÇÃO 3: Ordem de execução corrigida ### - - // 1. Carrega o conteúdo do navegador de samples loadAndRenderSampleBrowser(); - // 2. Adiciona o listener DEPOIS que o conteúdo supostamente existe const browserContent = document.getElementById('browser-content'); if (browserContent) { browserContent.addEventListener('click', function(event) { @@ -171,6 +243,6 @@ document.addEventListener("DOMContentLoaded", () => { }); } - // 3. Renderiza o resto renderAll(); + updateToolButtons(); // Define o estado inicial dos botões }); \ No newline at end of file diff --git a/assets/js/creations/state.js b/assets/js/creations/state.js index 7684e1d..2b62d07 100644 --- a/assets/js/creations/state.js +++ b/assets/js/creations/state.js @@ -18,9 +18,13 @@ const globalState = { zoomLevelIndex: 2, // --- ADICIONADO PARA A ÁREA DE LOOP --- - isLoopActive: false, // O botão de loop principal agora controla este estado - loopStartTime: 0, // Início do loop em segundos - loopEndTime: 8, // Fim do loop em segundos (padrão de 4 compassos a 120BPM) + isLoopActive: false, + loopStartTime: 0, + loopEndTime: 8, + + // --- ADICIONADO PARA O MODO DE REDIMENSIONAMENTO --- + resizeMode: 'trim', // Pode ser 'trim' (Modo 2) ou 'stretch' (Modo 1) + selectedClipId: null, }; // Combina todos os estados em um único objeto namespaced @@ -50,5 +54,7 @@ export function resetProjectState() { isLoopActive: false, loopStartTime: 0, loopEndTime: 8, + resizeMode: 'trim', // Reseta para o modo 'trim' + selectedClipId: null, }); } \ No newline at end of file diff --git a/assets/js/creations/ui.js b/assets/js/creations/ui.js index 8f48e70..4533a60 100644 --- a/assets/js/creations/ui.js +++ b/assets/js/creations/ui.js @@ -1,4 +1,5 @@ // js/ui.js +import { appState } from "./state.js"; // <-- CORREÇÃO: Importação adicionada import { playSample } from "./pattern/pattern_audio.js"; import { renderPatternEditor } from "./pattern/pattern_ui.js"; import { renderAudioEditor } from "./audio/audio_ui.js"; @@ -21,9 +22,6 @@ export function getSamplePathMap() { return samplePathMap; } - -// - function buildSamplePathMap(tree, currentPath) { for (const key in tree) { if (key === "_isFile") continue; diff --git a/assets/js/creations/utils.js b/assets/js/creations/utils.js index e54fca5..b28ba8c 100644 --- a/assets/js/creations/utils.js +++ b/assets/js/creations/utils.js @@ -2,14 +2,69 @@ import { appState } from './state.js'; import { PIXELS_PER_STEP, ZOOM_LEVELS } from './config.js'; +/** + * Helper interna para ler o BPM do input. + * @returns {number} O BPM atual. + */ +function _getBpm() { + const bpmInput = document.getElementById("bpm-input"); + return parseFloat(bpmInput.value, 10) || 120; +} + +/** + * Calcula e exporta quantos compassos (beats) existem por compasso (bar). + * @returns {number} O número de batidas por compasso (ex: 4 para 4/4). + */ +export function getBeatsPerBar() { + const compassoAInput = document.getElementById("compasso-a-input"); + return parseInt(compassoAInput.value, 10) || 4; +} + +/** + * Calcula e exporta quantos segundos dura uma "batida" (beat). + * No contexto de BPM, uma "batida" é quase sempre uma semínima (1/4). + * @returns {number} Duração da batida em segundos. + */ +export function getSecondsPerBeat() { + return 60.0 / _getBpm(); +} + +/** + * Calcula e exporta quantos segundos dura um "step". + * Baseado na config, um "step" é uma semicolcheia (1/16). + * Há 4 steps (1/16) por batida (1/4). + * @returns {number} Duração do step em segundos. + */ +export function getSecondsPerStep() { + return getSecondsPerBeat() / 4.0; // 4 steps (1/16) por beat (1/4) +} + +/** + * Quantiza (arredonda) um tempo em segundos para o "step" do grid mais próximo. + * @param {number} timeInSeconds - O tempo arbitrário (ex: 1.234s). + * @returns {number} O tempo alinhado ao grid (ex: 1.250s). + */ +export function quantizeTime(timeInSeconds) { + // TODO: Adicionar um toggle global (appState.global.isSnapEnabled) + + const secondsPerStep = getSecondsPerStep(); + if (secondsPerStep <= 0) return timeInSeconds; // Evita divisão por zero + + const roundedSteps = Math.round(timeInSeconds / secondsPerStep); + return roundedSteps * secondsPerStep; +} + + /** * Calcula a quantidade de pixels que representa um segundo na timeline, * levando em conta o BPM e o nível de zoom atual. * @returns {number} A quantidade de pixels por segundo. */ export function getPixelsPerSecond() { - const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120; - const stepsPerSecond = (bpm / 60) * 4; + const bpm = _getBpm(); // Usa a helper interna + // (bpm / 60) = batidas por segundo + // * 4 = steps por segundo (assumindo 4 steps/beat) + const stepsPerSecond = (bpm / 60) * 4; const zoomFactor = ZOOM_LEVELS[appState.global.zoomLevelIndex]; return stepsPerSecond * PIXELS_PER_STEP * zoomFactor; } diff --git a/creation.html b/creation.html index f981238..b83e3fc 100644 --- a/creation.html +++ b/creation.html @@ -126,6 +126,9 @@ + + + @@ -215,6 +218,8 @@
Definir Início do Loop
Definir Fim do Loop
+ +
Excluir Clipe