809 lines
32 KiB
JavaScript
Executable File
809 lines
32 KiB
JavaScript
Executable File
// js/audio/audio_ui.js
|
|
import { appState } from "../state.js";
|
|
import {
|
|
addAudioClipToTimeline,
|
|
updateAudioClipProperties,
|
|
sliceAudioClip,
|
|
removeAudioClip,
|
|
} from "./audio_state.js";
|
|
import {
|
|
seekAudioEditor,
|
|
restartAudioEditorIfPlaying,
|
|
updateTransportLoop,
|
|
} from "./audio_audio.js";
|
|
import { drawWaveform } from "../waveform.js";
|
|
import { PIXELS_PER_BAR, PIXELS_PER_STEP, ZOOM_LEVELS } from "../config.js";
|
|
import {
|
|
getPixelsPerSecond,
|
|
quantizeTime,
|
|
getBeatsPerBar,
|
|
getSecondsPerStep,
|
|
} from "../utils.js";
|
|
import { sendAction } from "../socket.js";
|
|
|
|
export function renderAudioEditor() {
|
|
const audioEditor = document.querySelector(".audio-editor");
|
|
const existingTrackContainer = document.getElementById("audio-track-container");
|
|
|
|
if (!audioEditor || !existingTrackContainer) return;
|
|
|
|
// --- Identifica o pai real do container ---
|
|
const tracksParent = existingTrackContainer.parentElement;
|
|
|
|
// --- CRIAÇÃO E RENDERIZAÇÃO DA RÉGUA ---
|
|
let rulerWrapper = tracksParent.querySelector(".ruler-wrapper");
|
|
|
|
if (!rulerWrapper) {
|
|
rulerWrapper = document.createElement("div");
|
|
rulerWrapper.className = "ruler-wrapper";
|
|
rulerWrapper.innerHTML = `
|
|
<div class="ruler-spacer"></div>
|
|
<div class="timeline-ruler"></div>
|
|
`;
|
|
tracksParent.insertBefore(rulerWrapper, existingTrackContainer);
|
|
}
|
|
|
|
const staticRuler = tracksParent.querySelector("#audio-timeline-ruler");
|
|
if (staticRuler && staticRuler.parentElement === tracksParent) {
|
|
staticRuler.remove();
|
|
}
|
|
const oldLoopRegion = tracksParent.querySelector("#loop-region");
|
|
const oldPlayhead = tracksParent.querySelector("#playhead");
|
|
if(oldLoopRegion) oldLoopRegion.remove();
|
|
if(oldPlayhead) oldPlayhead.remove();
|
|
|
|
const ruler = rulerWrapper.querySelector(".timeline-ruler");
|
|
ruler.innerHTML = "";
|
|
|
|
const pixelsPerSecond = getPixelsPerSecond();
|
|
|
|
let maxTime = appState.global.loopEndTime;
|
|
appState.audio.clips.forEach((clip) => {
|
|
const endTime =
|
|
(clip.startTimeInSeconds || 0) + (clip.durationInSeconds || 0);
|
|
if (endTime > maxTime) maxTime = endTime;
|
|
});
|
|
|
|
const containerWidth = existingTrackContainer.offsetWidth;
|
|
const contentWidth = maxTime * pixelsPerSecond;
|
|
const totalWidth = Math.max(contentWidth, containerWidth, 2000);
|
|
|
|
ruler.style.width = `${totalWidth}px`;
|
|
|
|
const zoomFactor = ZOOM_LEVELS[appState.global.zoomLevelIndex];
|
|
const beatsPerBar = getBeatsPerBar();
|
|
const stepWidthPx = PIXELS_PER_STEP * zoomFactor;
|
|
const beatWidthPx = stepWidthPx * 4;
|
|
const barWidthPx = beatWidthPx * beatsPerBar;
|
|
|
|
if (barWidthPx > 0) {
|
|
const numberOfBars = Math.ceil(totalWidth / barWidthPx);
|
|
for (let i = 1; i <= numberOfBars; i++) {
|
|
const marker = document.createElement("div");
|
|
marker.className = "ruler-marker";
|
|
marker.textContent = i;
|
|
marker.style.left = `${(i - 1) * barWidthPx}px`;
|
|
ruler.appendChild(marker);
|
|
}
|
|
}
|
|
|
|
const loopRegion = document.createElement("div");
|
|
loopRegion.id = "loop-region";
|
|
loopRegion.style.left = `${
|
|
appState.global.loopStartTime * pixelsPerSecond
|
|
}px`;
|
|
loopRegion.style.width = `${
|
|
(appState.global.loopEndTime - appState.global.loopStartTime) *
|
|
pixelsPerSecond
|
|
}px`;
|
|
loopRegion.innerHTML = `<div class="loop-handle left"></div><div class="loop-handle right"></div>`;
|
|
loopRegion.classList.toggle("visible", appState.global.isLoopActive);
|
|
ruler.appendChild(loopRegion);
|
|
|
|
// --- LISTENER DA RÉGUA ---
|
|
const newRuler = ruler.cloneNode(true);
|
|
ruler.parentNode.replaceChild(newRuler, ruler);
|
|
|
|
newRuler.addEventListener("mousedown", (e) => {
|
|
document.getElementById("timeline-context-menu").style.display = "none";
|
|
document.getElementById("ruler-context-menu").style.display = "none";
|
|
|
|
const currentPixelsPerSecond = getPixelsPerSecond();
|
|
const loopHandle = e.target.closest(".loop-handle");
|
|
const loopRegionBody = e.target.closest("#loop-region:not(.loop-handle)");
|
|
|
|
// Drag Handle Loop
|
|
if (loopHandle) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const handleType = loopHandle.classList.contains("left") ? "left" : "right";
|
|
const initialMouseX = e.clientX;
|
|
const initialStart = appState.global.loopStartTime;
|
|
const initialEnd = appState.global.loopEndTime;
|
|
const onMouseMove = (moveEvent) => {
|
|
const deltaX = moveEvent.clientX - initialMouseX;
|
|
const deltaTime = deltaX / currentPixelsPerSecond;
|
|
let newStart = appState.global.loopStartTime;
|
|
let newEnd = appState.global.loopEndTime;
|
|
if (handleType === "left") {
|
|
newStart = Math.max(0, initialStart + deltaTime);
|
|
newStart = Math.min(newStart, appState.global.loopEndTime - 0.1);
|
|
appState.global.loopStartTime = newStart;
|
|
} else {
|
|
newEnd = Math.max(
|
|
appState.global.loopStartTime + 0.1,
|
|
initialEnd + deltaTime
|
|
);
|
|
appState.global.loopEndTime = newEnd;
|
|
}
|
|
updateTransportLoop();
|
|
const loopRegionEl = newRuler.querySelector("#loop-region");
|
|
if (loopRegionEl) {
|
|
loopRegionEl.style.left = `${newStart * currentPixelsPerSecond}px`;
|
|
loopRegionEl.style.width = `${(newEnd - newStart) * currentPixelsPerSecond}px`;
|
|
}
|
|
};
|
|
const onMouseUp = () => {
|
|
document.removeEventListener("mousemove", onMouseMove);
|
|
document.removeEventListener("mouseup", onMouseUp);
|
|
sendAction({
|
|
type: "SET_LOOP_STATE",
|
|
isLoopActive: appState.global.isLoopActive,
|
|
loopStartTime: appState.global.loopStartTime,
|
|
loopEndTime: appState.global.loopEndTime,
|
|
});
|
|
};
|
|
document.addEventListener("mousemove", onMouseMove);
|
|
document.addEventListener("mouseup", onMouseUp);
|
|
return;
|
|
}
|
|
|
|
// Drag Body Loop
|
|
if (loopRegionBody) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const initialMouseX = e.clientX;
|
|
const initialStart = appState.global.loopStartTime;
|
|
const initialEnd = appState.global.loopEndTime;
|
|
const initialDuration = initialEnd - initialStart;
|
|
const onMouseMove = (moveEvent) => {
|
|
const deltaX = moveEvent.clientX - initialMouseX;
|
|
const deltaTime = deltaX / currentPixelsPerSecond;
|
|
let newStart = Math.max(0, initialStart + deltaTime);
|
|
let newEnd = newStart + initialDuration;
|
|
appState.global.loopStartTime = newStart;
|
|
appState.global.loopEndTime = newEnd;
|
|
updateTransportLoop();
|
|
const loopRegionEl = newRuler.querySelector("#loop-region");
|
|
if (loopRegionEl)
|
|
loopRegionEl.style.left = `${newStart * currentPixelsPerSecond}px`;
|
|
};
|
|
const onMouseUp = () => {
|
|
document.removeEventListener("mousemove", onMouseMove);
|
|
document.removeEventListener("mouseup", onMouseUp);
|
|
sendAction({
|
|
type: "SET_LOOP_STATE",
|
|
isLoopActive: appState.global.isLoopActive,
|
|
loopStartTime: appState.global.loopStartTime,
|
|
loopEndTime: appState.global.loopEndTime,
|
|
});
|
|
};
|
|
document.addEventListener("mousemove", onMouseMove);
|
|
document.addEventListener("mouseup", onMouseUp);
|
|
return;
|
|
}
|
|
|
|
// Seek na Régua
|
|
e.preventDefault();
|
|
const handleSeek = (event) => {
|
|
const rect = newRuler.getBoundingClientRect();
|
|
const scrollLeft = newRuler.scrollLeft;
|
|
const clickX = event.clientX - rect.left;
|
|
const absoluteX = clickX + scrollLeft;
|
|
const newTime = absoluteX / currentPixelsPerSecond;
|
|
sendAction({ type: "SET_SEEK_TIME", seekTime: newTime });
|
|
};
|
|
handleSeek(e);
|
|
const onMouseMoveSeek = (moveEvent) => handleSeek(moveEvent);
|
|
const onMouseUpSeek = () => {
|
|
document.removeEventListener("mousemove", onMouseMoveSeek);
|
|
document.removeEventListener("mouseup", onMouseUpSeek);
|
|
};
|
|
document.addEventListener("mousemove", onMouseMoveSeek);
|
|
document.addEventListener("mouseup", onMouseUpSeek);
|
|
});
|
|
|
|
// Menu Contexto Régua
|
|
newRuler.addEventListener("contextmenu", (e) => {
|
|
e.preventDefault();
|
|
document.getElementById("timeline-context-menu").style.display = "none";
|
|
const menu = document.getElementById("ruler-context-menu");
|
|
const currentPixelsPerSecond = getPixelsPerSecond();
|
|
const rect = newRuler.getBoundingClientRect();
|
|
const scrollLeft = newRuler.scrollLeft;
|
|
const clickX = e.clientX - rect.left;
|
|
const absoluteX = clickX + scrollLeft;
|
|
const clickTime = absoluteX / currentPixelsPerSecond;
|
|
appState.global.lastRulerClickTime = clickTime;
|
|
menu.style.display = "block";
|
|
menu.style.left = `${e.clientX}px`;
|
|
menu.style.top = `${e.clientY}px`;
|
|
});
|
|
|
|
// Recriação Container Pistas
|
|
const newTrackContainer = existingTrackContainer.cloneNode(false);
|
|
tracksParent.replaceChild(newTrackContainer, existingTrackContainer);
|
|
|
|
// === RENDERIZAÇÃO DAS PISTAS (LANES) ===
|
|
|
|
// CORREÇÃO: Junta as pistas de áudio com as Basslines (que estão no Pattern State)
|
|
const tracksToRender = [...(appState.audio.tracks || [])];
|
|
if (appState.pattern && appState.pattern.tracks) {
|
|
appState.pattern.tracks.forEach(pTrack => {
|
|
// Adiciona se for bassline e ainda não existir na lista
|
|
if (pTrack.type === 'bassline' && !tracksToRender.find(t => t.id === pTrack.id)) {
|
|
tracksToRender.push(pTrack);
|
|
}
|
|
});
|
|
}
|
|
|
|
tracksToRender.forEach((trackData) => {
|
|
const audioTrackLane = document.createElement("div");
|
|
audioTrackLane.className = "audio-track-lane";
|
|
audioTrackLane.dataset.trackId = trackData.id;
|
|
|
|
// Ícone dinâmico
|
|
let iconHTML = '<i class="fa-solid fa-music"></i>';
|
|
if(trackData.type === 'bassline') iconHTML = '<i class="fa-solid fa-th-large" title="Bassline"></i>';
|
|
|
|
audioTrackLane.innerHTML = `
|
|
<div class="track-info">
|
|
<div class="track-info-header">
|
|
${iconHTML}
|
|
<span class="track-name">${trackData.name}</span>
|
|
<div class="track-mute"></div>
|
|
</div>
|
|
<div class="track-controls">
|
|
<div class="knob-container"> <div class="knob" data-control="volume"><div class="knob-indicator"></div></div> <span>VOL</span> </div>
|
|
<div class="knob-container"> <div class="knob" data-control="pan"><div class="knob-indicator"></div></div> <span>PAN</span> </div>
|
|
</div>
|
|
</div>
|
|
<div class="timeline-container">
|
|
<div class="spectrogram-view-grid" style="width: ${totalWidth}px;"></div>
|
|
<div class="playhead"></div>
|
|
</div>
|
|
`;
|
|
|
|
newTrackContainer.appendChild(audioTrackLane);
|
|
|
|
const timelineContainer = audioTrackLane.querySelector(".timeline-container");
|
|
const grid = timelineContainer.querySelector(".spectrogram-view-grid");
|
|
|
|
// Configura variáveis CSS
|
|
grid.style.setProperty("--step-width", `${stepWidthPx}px`);
|
|
grid.style.setProperty("--beat-width", `${beatWidthPx}px`);
|
|
grid.style.setProperty("--bar-width", `${barWidthPx}px`);
|
|
|
|
// Drag & Drop
|
|
timelineContainer.addEventListener("dragover", (e) => {
|
|
e.preventDefault();
|
|
audioTrackLane.classList.add("drag-over");
|
|
});
|
|
timelineContainer.addEventListener("dragleave", () =>
|
|
audioTrackLane.classList.remove("drag-over")
|
|
);
|
|
timelineContainer.addEventListener("drop", (e) => {
|
|
e.preventDefault();
|
|
audioTrackLane.classList.remove("drag-over");
|
|
const filePath = e.dataTransfer.getData("text/plain");
|
|
if (!filePath) return;
|
|
const rect = timelineContainer.getBoundingClientRect();
|
|
const dropX = e.clientX - rect.left + timelineContainer.scrollLeft;
|
|
let startTimeInSeconds = dropX / pixelsPerSecond;
|
|
startTimeInSeconds = quantizeTime(startTimeInSeconds);
|
|
if (!trackData.id || startTimeInSeconds == null || isNaN(startTimeInSeconds)) return;
|
|
|
|
const clipId = crypto?.randomUUID?.() || `clip_${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
|
|
|
|
addAudioClipToTimeline(filePath, trackData.id, startTimeInSeconds, clipId);
|
|
try {
|
|
sendAction({
|
|
type: "ADD_AUDIO_CLIP",
|
|
filePath,
|
|
trackId: trackData.id,
|
|
startTimeInSeconds,
|
|
clipId,
|
|
name: String(filePath).split(/[\\/]/).pop(),
|
|
});
|
|
} catch (err) {
|
|
console.warn("[SYNC] Falha ao emitir ADD_AUDIO_CLIP", err);
|
|
}
|
|
});
|
|
|
|
// --- RENDERIZAÇÃO DOS CLIPES DE BASSLINE (Blocos Azuis) ---
|
|
if (trackData.type === "bassline" && trackData.playlist_clips) {
|
|
trackData.playlist_clips.forEach(clip => {
|
|
const clipDiv = document.createElement("div");
|
|
clipDiv.className = "timeline-clip bassline-clip";
|
|
|
|
// CONVERSÃO MMP (192 ticks/compasso?) vs StepWidth
|
|
// Se stepWidthPx representa 1/16, e cada step tem 12 ticks
|
|
const steps = clip.pos / 12;
|
|
const lengthInSteps = clip.len / 12;
|
|
|
|
const leftPos = steps * stepWidthPx;
|
|
const widthDim = lengthInSteps * stepWidthPx;
|
|
|
|
clipDiv.style.position = "absolute";
|
|
clipDiv.style.left = `${leftPos}px`;
|
|
clipDiv.style.width = `${widthDim}px`;
|
|
clipDiv.style.height = "100%";
|
|
clipDiv.style.backgroundColor = "rgba(0, 170, 170, 0.6)";
|
|
clipDiv.style.border = "1px solid #00aaaa";
|
|
clipDiv.style.boxSizing = "border-box";
|
|
clipDiv.style.cursor = "pointer";
|
|
clipDiv.style.zIndex = "5";
|
|
clipDiv.title = `${clip.name} (Pos: ${clip.pos})`;
|
|
|
|
const label = document.createElement("span");
|
|
label.innerText = clip.name;
|
|
label.style.fontSize = "0.7rem";
|
|
label.style.color = "#fff";
|
|
label.style.padding = "4px";
|
|
label.style.pointerEvents = "none";
|
|
label.style.whiteSpace = "nowrap";
|
|
label.style.overflow = "hidden";
|
|
clipDiv.appendChild(label);
|
|
|
|
clipDiv.addEventListener("dblclick", (e) => {
|
|
e.stopPropagation();
|
|
if (window.openPatternEditor) {
|
|
window.openPatternEditor(trackData);
|
|
} else {
|
|
console.error("Função window.openPatternEditor não encontrada.");
|
|
}
|
|
});
|
|
|
|
grid.appendChild(clipDiv);
|
|
});
|
|
}
|
|
});
|
|
|
|
// === RENDERIZAÇÃO DE CLIPES DE ÁUDIO (Samples) ===
|
|
appState.audio.clips.forEach((clip) => {
|
|
// Busca a pista correta (pode ser nova ou antiga)
|
|
const parentGrid = newTrackContainer.querySelector(
|
|
`.audio-track-lane[data-track-id="${clip.trackId}"] .spectrogram-view-grid`
|
|
);
|
|
if (!parentGrid) return; // Se o clipe aponta pra uma pista que não existe, ignora
|
|
|
|
const clipElement = document.createElement("div");
|
|
clipElement.className = "timeline-clip";
|
|
clipElement.dataset.clipId = clip.id;
|
|
if (clip.id === appState.global.selectedClipId)
|
|
clipElement.classList.add("selected");
|
|
if (appState.global.clipboard?.cutSourceId === clip.id)
|
|
clipElement.classList.add("cut");
|
|
clipElement.style.left = `${
|
|
(clip.startTimeInSeconds || 0) * pixelsPerSecond
|
|
}px`;
|
|
clipElement.style.width = `${
|
|
(clip.durationInSeconds || 0) * pixelsPerSecond
|
|
}px`;
|
|
let pitchStr =
|
|
clip.pitch > 0 ? `+${clip.pitch.toFixed(1)}` : `${clip.pitch.toFixed(1)}`;
|
|
if (clip.pitch === 0) pitchStr = "";
|
|
|
|
clipElement.innerHTML = ` <div class="clip-resize-handle left"></div> <span class="clip-name">${clip.name} ${pitchStr}</span> <canvas class="waveform-canvas-clip"></canvas> <div class="clip-resize-handle right"></div> `;
|
|
|
|
if (
|
|
clip.patternData &&
|
|
Array.isArray(clip.patternData) &&
|
|
clip.patternData.length > 0
|
|
) {
|
|
clipElement.classList.add("pattern-clip");
|
|
const totalSteps = clip.patternData[0]?.length || 0;
|
|
if (totalSteps > 0) {
|
|
const patternViewEl = createPatternViewElement(
|
|
clip.patternData,
|
|
totalSteps
|
|
);
|
|
clipElement.appendChild(patternViewEl);
|
|
}
|
|
}
|
|
|
|
parentGrid.appendChild(clipElement);
|
|
|
|
if (clip.buffer) {
|
|
const canvas = clipElement.querySelector(".waveform-canvas-clip");
|
|
const canvasWidth = (clip.durationInSeconds || 0) * pixelsPerSecond;
|
|
if (canvasWidth > 0) {
|
|
canvas.width = canvasWidth;
|
|
canvas.height = 40;
|
|
const audioBuffer = clip.buffer;
|
|
const isStretched = clip.pitch !== 0;
|
|
const sourceOffset = isStretched ? 0 : clip.offset || 0;
|
|
const sourceDuration = isStretched
|
|
? clip.originalDuration
|
|
: clip.durationInSeconds;
|
|
drawWaveform(
|
|
canvas,
|
|
audioBuffer,
|
|
"var(--accent-green)",
|
|
sourceOffset,
|
|
sourceDuration
|
|
);
|
|
}
|
|
}
|
|
|
|
clipElement.addEventListener("wheel", (e) => {
|
|
e.preventDefault();
|
|
const clipToUpdate = appState.audio.clips.find(
|
|
(c) => c.id == clipElement.dataset.clipId
|
|
);
|
|
if (!clipToUpdate) return;
|
|
const direction = e.deltaY < 0 ? 1 : -1;
|
|
let newPitch = clipToUpdate.pitch + direction;
|
|
newPitch = Math.max(-24, Math.min(24, newPitch));
|
|
updateAudioClipProperties(clipToUpdate.id, { pitch: newPitch });
|
|
try {
|
|
sendAction({
|
|
type: "UPDATE_AUDIO_CLIP",
|
|
clipId: clipToUpdate.id,
|
|
props: { pitch: newPitch },
|
|
});
|
|
} catch (err) {
|
|
console.warn("[SYNC] Falha ao emitir UPDATE_AUDIO_CLIP (wheel)", err);
|
|
}
|
|
renderAudioEditor();
|
|
restartAudioEditorIfPlaying();
|
|
});
|
|
});
|
|
|
|
// Sync Scroll
|
|
newTrackContainer.addEventListener("scroll", () => {
|
|
const scrollPos = newTrackContainer.scrollLeft;
|
|
const mainRuler = document.querySelector(".timeline-ruler");
|
|
if (mainRuler && mainRuler.scrollLeft !== scrollPos) {
|
|
mainRuler.scrollLeft = scrollPos;
|
|
}
|
|
});
|
|
|
|
// Event Listener Principal (mousedown)
|
|
newTrackContainer.addEventListener("mousedown", (e) => {
|
|
document.getElementById("timeline-context-menu").style.display = "none";
|
|
document.getElementById("ruler-context-menu").style.display = "none";
|
|
const clipElement = e.target.closest(".timeline-clip");
|
|
|
|
if (!clipElement && e.button !== 2) {
|
|
if (appState.global.selectedClipId) {
|
|
appState.global.selectedClipId = null;
|
|
newTrackContainer
|
|
.querySelectorAll(".timeline-clip.selected")
|
|
.forEach((c) => c.classList.remove("selected"));
|
|
}
|
|
}
|
|
|
|
const currentPixelsPerSecond = getPixelsPerSecond();
|
|
const handle = e.target.closest(".clip-resize-handle");
|
|
|
|
// Slice Tool
|
|
if (appState.global.sliceToolActive && clipElement && !clipElement.classList.contains("bassline-clip")) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const clipId = clipElement.dataset.clipId;
|
|
const timelineContainer = clipElement.closest(".timeline-container");
|
|
const rect = timelineContainer.getBoundingClientRect();
|
|
const clickX = e.clientX - rect.left;
|
|
const absoluteX = clickX + timelineContainer.scrollLeft;
|
|
let sliceTimeInTimeline = absoluteX / currentPixelsPerSecond;
|
|
sliceTimeInTimeline = quantizeTime(sliceTimeInTimeline);
|
|
sliceAudioClip(clipId, sliceTimeInTimeline);
|
|
try {
|
|
sendAction({
|
|
type: "UPDATE_AUDIO_CLIP",
|
|
clipId,
|
|
props: { __operation: "slice", sliceTimeInTimeline },
|
|
});
|
|
} catch (err) {
|
|
console.warn("[SYNC] Falha ao emitir UPDATE_AUDIO_CLIP (slice)", err);
|
|
}
|
|
renderAudioEditor();
|
|
return;
|
|
}
|
|
|
|
// Resize Handle
|
|
if (handle) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const clipId = clipElement.dataset.clipId;
|
|
const clip = appState.audio.clips.find((c) => c.id == clipId);
|
|
if (!clip || !clip.buffer) return;
|
|
|
|
const handleType = handle.classList.contains("left") ? "left" : "right";
|
|
const initialMouseX = e.clientX;
|
|
const secondsPerStep = getSecondsPerStep();
|
|
const initialLeftPx = clipElement.offsetLeft;
|
|
const initialWidthPx = clipElement.offsetWidth;
|
|
const initialStartTime = clip.startTimeInSeconds;
|
|
const initialDuration = clip.durationInSeconds;
|
|
const initialOffset = clip.offset || 0;
|
|
const initialOriginalDuration = clip.originalDuration || clip.buffer.duration;
|
|
const bufferStartTime = initialStartTime - initialOffset;
|
|
|
|
const onMouseMove = (moveEvent) => {
|
|
const deltaX = moveEvent.clientX - initialMouseX;
|
|
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`;
|
|
}
|
|
} 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;
|
|
|
|
if (appState.global.resizeMode === "trim") {
|
|
const newOffset = newStartTime - bufferStartTime;
|
|
if(handleType === "right") {
|
|
updateAudioClipProperties(clipId, { durationInSeconds: newDuration, pitch: 0 });
|
|
sendActionSafe({ type: "UPDATE_AUDIO_CLIP", clipId, props: { durationInSeconds: newDuration, pitch: 0 } });
|
|
} else {
|
|
updateAudioClipProperties(clipId, { startTimeInSeconds: newStartTime, durationInSeconds: newDuration, offset: newOffset, pitch: 0 });
|
|
sendActionSafe({ type: "UPDATE_AUDIO_CLIP", clipId, props: { startTimeInSeconds: newStartTime, durationInSeconds: newDuration, offset: newOffset, pitch: 0 } });
|
|
}
|
|
} else {
|
|
const newPlaybackRate = initialOriginalDuration / newDuration;
|
|
const newPitch = 12 * Math.log2(newPlaybackRate);
|
|
if(handleType === "right") {
|
|
updateAudioClipProperties(clipId, { durationInSeconds: newDuration, pitch: newPitch, offset: 0 });
|
|
sendActionSafe({ type: "UPDATE_AUDIO_CLIP", clipId, props: { durationInSeconds: newDuration, pitch: newPitch, offset: 0 } });
|
|
} else {
|
|
updateAudioClipProperties(clipId, { startTimeInSeconds: newStartTime, durationInSeconds: newDuration, pitch: newPitch, offset: 0 });
|
|
sendActionSafe({ type: "UPDATE_AUDIO_CLIP", clipId, props: { startTimeInSeconds: newStartTime, durationInSeconds: newDuration, pitch: newPitch, offset: 0 } });
|
|
}
|
|
}
|
|
restartAudioEditorIfPlaying();
|
|
renderAudioEditor();
|
|
};
|
|
document.addEventListener("mousemove", onMouseMove);
|
|
document.addEventListener("mouseup", onMouseUp);
|
|
return;
|
|
}
|
|
|
|
// Drag Clip (Audio apenas)
|
|
if (clipElement && !clipElement.classList.contains("bassline-clip")) {
|
|
const clipId = clipElement.dataset.clipId;
|
|
if (appState.global.selectedClipId !== clipId) {
|
|
appState.global.selectedClipId = clipId;
|
|
newTrackContainer.querySelectorAll(".timeline-clip.selected").forEach((c) => c.classList.remove("selected"));
|
|
clipElement.classList.add("selected");
|
|
}
|
|
e.preventDefault();
|
|
const clickOffsetInClip = e.clientX - clipElement.getBoundingClientRect().left;
|
|
clipElement.classList.add("dragging");
|
|
let lastOverLane = clipElement.closest(".audio-track-lane");
|
|
|
|
const onMouseMove = (moveEvent) => {
|
|
const deltaX = moveEvent.clientX - e.clientX;
|
|
clipElement.style.transform = `translateX(${deltaX}px)`;
|
|
const overElement = document.elementFromPoint(moveEvent.clientX, moveEvent.clientY);
|
|
const overLane = overElement ? overElement.closest(".audio-track-lane") : null;
|
|
if (overLane && overLane !== lastOverLane) {
|
|
if (lastOverLane) lastOverLane.classList.remove("drag-over");
|
|
overLane.classList.add("drag-over");
|
|
lastOverLane = overLane;
|
|
}
|
|
};
|
|
|
|
const onMouseUp = (upEvent) => {
|
|
clipElement.classList.remove("dragging");
|
|
if (lastOverLane) lastOverLane.classList.remove("drag-over");
|
|
clipElement.style.transform = "";
|
|
document.removeEventListener("mousemove", onMouseMove);
|
|
document.removeEventListener("mouseup", onMouseUp);
|
|
|
|
const finalLane = lastOverLane;
|
|
if (!finalLane) return;
|
|
const newTrackId = finalLane.dataset.trackId;
|
|
const timelineContainer = finalLane.querySelector(".timeline-container");
|
|
const wrapperRect = timelineContainer.getBoundingClientRect();
|
|
const newLeftPx = upEvent.clientX - wrapperRect.left - clickOffsetInClip + timelineContainer.scrollLeft;
|
|
const constrainedLeftPx = Math.max(0, newLeftPx);
|
|
let newStartTime = constrainedLeftPx / currentPixelsPerSecond;
|
|
newStartTime = quantizeTime(newStartTime);
|
|
|
|
updateAudioClipProperties(clipId, { trackId: newTrackId, startTimeInSeconds: newStartTime });
|
|
sendActionSafe({ type: "UPDATE_AUDIO_CLIP", clipId, props: { trackId: newTrackId, startTimeInSeconds: newStartTime } });
|
|
renderAudioEditor();
|
|
};
|
|
document.addEventListener("mousemove", onMouseMove);
|
|
document.addEventListener("mouseup", onMouseUp);
|
|
return;
|
|
}
|
|
|
|
// Seek na Pista
|
|
const timelineContainer = e.target.closest(".timeline-container");
|
|
if (timelineContainer) {
|
|
e.preventDefault();
|
|
const handleSeek = (event) => {
|
|
const rect = timelineContainer.getBoundingClientRect();
|
|
const scrollLeft = timelineContainer.scrollLeft;
|
|
const clickX = event.clientX - rect.left;
|
|
const absoluteX = clickX + scrollLeft;
|
|
const newTime = absoluteX / currentPixelsPerSecond;
|
|
sendAction({ type: "SET_SEEK_TIME", seekTime: newTime });
|
|
};
|
|
handleSeek(e);
|
|
const onMouseMoveSeek = (moveEvent) => handleSeek(moveEvent);
|
|
const onMouseUpSeek = () => {
|
|
document.removeEventListener("mousemove", onMouseMoveSeek);
|
|
document.removeEventListener("mouseup", onMouseUpSeek);
|
|
};
|
|
document.addEventListener("mousemove", onMouseMoveSeek);
|
|
document.addEventListener("mouseup", onMouseUpSeek);
|
|
}
|
|
});
|
|
|
|
// Menu Contexto Pista
|
|
newTrackContainer.addEventListener("contextmenu", (e) => {
|
|
e.preventDefault();
|
|
document.getElementById("ruler-context-menu").style.display = "none";
|
|
const menu = document.getElementById("timeline-context-menu");
|
|
if (!menu) return;
|
|
const clipElement = e.target.closest(".timeline-clip");
|
|
|
|
const copyItem = document.getElementById("copy-clip");
|
|
const cutItem = document.getElementById("cut-clip");
|
|
const pasteItem = document.getElementById("paste-clip");
|
|
const deleteItem = document.getElementById("delete-clip");
|
|
const canPaste = appState.global.clipboard?.type === "audio";
|
|
|
|
pasteItem.style.display = canPaste ? "block" : "none";
|
|
|
|
if (clipElement) {
|
|
if(clipElement.classList.contains("bassline-clip")) {
|
|
menu.style.display = "none";
|
|
return;
|
|
}
|
|
|
|
const clipId = clipElement.dataset.clipId;
|
|
if (appState.global.selectedClipId !== clipId) {
|
|
appState.global.selectedClipId = clipId;
|
|
newTrackContainer.querySelectorAll(".timeline-clip.selected").forEach((c) => c.classList.remove("selected"));
|
|
clipElement.classList.add("selected");
|
|
}
|
|
copyItem.style.display = "block";
|
|
cutItem.style.display = "block";
|
|
deleteItem.style.display = "block";
|
|
menu.style.display = "block";
|
|
menu.style.left = `${e.clientX}px`;
|
|
menu.style.top = `${e.clientY}px`;
|
|
|
|
if (!deleteItem.__synced) {
|
|
deleteItem.__synced = true;
|
|
deleteItem.addEventListener("click", () => {
|
|
const id = appState.global.selectedClipId;
|
|
if (!id) return;
|
|
const ok = removeAudioClip(id);
|
|
sendActionSafe({ type: "REMOVE_AUDIO_CLIP", clipId: id });
|
|
if (ok) renderAudioEditor();
|
|
menu.style.display = "none";
|
|
});
|
|
}
|
|
} else {
|
|
copyItem.style.display = "none";
|
|
cutItem.style.display = "none";
|
|
deleteItem.style.display = "none";
|
|
if (canPaste) {
|
|
menu.style.display = "block";
|
|
menu.style.left = `${e.clientX}px`;
|
|
menu.style.top = `${e.clientY}px`;
|
|
} else {
|
|
menu.style.display = "none";
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function sendActionSafe(action) {
|
|
try {
|
|
sendAction(action);
|
|
} catch (err) {
|
|
console.warn("[SYNC] Falha ao emitir ação:", action.type, err);
|
|
}
|
|
}
|
|
|
|
export function updateAudioEditorUI() {
|
|
const playBtn = document.getElementById("audio-editor-play-btn");
|
|
if (!playBtn) return;
|
|
if (appState.global.isAudioEditorPlaying) {
|
|
playBtn.classList.remove("fa-play");
|
|
playBtn.classList.add("fa-pause");
|
|
} else {
|
|
playBtn.classList.remove("fa-pause");
|
|
playBtn.classList.add("fa-play");
|
|
}
|
|
}
|
|
export function updatePlayheadVisual(pixels) {
|
|
document.querySelectorAll(".audio-track-lane .playhead").forEach((ph) => {
|
|
ph.style.left = `${pixels}px`;
|
|
});
|
|
}
|
|
export function resetPlayheadVisual() {
|
|
document.querySelectorAll(".audio-track-lane .playhead").forEach((ph) => {
|
|
ph.style.left = "0px";
|
|
});
|
|
}
|
|
|
|
function createPatternViewElement(patternData) {
|
|
const view = document.createElement("div");
|
|
view.className = "pattern-clip-view";
|
|
|
|
const validTracksData = patternData.filter(
|
|
(steps) => Array.isArray(steps) && steps.length > 0
|
|
);
|
|
|
|
const totalSteps = validTracksData.reduce(
|
|
(max, steps) => Math.max(max, steps.length),
|
|
0
|
|
);
|
|
if (totalSteps === 0) return view;
|
|
|
|
validTracksData.forEach((trackSteps) => {
|
|
const row = document.createElement("div");
|
|
row.className = "pattern-clip-track-row";
|
|
const stepWidthPercent = (1 / totalSteps) * 100;
|
|
for (let i = 0; i < totalSteps; i++) {
|
|
if (trackSteps[i] === true) {
|
|
const note = document.createElement("div");
|
|
note.className = "pattern-step-note";
|
|
note.style.left = `${(i / totalSteps) * 100}%`;
|
|
note.style.width = `${stepWidthPercent}%`;
|
|
row.appendChild(note);
|
|
}
|
|
}
|
|
view.appendChild(row);
|
|
});
|
|
return view;
|
|
} |