versão 2.0.1 - Correção do grid (Editor de Samples), loop, delete, resize, trim e streching funcionais
Deploy / Deploy (push) Successful in 1m2s Details

This commit is contained in:
JotaChina 2025-10-22 19:21:51 -03:00
parent dc32ba2225
commit 6903839643
9 changed files with 856 additions and 250 deletions

View File

@ -246,6 +246,7 @@ body.sidebar-hidden .sample-browser {
height: 100%; height: 100%;
position: relative; position: relative;
display: block; display: block;
/* Estas variáveis são agora definidas dinamicamente por audio_ui.js */
--step-width: 32px; --step-width: 32px;
--beat-width: 128px; --beat-width: 128px;
--bar-width: 512px; --bar-width: 512px;
@ -305,7 +306,8 @@ body.sidebar-hidden .sample-browser {
box-shadow: 0 3px 8px rgba(0,0,0,0.5); box-shadow: 0 3px 8px rgba(0,0,0,0.5);
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 8px; /* --- CORREÇÃO: Remove o padding horizontal --- */
padding: 0;
overflow: hidden; overflow: hidden;
cursor: grab; cursor: grab;
user-select: none; 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 { position: absolute; top: 0; bottom: 0; width: 10px; cursor: ew-resize; }
.loop-handle.left { left: -5px; } .loop-handle.left { left: -5px; }
.loop-handle.right { right: -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); } #audio-editor-loop-btn.active { color: var(--accent-green); }
/* =============================================== */ /* =============================================== */
/* COMPONENTES GERAIS (KNOBS, BOTÕES, INPUTS) /* 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-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 { 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: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; } .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; }

View File

@ -1,154 +1,304 @@
// js/audio_audio.js // js/audio/audio_audio.js
import { appState } from "../state.js"; import { appState } from "../state.js";
import { updateAudioEditorUI, updatePlayheadVisual, resetPlayheadVisual } from "./audio_ui.js"; import { updateAudioEditorUI, updatePlayheadVisual, resetPlayheadVisual } from "./audio_ui.js";
import { PIXELS_PER_STEP } from "../config.js"; import { initializeAudioContext, getAudioContext } from "../audio.js";
import { initializeAudioContext } from "../audio.js";
import { getPixelsPerSecond } from "../utils.js"; import { getPixelsPerSecond } from "../utils.js";
function animationLoop() { // --- Configurações do Scheduler ---
if (!appState.global.isAudioEditorPlaying) return; const LOOKAHEAD_INTERVAL_MS = 25.0;
const SCHEDULE_AHEAD_TIME_SEC = 0.5; // 500ms
const pixelsPerSecond = getPixelsPerSecond(); // --- Estado Interno do Engine ---
const totalElapsedTime = Tone.Transport.seconds; let audioCtx = null;
let isPlaying = false;
let schedulerIntervalId = null;
let animationFrameId = null;
// Sincronização de Tempo
let startTime = 0;
let seekTime = 0;
let logicalPlaybackTime = 0;
// 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; let maxTime = 0;
appState.audio.clips.forEach(clip => { appState.audio.clips.forEach(clip => {
const endTime = clip.startTime + clip.duration; const clipStartTime = clip.startTimeInSeconds || 0;
const clipDuration = clip.durationInSeconds || 0;
const endTime = clipStartTime + clipDuration;
if (endTime > maxTime) maxTime = endTime; if (endTime > maxTime) maxTime = endTime;
}); });
if (!appState.global.isLoopActive && totalElapsedTime >= maxTime && maxTime > 0) { if (maxTime > 0 && logicalPlaybackTime >= maxTime) {
stopAudioEditorPlayback(); stopAudioEditorPlayback(true); // Rebobina no fim
resetPlayheadVisual(); resetPlayheadVisual();
return; return;
} }
}
const newPositionPx = totalElapsedTime * pixelsPerSecond; const pixelsPerSecond = getPixelsPerSecond();
updatePlayheadVisual(newPositionPx); const newPositionPx = logicalPlaybackTime * pixelsPerSecond;
updatePlayheadVisual(newPositionPx);
// ##### CORREÇÃO 1 ##### animationFrameId = requestAnimationFrame(_animationLoop);
// Salva o ID da animação para que o stop possa cancelá-lo
appState.audio.audioEditorAnimationId = requestAnimationFrame(animationLoop);
} }
// --- API Pública ---
export function updateTransportLoop() { export function updateTransportLoop() {
Tone.Transport.loop = appState.global.isLoopActive; isLoopActive = appState.global.isLoopActive;
Tone.Transport.loopStart = appState.global.loopStartTime; loopStartTimeSec = appState.global.loopStartTime;
Tone.Transport.loopEnd = appState.global.loopEndTime; 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() { export function startAudioEditorPlayback() {
if (appState.global.isAudioEditorPlaying) return; if (isPlaying) return;
initializeAudioContext();
Tone.Transport.cancel(); // Limpa eventos agendados anteriormente
updateTransportLoop(); // Isso deve definir Tone.Transport.loop = true e Tone.Transport.loopEnd _initContext();
if (audioCtx.state === 'suspended') {
// 1. Pegue a duração total do loop que a função acima definiu audioCtx.resume();
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)
} }
appState.audio.clips.forEach(clip => { isPlaying = true;
if (!clip.player || !clip.player.loaded) return; // --- CORREÇÃO BUG 2: Atualiza o estado global ---
// 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();
appState.global.isAudioEditorPlaying = true; appState.global.isAudioEditorPlaying = true;
// 4. (CORRIGIDO) Atualize a UI do botão de play startTime = audioCtx.currentTime;
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 ##### updateTransportLoop();
// Inicia o loop de animação da agulha
animationLoop();
}
export function stopAudioEditorPlayback() { console.log("%cIniciando Playback...", "color: #3498db;");
if (!appState.global.isAudioEditorPlaying) return;
Tone.Transport.stop();
appState.audio.clips.forEach(clip => { _schedulerTick();
if (clip.player && clip.player.state === 'started') { schedulerIntervalId = setInterval(_schedulerTick, LOOKAHEAD_INTERVAL_MS);
clip.player.stop(); animationFrameId = requestAnimationFrame(_animationLoop);
}
});
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;
}
// (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"
}
}
appState.global.isAudioEditorPlaying = false;
updateAudioEditorUI(); updateAudioEditorUI();
const playBtn = document.getElementById("audio-editor-play-btn");
if (playBtn) {
playBtn.className = 'fa-solid fa-pause';
}
} }
export function seekAudioEditor(newTime) { export function stopAudioEditorPlayback(rewind = false) {
const wasPlaying = appState.global.isAudioEditorPlaying; if (!isPlaying) return;
if (wasPlaying) {
stopAudioEditorPlayback();
}
appState.audio.audioEditorPlaybackTime = newTime; isPlaying = false;
Tone.Transport.seconds = newTime; // --- CORREÇÃO BUG 2: Atualiza o estado global ---
appState.global.isAudioEditorPlaying = false;
const pixelsPerSecond = getPixelsPerSecond(); console.log(`%cParando Playback... (Rewind: ${rewind})`, "color: #d9534f;");
const newPositionPx = newTime * pixelsPerSecond;
updatePlayheadVisual(newPositionPx);
if (wasPlaying) { clearInterval(schedulerIntervalId);
startAudioEditorPlayback(); 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() { export function restartAudioEditorIfPlaying() {
if (appState.global.isAudioEditorPlaying) { if (isPlaying) {
appState.audio.audioEditorPlaybackTime = Tone.Transport.seconds; stopAudioEditorPlayback(false); // Pausa
stopAudioEditorPlayback(); startAudioEditorPlayback();
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;
} }
} }

View File

@ -2,8 +2,9 @@
import { DEFAULT_VOLUME, DEFAULT_PAN } from "../config.js"; import { DEFAULT_VOLUME, DEFAULT_PAN } from "../config.js";
import { renderAudioEditor } from "./audio_ui.js"; import { renderAudioEditor } from "./audio_ui.js";
import { getMainGainNode } from "../audio.js"; import { getMainGainNode } from "../audio.js";
import { getAudioContext } from "../audio.js";
const initialState = { export let audioState = {
tracks: [], tracks: [],
clips: [], clips: [],
audioEditorStartTime: 0, audioEditorStartTime: 0,
@ -12,33 +13,47 @@ const initialState = {
isAudioEditorLoopEnabled: false, isAudioEditorLoopEnabled: false,
}; };
export let audioState = { ...initialState };
export function initializeAudioState() { export function initializeAudioState() {
audioState.clips.forEach(clip => { audioState.clips.forEach(clip => {
if (clip.player) clip.player.dispose();
if (clip.pannerNode) clip.pannerNode.dispose(); if (clip.pannerNode) clip.pannerNode.dispose();
if (clip.gainNode) clip.gainNode.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) { export async function loadAudioForClip(clip) {
if (!clip.sourcePath) return clip; if (!clip.sourcePath) return clip;
const audioCtx = getAudioContext();
if (!audioCtx) {
console.error("AudioContext não disponível para carregar áudio.");
return clip;
}
try { try {
// Cria o player e o conecta à cadeia de áudio do clipe const response = await fetch(clip.sourcePath);
clip.player = new Tone.Player(); if (!response.ok) throw new Error(`Falha ao buscar áudio: ${clip.sourcePath}`);
clip.player.chain(clip.gainNode, clip.pannerNode, getMainGainNode()); const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
// Carrega o áudio e espera a conclusão clip.buffer = audioBuffer;
await clip.player.load(clip.sourcePath);
if (clip.duration === 0) { // --- CORREÇÃO: Salva a duração original ---
clip.duration = clip.player.buffer.duration; 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) { } catch (error) {
console.error(`Falha ao carregar áudio para o clipe ${clip.name}:`, error); console.error(`Falha ao carregar áudio para o clipe ${clip.name}:`, error);
clip.player = null;
} }
return clip; return clip;
} }
@ -49,19 +64,26 @@ export function addAudioClipToTimeline(samplePath, trackId = 1, startTime = 0) {
trackId: trackId, trackId: trackId,
sourcePath: samplePath, sourcePath: samplePath,
name: samplePath.split('/').pop(), name: samplePath.split('/').pop(),
player: null,
startTime: startTime, startTimeInSeconds: startTime,
offset: 0, offset: 0,
duration: 0, durationInSeconds: 0,
originalDuration: 0, // Será preenchido pelo loadAudioForClip
pitch: 0, pitch: 0,
volume: DEFAULT_VOLUME, volume: DEFAULT_VOLUME,
pan: DEFAULT_PAN, pan: DEFAULT_PAN,
isSoloed: true,
// --- ADICIONADO: Nós de áudio para cada clipe ---
gainNode: new Tone.Gain(Tone.gainToDb(DEFAULT_VOLUME)), gainNode: new Tone.Gain(Tone.gainToDb(DEFAULT_VOLUME)),
pannerNode: new Tone.Panner(DEFAULT_PAN), pannerNode: new Tone.Panner(DEFAULT_PAN),
buffer: null,
player: null,
}; };
newClip.gainNode.connect(newClip.pannerNode);
newClip.pannerNode.connect(getMainGainNode());
audioState.clips.push(newClip); audioState.clips.push(newClip);
loadAudioForClip(newClip).then(() => { loadAudioForClip(newClip).then(() => {
@ -78,35 +100,52 @@ export function updateAudioClipProperties(clipId, properties) {
export function sliceAudioClip(clipId, sliceTimeInTimeline) { export function sliceAudioClip(clipId, sliceTimeInTimeline) {
const originalClip = audioState.clips.find(c => c.id == clipId); 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; return;
} }
const cutPointInClip = sliceTimeInTimeline - originalClip.startTime; const originalOffset = originalClip.offset || 0;
const cutPointInClip = sliceTimeInTimeline - originalClip.startTimeInSeconds;
const newClip = { const newClip = {
id: Date.now() + Math.random(), id: Date.now() + Math.random(),
trackId: originalClip.trackId, trackId: originalClip.trackId,
sourcePath: originalClip.sourcePath, sourcePath: originalClip.sourcePath,
name: originalClip.name, name: originalClip.name,
player: originalClip.player, buffer: originalClip.buffer,
startTime: sliceTimeInTimeline,
offset: originalClip.offset + cutPointInClip, startTimeInSeconds: sliceTimeInTimeline,
duration: originalClip.duration - cutPointInClip, offset: originalOffset + cutPointInClip,
durationInSeconds: originalClip.durationInSeconds - cutPointInClip,
// --- CORREÇÃO: Propaga a duração original ---
originalDuration: originalClip.originalDuration,
pitch: originalClip.pitch, pitch: originalClip.pitch,
volume: originalClip.volume, volume: originalClip.volume,
pan: originalClip.pan, pan: originalClip.pan,
isSoloed: false,
gainNode: new Tone.Gain(Tone.gainToDb(originalClip.volume)), gainNode: new Tone.Gain(Tone.gainToDb(originalClip.volume)),
pannerNode: new Tone.Panner(originalClip.pan), 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); 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) { export function updateClipVolume(clipId, volume) {
const clip = audioState.clips.find((c) => c.id == clipId); const clip = audioState.clips.find((c) => c.id == clipId);
if (clip) { if (clip) {
@ -132,5 +171,27 @@ export function updateClipPan(clipId, pan) {
export function addAudioTrackLane() { export function addAudioTrackLane() {
const newTrackName = `Pista de Áudio ${audioState.tracks.length + 1}`; const newTrackName = `Pista de Áudio ${audioState.tracks.length + 1}`;
audioState.tracks.push({ id: Date.now(), name: newTrackName }); 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;
} }

View File

@ -7,15 +7,15 @@ import {
} from "./audio_state.js"; } 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 { drawWaveform } from "../waveform.js";
import { PIXELS_PER_BAR, ZOOM_LEVELS } from "../config.js"; import { PIXELS_PER_BAR, PIXELS_PER_STEP, ZOOM_LEVELS } from "../config.js";
import { getPixelsPerSecond } from "../utils.js"; import { getPixelsPerSecond, quantizeTime, getBeatsPerBar, getSecondsPerStep } from "../utils.js";
export function renderAudioEditor() { export function renderAudioEditor() {
const audioEditor = document.querySelector('.audio-editor'); const audioEditor = document.querySelector('.audio-editor');
const existingTrackContainer = document.getElementById('audio-track-container'); const existingTrackContainer = document.getElementById('audio-track-container');
if (!audioEditor || !existingTrackContainer) return; 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'); let rulerWrapper = audioEditor.querySelector('.ruler-wrapper');
if (!rulerWrapper) { if (!rulerWrapper) {
rulerWrapper = document.createElement('div'); rulerWrapper = document.createElement('div');
@ -28,32 +28,35 @@ export function renderAudioEditor() {
} }
const ruler = rulerWrapper.querySelector('.timeline-ruler'); const ruler = rulerWrapper.querySelector('.timeline-ruler');
ruler.innerHTML = ''; // Limpa a régua para redesenhar ruler.innerHTML = '';
const pixelsPerSecond = getPixelsPerSecond(); const pixelsPerSecond = getPixelsPerSecond();
let maxTime = appState.global.loopEndTime; let maxTime = appState.global.loopEndTime;
appState.audio.clips.forEach(clip => { appState.audio.clips.forEach(clip => {
const endTime = clip.startTime + clip.duration; const endTime = (clip.startTimeInSeconds || 0) + (clip.durationInSeconds || 0);
if (endTime > maxTime) maxTime = endTime; if (endTime > maxTime) maxTime = endTime;
}); });
const containerWidth = existingTrackContainer.offsetWidth; const containerWidth = existingTrackContainer.offsetWidth;
const contentWidth = maxTime * pixelsPerSecond; 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`; ruler.style.width = `${totalWidth}px`;
const zoomFactor = ZOOM_LEVELS[appState.global.zoomLevelIndex]; 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) { if (barWidthPx > 0) {
const numberOfBars = Math.ceil(totalWidth / scaledBarWidth); const numberOfBars = Math.ceil(totalWidth / barWidthPx);
for (let i = 1; i <= numberOfBars; i++) { for (let i = 1; i <= numberOfBars; i++) {
const marker = document.createElement('div'); const marker = document.createElement('div');
marker.className = 'ruler-marker'; marker.className = 'ruler-marker';
marker.textContent = i; marker.textContent = i;
marker.style.left = `${(i - 1) * scaledBarWidth}px`; marker.style.left = `${(i - 1) * barWidthPx}px`;
ruler.appendChild(marker); ruler.appendChild(marker);
} }
} }
@ -66,13 +69,13 @@ export function renderAudioEditor() {
loopRegion.classList.toggle("visible", appState.global.isLoopActive); loopRegion.classList.toggle("visible", appState.global.isLoopActive);
ruler.appendChild(loopRegion); ruler.appendChild(loopRegion);
// --- LISTENER DA RÉGUA PARA INTERAÇÕES (LOOP E SEEK) --- // --- LISTENER DA RÉGUA (sem alterações) ---
ruler.addEventListener('mousedown', (e) => { ruler.addEventListener('mousedown', (e) => {
const currentPixelsPerSecond = getPixelsPerSecond(); const currentPixelsPerSecond = getPixelsPerSecond();
const loopHandle = e.target.closest('.loop-handle'); const loopHandle = e.target.closest('.loop-handle');
const loopRegionBody = e.target.closest('#loop-region:not(.loop-handle)'); const loopRegionBody = e.target.closest('#loop-region:not(.loop-handle)');
if (loopHandle) { if (loopHandle) { /* ... lógica de loop ... */
e.preventDefault(); e.stopPropagation(); e.preventDefault(); e.stopPropagation();
const handleType = loopHandle.classList.contains('left') ? 'left' : 'right'; const handleType = loopHandle.classList.contains('left') ? 'left' : 'right';
const initialMouseX = e.clientX; const initialMouseX = e.clientX;
@ -87,35 +90,28 @@ export function renderAudioEditor() {
if (handleType === 'left') { if (handleType === 'left') {
newStart = Math.max(0, initialStart + deltaTime); 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; appState.global.loopStartTime = newStart;
} else { } 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; appState.global.loopEndTime = newEnd;
} }
updateTransportLoop(); 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.left = `${newStart * currentPixelsPerSecond}px`;
loopRegion.style.width = `${(newEnd - newStart) * currentPixelsPerSecond}px`; loopRegion.style.width = `${(newEnd - newStart) * currentPixelsPerSecond}px`;
// ### FIM DA CORREÇÃO 1 ###
}; };
const onMouseUp = () => { const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp); document.removeEventListener('mouseup', onMouseUp);
// Opcional: chamar renderAudioEditor() UMA VEZ no final para garantir a sincronia
renderAudioEditor(); renderAudioEditor();
}; };
document.addEventListener('mousemove', onMouseMove); document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp); document.addEventListener('mouseup', onMouseUp);
return; return;
} }
if (loopRegionBody) { /* ... lógica de mover loop ... */
if (loopRegionBody) {
e.preventDefault(); e.stopPropagation(); e.preventDefault(); e.stopPropagation();
const initialMouseX = e.clientX; const initialMouseX = e.clientX;
const initialStart = appState.global.loopStartTime; const initialStart = appState.global.loopStartTime;
@ -127,23 +123,15 @@ export function renderAudioEditor() {
const deltaTime = deltaX / currentPixelsPerSecond; const deltaTime = deltaX / currentPixelsPerSecond;
let newStart = Math.max(0, initialStart + deltaTime); let newStart = Math.max(0, initialStart + deltaTime);
let newEnd = newStart + initialDuration; let newEnd = newStart + initialDuration;
appState.global.loopStartTime = newStart; appState.global.loopStartTime = newStart;
appState.global.loopEndTime = newEnd; appState.global.loopEndTime = newEnd;
updateTransportLoop(); updateTransportLoop();
loopRegion.style.left = `${newStart * currentPixelsPerSecond}px`;
// ### 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 ###
}; };
const onMouseUp = () => { const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp); document.removeEventListener('mouseup', onMouseUp);
// Opcional: chamar renderAudioEditor() UMA VEZ no final
renderAudioEditor(); renderAudioEditor();
}; };
document.addEventListener('mousemove', onMouseMove); document.addEventListener('mousemove', onMouseMove);
@ -151,9 +139,8 @@ export function renderAudioEditor() {
return; return;
} }
// Se o clique não foi em um handle ou no corpo do loop, faz o "seek"
e.preventDefault(); e.preventDefault();
const handleSeek = (event) => { const handleSeek = (event) => { /* ... lógica de seek ... */
const rect = ruler.getBoundingClientRect(); const rect = ruler.getBoundingClientRect();
const scrollLeft = ruler.scrollLeft; const scrollLeft = ruler.scrollLeft;
const clickX = event.clientX - rect.left; const clickX = event.clientX - rect.left;
@ -168,7 +155,7 @@ export function renderAudioEditor() {
document.addEventListener('mouseup', onMouseUpSeek); 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); const newTrackContainer = existingTrackContainer.cloneNode(false);
audioEditor.replaceChild(newTrackContainer, existingTrackContainer); audioEditor.replaceChild(newTrackContainer, existingTrackContainer);
@ -176,7 +163,7 @@ export function renderAudioEditor() {
appState.audio.tracks.push({ id: Date.now(), name: "Pista de Áudio 1" }); 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 => { appState.audio.tracks.forEach(trackData => {
const audioTrackLane = document.createElement('div'); const audioTrackLane = document.createElement('div');
audioTrackLane.className = 'audio-track-lane'; 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("dragover", (e) => { e.preventDefault(); audioTrackLane.classList.add('drag-over'); });
timelineContainer.addEventListener("dragleave", () => audioTrackLane.classList.remove('drag-over')); timelineContainer.addEventListener("dragleave", () => audioTrackLane.classList.remove('drag-over'));
timelineContainer.addEventListener("drop", (e) => { timelineContainer.addEventListener("drop", (e) => {
e.preventDefault(); e.preventDefault();
audioTrackLane.classList.remove('drag-over'); audioTrackLane.classList.remove('drag-over');
@ -217,25 +205,37 @@ export function renderAudioEditor() {
if (!filePath) return; if (!filePath) return;
const rect = timelineContainer.getBoundingClientRect(); const rect = timelineContainer.getBoundingClientRect();
const dropX = e.clientX - rect.left + timelineContainer.scrollLeft; const dropX = e.clientX - rect.left + timelineContainer.scrollLeft;
const startTimeInSeconds = dropX / pixelsPerSecond; let startTimeInSeconds = dropX / pixelsPerSecond;
startTimeInSeconds = quantizeTime(startTimeInSeconds);
addAudioClipToTimeline(filePath, trackData.id, startTimeInSeconds); addAudioClipToTimeline(filePath, trackData.id, startTimeInSeconds);
}); });
const grid = timelineContainer.querySelector('.spectrogram-view-grid'); const grid = timelineContainer.querySelector('.spectrogram-view-grid');
grid.style.setProperty('--bar-width', `${scaledBarWidth}px`); grid.style.setProperty('--step-width', `${stepWidthPx}px`);
grid.style.setProperty('--four-bar-width', `${scaledBarWidth * 4}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 => { appState.audio.clips.forEach(clip => {
const parentGrid = newTrackContainer.querySelector(`.audio-track-lane[data-track-id="${clip.trackId}"] .spectrogram-view-grid`); const parentGrid = newTrackContainer.querySelector(`.audio-track-lane[data-track-id="${clip.trackId}"] .spectrogram-view-grid`);
if (!parentGrid) return; if (!parentGrid) return;
const clipElement = document.createElement('div'); const clipElement = document.createElement('div');
clipElement.className = 'timeline-clip'; clipElement.className = 'timeline-clip';
clipElement.dataset.clipId = clip.id; clipElement.dataset.clipId = clip.id;
clipElement.style.left = `${clip.startTime * pixelsPerSecond}px`;
clipElement.style.width = `${clip.duration * pixelsPerSecond}px`; // --- INÍCIO DA MODIFICAÇÃO ---
let pitchStr = clip.pitch > 0 ? `+${clip.pitch}` : `${clip.pitch}`; // 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 = ''; if (clip.pitch === 0) pitchStr = '';
clipElement.innerHTML = ` clipElement.innerHTML = `
<div class="clip-resize-handle left"></div> <div class="clip-resize-handle left"></div>
@ -244,13 +244,31 @@ export function renderAudioEditor() {
<div class="clip-resize-handle right"></div> <div class="clip-resize-handle right"></div>
`; `;
parentGrid.appendChild(clipElement); parentGrid.appendChild(clipElement);
if (clip.player && clip.player.loaded) {
if (clip.buffer) {
const canvas = clipElement.querySelector('.waveform-canvas-clip'); const canvas = clipElement.querySelector('.waveform-canvas-clip');
canvas.width = clip.duration * pixelsPerSecond; const canvasWidth = (clip.durationInSeconds || 0) * pixelsPerSecond;
canvas.height = 40; if (canvasWidth > 0) {
const audioBuffer = clip.player.buffer.get(); canvas.width = canvasWidth;
drawWaveform(canvas, audioBuffer, 'var(--accent-green)', clip.offset, clip.duration); 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) => { clipElement.addEventListener('wheel', (e) => {
e.preventDefault(); e.preventDefault();
const clipToUpdate = appState.audio.clips.find(c => c.id == clipElement.dataset.clipId); 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', () => { newTrackContainer.addEventListener('scroll', () => {
const scrollPos = newTrackContainer.scrollLeft; const scrollPos = newTrackContainer.scrollLeft;
if (ruler.scrollLeft !== scrollPos) { 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) => { newTrackContainer.addEventListener('mousedown', (e) => {
const currentPixelsPerSecond = getPixelsPerSecond(); // --- INÍCIO DA MODIFICAÇÃO ---
const handle = e.target.closest('.clip-resize-handle'); // 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'); const clipElement = e.target.closest('.timeline-clip');
if (appState.global.sliceToolActive && clipElement) { /* ... lógica de corte ... */ return; } // Desseleciona se clicar fora de um clipe (e não for clique direito)
if (handle) { /* ... lógica de redimensionamento de clipe ... */ return; } 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) { if (clipElement) {
e.preventDefault(); // --- INÍCIO DA MODIFICAÇÃO (SELEÇÃO NO DRAG) ---
const clipId = clipElement.dataset.clipId; 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; const clickOffsetInClip = e.clientX - clipElement.getBoundingClientRect().left;
clipElement.classList.add('dragging'); clipElement.classList.add('dragging');
let lastOverLane = clipElement.closest('.audio-track-lane'); let lastOverLane = clipElement.closest('.audio-track-lane');
@ -312,9 +524,10 @@ export function renderAudioEditor() {
const newLeftPx = (upEvent.clientX - wrapperRect.left) - clickOffsetInClip + timelineContainer.scrollLeft; const newLeftPx = (upEvent.clientX - wrapperRect.left) - clickOffsetInClip + timelineContainer.scrollLeft;
const constrainedLeftPx = Math.max(0, newLeftPx); 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(); renderAudioEditor();
}; };
document.addEventListener('mousemove', onMouseMove); document.addEventListener('mousemove', onMouseMove);
@ -324,6 +537,7 @@ export function renderAudioEditor() {
const timelineContainer = e.target.closest('.timeline-container'); const timelineContainer = e.target.closest('.timeline-container');
if (timelineContainer) { if (timelineContainer) {
// Lógica de 'seek' (sem alterações)
e.preventDefault(); e.preventDefault();
const handleSeek = (event) => { const handleSeek = (event) => {
const rect = timelineContainer.getBoundingClientRect(); const rect = timelineContainer.getBoundingClientRect();
@ -340,8 +554,42 @@ export function renderAudioEditor() {
document.addEventListener('mouseup', onMouseUpSeek); 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() { export function updateAudioEditorUI() {
const playBtn = document.getElementById('audio-editor-play-btn'); const playBtn = document.getElementById('audio-editor-play-btn');
if (!playBtn) return; if (!playBtn) return;

View File

@ -1,7 +1,8 @@
// js/main.js // js/main.js
import { appState, resetProjectState } from "./state.js"; import { appState, resetProjectState } from "./state.js";
import { addTrackToState, removeLastTrackFromState } from "./pattern/pattern_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 { updateTransportLoop } from "./audio/audio_audio.js";
import { import {
togglePlayback, togglePlayback,
@ -20,6 +21,21 @@ import { renderAudioEditor } from "./audio/audio_ui.js";
import { adjustValue, enforceNumericInput } from "./utils.js"; import { adjustValue, enforceNumericInput } from "./utils.js";
import { ZOOM_LEVELS } from "./config.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", () => { document.addEventListener("DOMContentLoaded", () => {
const newProjectBtn = document.getElementById("new-project-btn"); const newProjectBtn = document.getElementById("new-project-btn");
const openMmpBtn = document.getElementById("open-mmp-btn"); const openMmpBtn = document.getElementById("open-mmp-btn");
@ -36,6 +52,11 @@ document.addEventListener("DOMContentLoaded", () => {
const rewindBtn = document.getElementById("rewind-btn"); const rewindBtn = document.getElementById("rewind-btn");
const metronomeBtn = document.getElementById("metronome-btn"); const metronomeBtn = document.getElementById("metronome-btn");
const sliceToolBtn = document.getElementById("slice-tool-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 mmpFileInput = document.getElementById("mmp-file-input");
const sampleFileInput = document.getElementById("sample-file-input"); const sampleFileInput = document.getElementById("sample-file-input");
const openProjectModal = document.getElementById("open-project-modal"); const openProjectModal = document.getElementById("open-project-modal");
@ -46,6 +67,55 @@ document.addEventListener("DOMContentLoaded", () => {
const zoomInBtn = document.getElementById("zoom-in-btn"); const zoomInBtn = document.getElementById("zoom-in-btn");
const zoomOutBtn = document.getElementById("zoom-out-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", () => { 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; 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(); resetProjectState();
@ -74,10 +144,31 @@ document.addEventListener("DOMContentLoaded", () => {
stopBtn.addEventListener("click", stopPlayback); stopBtn.addEventListener("click", stopPlayback);
rewindBtn.addEventListener("click", rewindPlayback); rewindBtn.addEventListener("click", rewindPlayback);
metronomeBtn.addEventListener("click", () => { initializeAudioContext(); appState.global.metronomeEnabled = !appState.global.metronomeEnabled; metronomeBtn.classList.toggle("active", appState.global.metronomeEnabled); }); 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); openModalCloseBtn.addEventListener("click", closeOpenProjectModal);
// ### CORREÇÃO 2: Adicionada verificação 'if (icon)' ###
sidebarToggle.addEventListener("click", () => { sidebarToggle.addEventListener("click", () => {
document.body.classList.toggle("sidebar-hidden"); document.body.classList.toggle("sidebar-hidden");
const icon = sidebarToggle.querySelector("i"); const icon = sidebarToggle.querySelector("i");
@ -116,50 +207,31 @@ document.addEventListener("DOMContentLoaded", () => {
}); });
} }
audioEditorPlayBtn.addEventListener("click", () => { if (appState.global.isAudioEditorPlaying) { stopAudioEditorPlayback(); } else { startAudioEditorPlayback(); } }); audioEditorPlayBtn.addEventListener("click", () => {
audioEditorStopBtn.addEventListener("click", stopAudioEditorPlayback); if (appState.global.isAudioEditorPlaying) {
stopAudioEditorPlayback(false); // Pausa
// ### 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 { } else {
console.error("ERRO GRAVE: Elemento #loop-region NÃO FOI ENCONTRADO!"); // DEBUG 4 startAudioEditorPlayback();
} }
});
audioEditorStopBtn.addEventListener("click", () => stopAudioEditorPlayback(true)); // Stop (rebobina)
// 6. Reinicia o playback se estiver tocando 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);
}
restartAudioEditorIfPlaying(); restartAudioEditorIfPlaying();
}); });
if (addAudioTrackBtn) { addAudioTrackBtn.addEventListener("click", () => { addAudioTrackLane(); renderAudioEditor(); }); } 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(); loadAndRenderSampleBrowser();
// 2. Adiciona o listener DEPOIS que o conteúdo supostamente existe
const browserContent = document.getElementById('browser-content'); const browserContent = document.getElementById('browser-content');
if (browserContent) { if (browserContent) {
browserContent.addEventListener('click', function(event) { browserContent.addEventListener('click', function(event) {
@ -171,6 +243,6 @@ document.addEventListener("DOMContentLoaded", () => {
}); });
} }
// 3. Renderiza o resto
renderAll(); renderAll();
updateToolButtons(); // Define o estado inicial dos botões
}); });

View File

@ -18,9 +18,13 @@ const globalState = {
zoomLevelIndex: 2, zoomLevelIndex: 2,
// --- ADICIONADO PARA A ÁREA DE LOOP --- // --- ADICIONADO PARA A ÁREA DE LOOP ---
isLoopActive: false, // O botão de loop principal agora controla este estado isLoopActive: false,
loopStartTime: 0, // Início do loop em segundos loopStartTime: 0,
loopEndTime: 8, // Fim do loop em segundos (padrão de 4 compassos a 120BPM) 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 // Combina todos os estados em um único objeto namespaced
@ -50,5 +54,7 @@ export function resetProjectState() {
isLoopActive: false, isLoopActive: false,
loopStartTime: 0, loopStartTime: 0,
loopEndTime: 8, loopEndTime: 8,
resizeMode: 'trim', // Reseta para o modo 'trim'
selectedClipId: null,
}); });
} }

View File

@ -1,4 +1,5 @@
// js/ui.js // js/ui.js
import { appState } from "./state.js"; // <-- CORREÇÃO: Importação adicionada
import { playSample } from "./pattern/pattern_audio.js"; import { playSample } from "./pattern/pattern_audio.js";
import { renderPatternEditor } from "./pattern/pattern_ui.js"; import { renderPatternEditor } from "./pattern/pattern_ui.js";
import { renderAudioEditor } from "./audio/audio_ui.js"; import { renderAudioEditor } from "./audio/audio_ui.js";
@ -21,9 +22,6 @@ export function getSamplePathMap() {
return samplePathMap; return samplePathMap;
} }
//
function buildSamplePathMap(tree, currentPath) { function buildSamplePathMap(tree, currentPath) {
for (const key in tree) { for (const key in tree) {
if (key === "_isFile") continue; if (key === "_isFile") continue;

View File

@ -2,13 +2,68 @@
import { appState } from './state.js'; import { appState } from './state.js';
import { PIXELS_PER_STEP, ZOOM_LEVELS } from './config.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).
* 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, * Calcula a quantidade de pixels que representa um segundo na timeline,
* levando em conta o BPM e o nível de zoom atual. * levando em conta o BPM e o nível de zoom atual.
* @returns {number} A quantidade de pixels por segundo. * @returns {number} A quantidade de pixels por segundo.
*/ */
export function getPixelsPerSecond() { export function getPixelsPerSecond() {
const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120; 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 stepsPerSecond = (bpm / 60) * 4;
const zoomFactor = ZOOM_LEVELS[appState.global.zoomLevelIndex]; const zoomFactor = ZOOM_LEVELS[appState.global.zoomLevelIndex];
return stepsPerSecond * PIXELS_PER_STEP * zoomFactor; return stepsPerSecond * PIXELS_PER_STEP * zoomFactor;

View File

@ -126,6 +126,9 @@
<i class="fa-solid fa-search-minus" id="zoom-out-btn" title="Zoom Out"></i> <i class="fa-solid fa-search-minus" id="zoom-out-btn" title="Zoom Out"></i>
<i class="fa-solid fa-search-plus" id="zoom-in-btn" title="Zoom In"></i> <i class="fa-solid fa-search-plus" id="zoom-in-btn" title="Zoom In"></i>
<i class="fa-solid fa-scissors" id="slice-tool-btn" title="Ferramenta de Corte"></i> <i class="fa-solid fa-scissors" id="slice-tool-btn" title="Ferramenta de Corte"></i>
<i class="fa-solid fa-arrows-left-right-to-line" id="resize-tool-trim" title="Modo de Redimensionamento (Aparar/Trimming)"></i>
<i class="fa-solid fa-arrows-left-right" id="resize-tool-stretch" title="Modo de Redimensionamento (Esticar/Time Stretch)"></i>
<i class="fa-solid fa-play" id="audio-editor-play-btn" title="Play/Pause"></i> <i class="fa-solid fa-play" id="audio-editor-play-btn" title="Play/Pause"></i>
<i class="fa-solid fa-stop" id="audio-editor-stop-btn" title="Stop"></i> <i class="fa-solid fa-stop" id="audio-editor-stop-btn" title="Stop"></i>
<i class="fa-solid fa-repeat" id="audio-editor-loop-btn" title="Ativar/Desativar Loop"></i> <i class="fa-solid fa-repeat" id="audio-editor-loop-btn" title="Ativar/Desativar Loop"></i>
@ -215,6 +218,8 @@
<div id="timeline-context-menu"> <div id="timeline-context-menu">
<div id="set-loop-start">Definir Início do Loop</div> <div id="set-loop-start">Definir Início do Loop</div>
<div id="set-loop-end">Definir Fim do Loop</div> <div id="set-loop-end">Definir Fim do Loop</div>
<div class="menu-divider"></div>
<div id="delete-clip" style="color: var(--accent-red);">Excluir Clipe</div>
</div> </div>