1443 lines
52 KiB
JavaScript
Executable File
1443 lines
52 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, sendActionSafe } from "../socket.js";
|
|
|
|
// =====================================================
|
|
// Playlist Patterns (Bassline clips) - Drag/Resize/Delete
|
|
// =====================================================
|
|
const PL_TICKS_PER_STEP = 12;
|
|
const PL_MIN_LEN_TICKS = PL_TICKS_PER_STEP;
|
|
const PL_SNAP_TICKS = PL_TICKS_PER_STEP; // por enquanto 1/16; depois ligamos ao Snap do UI
|
|
|
|
function _genPlClipId() {
|
|
return crypto?.randomUUID?.() || `plc_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
}
|
|
|
|
function _ticksToPx(ticks, stepWidthPx) {
|
|
return (Number(ticks) / PL_TICKS_PER_STEP) * stepWidthPx;
|
|
}
|
|
|
|
function _pxToTicks(px, stepWidthPx) {
|
|
return Math.round((Number(px) / stepWidthPx) * PL_TICKS_PER_STEP);
|
|
}
|
|
|
|
function _snapTicks(ticks, snap = PL_SNAP_TICKS) {
|
|
const s = Math.max(1, Math.floor(snap));
|
|
return Math.round(Number(ticks) / s) * s;
|
|
}
|
|
|
|
function _ensureGlobalPlaylistSelectionFields() {
|
|
if (appState.global.selectedPlaylistClipId === undefined) {
|
|
appState.global.selectedPlaylistClipId = null;
|
|
}
|
|
if (appState.global.selectedPlaylistPatternIndex === undefined) {
|
|
appState.global.selectedPlaylistPatternIndex = null;
|
|
}
|
|
}
|
|
|
|
function _findBasslineByPatternIndex(patternIndex) {
|
|
return (appState.pattern?.tracks || []).find(
|
|
(t) => t.type === "bassline" && Number(t.patternIndex ?? 0) === Number(patternIndex ?? 0)
|
|
);
|
|
}
|
|
|
|
function _getPlaylistClipModel(patternIndex, clipId) {
|
|
const b = _findBasslineByPatternIndex(patternIndex);
|
|
if (!b || !Array.isArray(b.playlist_clips)) return null;
|
|
return b.playlist_clips.find((c) => String(c.id) === String(clipId));
|
|
}
|
|
|
|
function _updatePlaylistClipLocal(patternIndex, clipId, props) {
|
|
const b = _findBasslineByPatternIndex(patternIndex);
|
|
if (!b) return false;
|
|
if (!Array.isArray(b.playlist_clips)) b.playlist_clips = [];
|
|
|
|
const c = b.playlist_clips.find((x) => String(x.id) === String(clipId));
|
|
if (!c) return false;
|
|
|
|
if (props.pos !== undefined) c.pos = Math.max(0, Math.floor(props.pos));
|
|
if (props.len !== undefined) c.len = Math.max(PL_MIN_LEN_TICKS, Math.floor(props.len));
|
|
|
|
b.playlist_clips.sort((a, d) => (a.pos ?? 0) - (d.pos ?? 0));
|
|
return true;
|
|
}
|
|
|
|
function _removePlaylistClipLocal(patternIndex, clipId) {
|
|
const b = _findBasslineByPatternIndex(patternIndex);
|
|
if (!b || !Array.isArray(b.playlist_clips)) return false;
|
|
|
|
const before = b.playlist_clips.length;
|
|
b.playlist_clips = b.playlist_clips.filter((c) => String(c.id) !== String(clipId));
|
|
const after = b.playlist_clips.length;
|
|
|
|
if (appState.global.selectedPlaylistClipId === clipId) {
|
|
appState.global.selectedPlaylistClipId = null;
|
|
appState.global.selectedPlaylistPatternIndex = null;
|
|
}
|
|
|
|
return after !== before;
|
|
}
|
|
|
|
function _addPlaylistClipLocal(patternIndex, posTicks, lenTicks, name) {
|
|
const b = _findBasslineByPatternIndex(patternIndex);
|
|
if (!b) return false;
|
|
if (!Array.isArray(b.playlist_clips)) b.playlist_clips = [];
|
|
|
|
const clipId = _genPlClipId();
|
|
b.playlist_clips.push({
|
|
id: clipId,
|
|
pos: Math.max(0, Math.floor(posTicks ?? 0)),
|
|
len: Math.max(PL_MIN_LEN_TICKS, Math.floor(lenTicks ?? 192)),
|
|
name: name || b.name || `Beat/Bassline ${patternIndex}`,
|
|
});
|
|
b.playlist_clips.sort((a, d) => (a.pos ?? 0) - (d.pos ?? 0));
|
|
return clipId;
|
|
}
|
|
|
|
// Tecla Delete para apagar pattern selecionada na playlist (sem mexer no menu de áudio)
|
|
let _playlistKeybindInstalled = false;
|
|
function _installPlaylistKeybindOnce() {
|
|
if (_playlistKeybindInstalled) return;
|
|
_playlistKeybindInstalled = true;
|
|
|
|
window.addEventListener("keydown", (e) => {
|
|
// não atrapalha quando digitando em inputs
|
|
const tag = (document.activeElement?.tagName || "").toLowerCase();
|
|
if (tag === "input" || tag === "textarea") return;
|
|
|
|
if (e.key !== "Delete" && e.key !== "Backspace") return;
|
|
|
|
_ensureGlobalPlaylistSelectionFields();
|
|
const clipId = appState.global.selectedPlaylistClipId;
|
|
const patternIndex = appState.global.selectedPlaylistPatternIndex;
|
|
|
|
if (!clipId || patternIndex == null) return;
|
|
|
|
e.preventDefault();
|
|
const ok = confirm("Excluir esta pattern da playlist?");
|
|
if (!ok) return;
|
|
|
|
if (_removePlaylistClipLocal(patternIndex, clipId)) {
|
|
sendActionSafe({ type: "REMOVE_PLAYLIST_PATTERN_CLIP", patternIndex, clipId });
|
|
renderAudioEditor();
|
|
restartAudioEditorIfPlaying();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Função utilitária: Pattern Editor pode chamar isso para inserir na playlist
|
|
export function addActivePatternToPlaylistAt(timeSec = null) {
|
|
_ensureGlobalPlaylistSelectionFields();
|
|
|
|
const patternIndex = Number(appState.pattern?.activePatternIndex ?? 0);
|
|
const secondsPerStep = getSecondsPerStep();
|
|
|
|
// prioridade: tempo passado > último clique na régua > seek atual
|
|
const t =
|
|
(timeSec != null ? Number(timeSec) : null) ??
|
|
(appState.global.lastRulerClickTime != null ? Number(appState.global.lastRulerClickTime) : null) ??
|
|
Number(appState.audio?.audioEditorSeekTime ?? 0);
|
|
|
|
const steps = Math.max(0, Math.round(t / secondsPerStep));
|
|
let posTicks = steps * PL_TICKS_PER_STEP;
|
|
posTicks = _snapTicks(posTicks, PL_SNAP_TICKS);
|
|
|
|
// len default: 1 compasso em ticks (192) — pode refinar depois
|
|
const lenTicks = 192;
|
|
|
|
const newId = _addPlaylistClipLocal(patternIndex, posTicks, lenTicks, `Beat/Bassline ${patternIndex}`);
|
|
if (newId) {
|
|
sendActionSafe({
|
|
type: "ADD_PLAYLIST_PATTERN_CLIP",
|
|
patternIndex,
|
|
pos: posTicks,
|
|
len: lenTicks,
|
|
clipId: newId,
|
|
name: `Beat/Bassline ${patternIndex}`,
|
|
});
|
|
renderAudioEditor();
|
|
restartAudioEditorIfPlaying();
|
|
}
|
|
}
|
|
|
|
// opcional: expõe no window para botão no editor chamar sem import
|
|
window.addActivePatternToPlaylistAt = addActivePatternToPlaylistAt;
|
|
|
|
export function renderAudioEditor() {
|
|
const audioEditor = document.querySelector(".audio-editor");
|
|
const existingTrackContainer = document.getElementById("audio-track-container");
|
|
|
|
if (!audioEditor || !existingTrackContainer) return;
|
|
|
|
// ✅ preserva a posição de scroll mesmo recriando o container
|
|
const prevScrollLeft =
|
|
(existingTrackContainer?.scrollLeft ?? 0) ||
|
|
(appState.audio?.editorScrollLeft ?? 0);
|
|
|
|
const prevScrollTop =
|
|
(existingTrackContainer?.scrollTop ?? 0) ||
|
|
(appState.audio?.editorScrollTop ?? 0);
|
|
|
|
_ensureGlobalPlaylistSelectionFields();
|
|
_installPlaylistKeybindOnce();
|
|
|
|
// --- 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 || 0;
|
|
|
|
// áudio (segundos)
|
|
(appState.audio.clips || []).forEach((clip) => {
|
|
const endTime =
|
|
(clip.startTimeInSeconds || 0) + (clip.durationInSeconds || 0);
|
|
if (endTime > maxTime) maxTime = endTime;
|
|
});
|
|
|
|
// basslines (ticks -> steps -> segundos)
|
|
const TICKS_PER_STEP = 12; // você já usa 12 na renderização do bassline
|
|
const secondsPerStep = getSecondsPerStep();
|
|
|
|
if (appState.pattern?.tracks) {
|
|
appState.pattern.tracks.forEach((t) => {
|
|
if (t.type !== "bassline" || !Array.isArray(t.playlist_clips)) return;
|
|
|
|
t.playlist_clips.forEach((c) => {
|
|
const endTicks = (c.pos || 0) + (c.len || 0);
|
|
const endSteps = endTicks / TICKS_PER_STEP;
|
|
const endTimeSec = endSteps * secondsPerStep;
|
|
if (endTimeSec > maxTime) maxTime = endTimeSec;
|
|
});
|
|
});
|
|
}
|
|
|
|
const containerWidth =
|
|
tracksParent.clientWidth || existingTrackContainer.offsetWidth;
|
|
|
|
const contentWidth = maxTime * pixelsPerSecond;
|
|
|
|
// não encolhe se já tiver sido expandido antes
|
|
const previousWidth = appState.audio.timelineWidthPx || 0;
|
|
|
|
const totalWidth = Math.max(contentWidth, containerWidth, previousWidth, 2000);
|
|
|
|
appState.audio.timelineWidthPx = totalWidth;
|
|
|
|
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 = scrollEl.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 = scrollEl.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`;
|
|
});
|
|
|
|
// === 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);
|
|
}
|
|
});
|
|
}
|
|
|
|
function getLoopStepsForBasslineLane(basslineTrack) {
|
|
const patternIndex = basslineTrack.patternIndex ?? 0;
|
|
|
|
// pega os instrumentos que pertencem a esse rack (mesma lógica do pattern_ui) :contentReference[oaicite:1]{index=1}
|
|
const srcId = basslineTrack.instrumentSourceId || basslineTrack.id;
|
|
const children = (appState.pattern.tracks || []).filter(
|
|
(t) => t.type !== "bassline" && t.parentBasslineId === srcId && !t.muted
|
|
);
|
|
|
|
let maxSteps = 0;
|
|
|
|
for (const t of children) {
|
|
const p = t.patterns?.[patternIndex];
|
|
if (!p) continue;
|
|
|
|
if (Array.isArray(p.steps) && p.steps.length > 0) {
|
|
maxSteps = Math.max(maxSteps, p.steps.length);
|
|
continue;
|
|
}
|
|
|
|
// fallback pra patterns que só têm notes
|
|
if (Array.isArray(p.notes) && p.notes.length > 0) {
|
|
let endTick = 0;
|
|
for (const n of p.notes) {
|
|
const pos = Number(n.pos) || 0;
|
|
const len = Math.max(0, Number(n.len) || 0);
|
|
endTick = Math.max(endTick, pos + len);
|
|
}
|
|
const steps = Math.ceil(endTick / TICKS_PER_STEP);
|
|
maxSteps = Math.max(maxSteps, steps);
|
|
}
|
|
}
|
|
|
|
if (maxSteps <= 0) maxSteps = 16; // default
|
|
// arredonda pra múltiplo de 16 (bem “LMMS feel”)
|
|
maxSteps = Math.ceil(maxSteps / 16) * 16;
|
|
|
|
return maxSteps;
|
|
}
|
|
|
|
|
|
tracksToRender.forEach((trackData) => {
|
|
const audioTrackLane = document.createElement("div");
|
|
audioTrackLane.className = "audio-track-lane";
|
|
audioTrackLane.dataset.trackId = trackData.id;
|
|
audioTrackLane.style.minWidth = `calc(var(--track-info-width) + ${totalWidth}px)`;
|
|
|
|
// Í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 + scrollEl.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) => {
|
|
// garante id (sem quebrar projetos antigos)
|
|
if (!clip.id) clip.id = _genPlClipId();
|
|
|
|
const clipDiv = document.createElement("div");
|
|
clipDiv.className = "timeline-clip bassline-clip";
|
|
clipDiv.dataset.patternIndex = String(trackData.patternIndex ?? 0);
|
|
clipDiv.dataset.plClipId = String(clip.id);
|
|
|
|
if (String(clip.id) === String(appState.global.selectedPlaylistClipId)) {
|
|
clipDiv.classList.add("selected");
|
|
}
|
|
|
|
// ticks -> steps -> px
|
|
const steps = (clip.pos || 0) / PL_TICKS_PER_STEP;
|
|
const lengthInSteps = (clip.len || 0) / PL_TICKS_PER_STEP;
|
|
|
|
const leftPos = steps * stepWidthPx;
|
|
const widthDim = Math.max(1, lengthInSteps * stepWidthPx);
|
|
|
|
clipDiv.style.position = "absolute";
|
|
clipDiv.style.left = `${leftPos}px`;
|
|
clipDiv.style.width = `${widthDim}px`;
|
|
clipDiv.style.height = "100%";
|
|
clipDiv.style.boxSizing = "border-box";
|
|
clipDiv.style.cursor = "pointer";
|
|
clipDiv.style.zIndex = "5";
|
|
clipDiv.title = `${clip.name} (pos:${clip.pos}, len:${clip.len})`;
|
|
|
|
// grid visual igual ao seu (mantém “look”)
|
|
const gridStyle = getComputedStyle(grid);
|
|
clipDiv.style.backgroundImage = gridStyle.backgroundImage;
|
|
clipDiv.style.backgroundSize = gridStyle.backgroundSize;
|
|
clipDiv.style.backgroundRepeat = "repeat";
|
|
clipDiv.style.backgroundPosition = `-${leftPos}px 0px`;
|
|
clipDiv.style.backgroundColor = "rgba(0, 170, 170, 0.6)";
|
|
clipDiv.style.border = "1px solid #00aaaa";
|
|
|
|
// handles de resize (não usa .clip-resize-handle pra não conflitar com áudio)
|
|
const leftHandle = document.createElement("div");
|
|
leftHandle.className = "pattern-resize-handle left";
|
|
const rightHandle = document.createElement("div");
|
|
rightHandle.className = "pattern-resize-handle right";
|
|
clipDiv.appendChild(leftHandle);
|
|
clipDiv.appendChild(rightHandle);
|
|
|
|
// ✅ overlay de “marquinhas pretas” (loop interno da pattern)
|
|
const loopSteps = getLoopStepsForBasslineLane(trackData);
|
|
const loopPx = loopSteps * stepWidthPx;
|
|
if (loopPx > 0) {
|
|
const markers = document.createElement("div");
|
|
markers.style.position = "absolute";
|
|
markers.style.inset = "0";
|
|
markers.style.pointerEvents = "none";
|
|
markers.style.opacity = "0.9";
|
|
markers.style.backgroundImage = `repeating-linear-gradient(
|
|
to right,
|
|
rgba(0,0,0,0.75) 0px,
|
|
rgba(0,0,0,0.75) 2px,
|
|
transparent 2px,
|
|
transparent ${loopPx}px
|
|
)`;
|
|
markers.style.zIndex = "6";
|
|
clipDiv.appendChild(markers);
|
|
}
|
|
|
|
// label
|
|
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";
|
|
label.style.position = "relative";
|
|
label.style.zIndex = "7";
|
|
clipDiv.appendChild(label);
|
|
|
|
// duplo clique abre editor (seu comportamento atual)
|
|
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
|
|
function appendRulerMarkers(rulerEl, oldWidth, newWidth, barWidthPx) {
|
|
if (!rulerEl || barWidthPx <= 0) return;
|
|
|
|
const loopEl = rulerEl.querySelector("#loop-region");
|
|
const startBar = Math.floor(oldWidth / barWidthPx) + 1;
|
|
const endBar = Math.ceil(newWidth / barWidthPx);
|
|
|
|
for (let i = startBar; i <= endBar; i++) {
|
|
const marker = document.createElement("div");
|
|
marker.className = "ruler-marker";
|
|
marker.textContent = i;
|
|
marker.style.left = `${(i - 1) * barWidthPx}px`;
|
|
// mantém loop por cima/ordem estável
|
|
rulerEl.insertBefore(marker, loopEl || null);
|
|
}
|
|
}
|
|
|
|
function applyTimelineWidth(widthPx) {
|
|
appState.audio.timelineWidthPx = widthPx;
|
|
|
|
const r = tracksParent.querySelector(".timeline-ruler");
|
|
if (r) r.style.width = `${widthPx}px`;
|
|
|
|
tracksParent
|
|
.querySelectorAll(".spectrogram-view-grid")
|
|
.forEach((g) => (g.style.width = `${widthPx}px`));
|
|
}
|
|
|
|
// Sync Scroll
|
|
newTrackContainer.addEventListener("scroll", () => {
|
|
const scrollPos = scrollEl.scrollLeft;
|
|
|
|
// ✅ guarda no estado para sobreviver a re-render
|
|
appState.audio.editorScrollLeft = scrollPos;
|
|
appState.audio.editorScrollTop = scrollEl.scrollTop || 0;
|
|
|
|
// sincroniza régua com o container
|
|
const mainRuler = tracksParent.querySelector(".timeline-ruler");
|
|
if (mainRuler && mainRuler.scrollLeft !== scrollPos) {
|
|
mainRuler.scrollLeft = scrollPos;
|
|
}
|
|
|
|
// expansão "infinita"
|
|
const threshold = 300;
|
|
const rightEdge = scrollPos + newTrackContainer.clientWidth;
|
|
const currentWidth = appState.audio.timelineWidthPx || totalWidth;
|
|
|
|
if (rightEdge > currentWidth - threshold) {
|
|
const newWidth = Math.ceil(currentWidth * 1.5);
|
|
|
|
applyTimelineWidth(newWidth);
|
|
|
|
const rulerEl = tracksParent.querySelector(".timeline-ruler");
|
|
appendRulerMarkers(rulerEl, currentWidth, newWidth, barWidthPx);
|
|
}
|
|
});
|
|
|
|
// 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");
|
|
const isBasslineClip =
|
|
!!(clipElement && clipElement.classList.contains("bassline-clip"));
|
|
|
|
// ✅ limpa seleções ao clicar no vazio (sem mexer no RMB)
|
|
if (!clipElement && e.button !== 2) {
|
|
if (appState.global.selectedClipId) {
|
|
appState.global.selectedClipId = null;
|
|
}
|
|
if (appState.global.selectedPlaylistClipId) {
|
|
appState.global.selectedPlaylistClipId = null;
|
|
appState.global.selectedPlaylistPatternIndex = null;
|
|
}
|
|
|
|
newTrackContainer
|
|
.querySelectorAll(".timeline-clip.selected")
|
|
.forEach((c) => c.classList.remove("selected"));
|
|
}
|
|
|
|
const currentPixelsPerSecond = getPixelsPerSecond();
|
|
const handle = e.target.closest(".clip-resize-handle");
|
|
const patternHandle = e.target.closest(".pattern-resize-handle");
|
|
|
|
// ✅ se clicou num clip de áudio, deseleciona a pattern da playlist
|
|
if (clipElement && !isBasslineClip && e.button === 0) {
|
|
if (appState.global.selectedPlaylistClipId) {
|
|
appState.global.selectedPlaylistClipId = null;
|
|
appState.global.selectedPlaylistPatternIndex = null;
|
|
newTrackContainer
|
|
.querySelectorAll(".bassline-clip.selected")
|
|
.forEach((c) => c.classList.remove("selected"));
|
|
}
|
|
}
|
|
|
|
// Slice Tool (áudio apenas)
|
|
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 + scrollEl.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;
|
|
}
|
|
|
|
// =========================================================
|
|
// ✅ BASSLINE / PATTERN CLIPS (drag horizontal + resize L/R)
|
|
// =========================================================
|
|
if (isBasslineClip && e.button === 0) {
|
|
e.preventDefault();
|
|
|
|
_ensureGlobalPlaylistSelectionFields();
|
|
|
|
const patternIndex = Number(clipElement.dataset.patternIndex ?? 0);
|
|
const plClipId = String(clipElement.dataset.plClipId || "");
|
|
if (!plClipId) return;
|
|
|
|
// seleção visual/estado
|
|
appState.global.selectedPlaylistClipId = plClipId;
|
|
appState.global.selectedPlaylistPatternIndex = patternIndex;
|
|
|
|
// desmarca seleção de áudio se tiver
|
|
if (appState.global.selectedClipId) {
|
|
appState.global.selectedClipId = null;
|
|
newTrackContainer
|
|
.querySelectorAll(".timeline-clip.selected")
|
|
.forEach((c) => c.classList.remove("selected"));
|
|
}
|
|
|
|
newTrackContainer
|
|
.querySelectorAll(".bassline-clip.selected")
|
|
.forEach((c) => c.classList.remove("selected"));
|
|
clipElement.classList.add("selected");
|
|
|
|
// (opcional) sync de seleção
|
|
sendActionSafe({
|
|
type: "SELECT_PLAYLIST_PATTERN_CLIP",
|
|
patternIndex,
|
|
clipId: plClipId,
|
|
});
|
|
|
|
const model = _getPlaylistClipModel(patternIndex, plClipId);
|
|
if (!model) return;
|
|
|
|
const initialMouseX = e.clientX;
|
|
const initialScrollLeft = scrollEl.scrollLeft;
|
|
|
|
const initialPosTicks = Number(model.pos || 0);
|
|
const initialLenTicks = Math.max(
|
|
PL_MIN_LEN_TICKS,
|
|
Number(model.len || PL_MIN_LEN_TICKS)
|
|
);
|
|
const initialEndTicks = initialPosTicks + initialLenTicks;
|
|
|
|
const previewUpdate = (posTicks, lenTicks) => {
|
|
const leftPx = _ticksToPx(posTicks, stepWidthPx);
|
|
const widthPx = Math.max(1, _ticksToPx(lenTicks, stepWidthPx));
|
|
clipElement.style.left = `${leftPx}px`;
|
|
clipElement.style.width = `${widthPx}px`;
|
|
// mantém grid alinhado com a timeline
|
|
clipElement.style.backgroundPosition = `-${leftPx}px 0px`;
|
|
};
|
|
|
|
// ---------- RESIZE ----------
|
|
if (patternHandle) {
|
|
const handleType = patternHandle.classList.contains("left")
|
|
? "left"
|
|
: "right";
|
|
|
|
clipElement.classList.add("dragging");
|
|
|
|
const onMouseMove = (moveEvent) => {
|
|
const deltaPx =
|
|
(moveEvent.clientX - initialMouseX) +
|
|
(scrollEl.scrollLeft - initialScrollLeft);
|
|
|
|
const deltaTicks = _pxToTicks(deltaPx, stepWidthPx);
|
|
|
|
let newPos = initialPosTicks;
|
|
let newLen = initialLenTicks;
|
|
|
|
if (handleType === "right") {
|
|
let newEnd = _snapTicks(initialEndTicks + deltaTicks, PL_SNAP_TICKS);
|
|
newEnd = Math.max(initialPosTicks + PL_MIN_LEN_TICKS, newEnd);
|
|
newLen = newEnd - initialPosTicks;
|
|
} else {
|
|
newPos = _snapTicks(initialPosTicks + deltaTicks, PL_SNAP_TICKS);
|
|
newPos = Math.max(0, newPos);
|
|
newPos = Math.min(newPos, initialEndTicks - PL_MIN_LEN_TICKS);
|
|
newLen = initialEndTicks - newPos;
|
|
}
|
|
|
|
previewUpdate(newPos, newLen);
|
|
};
|
|
|
|
const onMouseUp = () => {
|
|
clipElement.classList.remove("dragging");
|
|
document.removeEventListener("mousemove", onMouseMove);
|
|
document.removeEventListener("mouseup", onMouseUp);
|
|
|
|
// converte estado final (px -> ticks) com snap
|
|
const finalLeftPx = clipElement.offsetLeft;
|
|
const finalWidthPx = clipElement.offsetWidth;
|
|
|
|
let finalPos = _pxToTicks(finalLeftPx, stepWidthPx);
|
|
let finalLen = _pxToTicks(finalWidthPx, stepWidthPx);
|
|
|
|
finalPos = _snapTicks(finalPos, PL_SNAP_TICKS);
|
|
finalLen = _snapTicks(finalLen, PL_SNAP_TICKS);
|
|
|
|
finalPos = Math.max(0, finalPos);
|
|
finalLen = Math.max(PL_MIN_LEN_TICKS, finalLen);
|
|
|
|
_updatePlaylistClipLocal(patternIndex, plClipId, {
|
|
pos: finalPos,
|
|
len: finalLen,
|
|
});
|
|
|
|
sendActionSafe({
|
|
type: "UPDATE_PLAYLIST_PATTERN_CLIP",
|
|
patternIndex,
|
|
clipId: plClipId,
|
|
pos: finalPos,
|
|
len: finalLen,
|
|
});
|
|
|
|
renderAudioEditor();
|
|
restartAudioEditorIfPlaying();
|
|
};
|
|
|
|
document.addEventListener("mousemove", onMouseMove);
|
|
document.addEventListener("mouseup", onMouseUp);
|
|
return;
|
|
}
|
|
|
|
// ---------- DRAG (horizontal apenas) ----------
|
|
clipElement.classList.add("dragging");
|
|
|
|
const onMouseMove = (moveEvent) => {
|
|
const deltaPx =
|
|
(moveEvent.clientX - initialMouseX) +
|
|
(scrollEl.scrollLeft - initialScrollLeft);
|
|
|
|
let newPos = initialPosTicks + _pxToTicks(deltaPx, stepWidthPx);
|
|
newPos = _snapTicks(newPos, PL_SNAP_TICKS);
|
|
newPos = Math.max(0, newPos);
|
|
|
|
previewUpdate(newPos, initialLenTicks);
|
|
};
|
|
|
|
const onMouseUp = () => {
|
|
clipElement.classList.remove("dragging");
|
|
document.removeEventListener("mousemove", onMouseMove);
|
|
document.removeEventListener("mouseup", onMouseUp);
|
|
|
|
const finalLeftPx = clipElement.offsetLeft;
|
|
let finalPos = _pxToTicks(finalLeftPx, stepWidthPx);
|
|
finalPos = _snapTicks(finalPos, PL_SNAP_TICKS);
|
|
finalPos = Math.max(0, finalPos);
|
|
|
|
_updatePlaylistClipLocal(patternIndex, plClipId, {
|
|
pos: finalPos,
|
|
len: initialLenTicks,
|
|
});
|
|
|
|
sendActionSafe({
|
|
type: "UPDATE_PLAYLIST_PATTERN_CLIP",
|
|
patternIndex,
|
|
clipId: plClipId,
|
|
pos: finalPos,
|
|
len: initialLenTicks,
|
|
});
|
|
|
|
renderAudioEditor();
|
|
restartAudioEditorIfPlaying();
|
|
};
|
|
|
|
document.addEventListener("mousemove", onMouseMove);
|
|
document.addEventListener("mouseup", onMouseUp);
|
|
return;
|
|
}
|
|
|
|
// =========================================================
|
|
// Resize Handle (ÁUDIO)
|
|
// =========================================================
|
|
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 = () => {
|
|
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;
|
|
|
|
const clipModel = appState.audio.clips.find(
|
|
(c) => String(c.id) === String(clipId)
|
|
);
|
|
const initialStartTime = Number(clipModel?.startTimeInSeconds || 0);
|
|
|
|
e.preventDefault();
|
|
const initialMouseX = e.clientX;
|
|
const initialScrollLeft = scrollEl.scrollLeft;
|
|
|
|
clipElement.classList.add("dragging");
|
|
let lastOverLane = clipElement.closest(".audio-track-lane");
|
|
|
|
const onMouseMove = (moveEvent) => {
|
|
const deltaX = moveEvent.clientX - initialMouseX;
|
|
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 deltaX =
|
|
(upEvent.clientX - initialMouseX) +
|
|
(scrollEl.scrollLeft - initialScrollLeft);
|
|
|
|
let newStartTime =
|
|
initialStartTime + deltaX / currentPixelsPerSecond;
|
|
newStartTime = Math.max(0, newStartTime);
|
|
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 = scrollEl.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")) {
|
|
const patternIndex = Number(clipElement.dataset.patternIndex ?? 0);
|
|
const plClipId = String(clipElement.dataset.plClipId || "");
|
|
|
|
appState.global.selectedPlaylistClipId = plClipId;
|
|
appState.global.selectedPlaylistPatternIndex = patternIndex;
|
|
|
|
const ok = confirm("Excluir esta pattern da playlist?");
|
|
if (ok) {
|
|
if (_removePlaylistClipLocal(patternIndex, plClipId)) {
|
|
sendActionSafe({ type: "REMOVE_PLAYLIST_PATTERN_CLIP", patternIndex, clipId: plClipId });
|
|
renderAudioEditor();
|
|
restartAudioEditorIfPlaying();
|
|
}
|
|
}
|
|
|
|
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";
|
|
}
|
|
}
|
|
});
|
|
|
|
// ✅ restaura scroll após reconstruir a DOM (precisa ser após tudo estar no DOM)
|
|
requestAnimationFrame(() => {
|
|
try {
|
|
newTrackContainer.scrollLeft = prevScrollLeft;
|
|
newTrackContainer.scrollTop = prevScrollTop;
|
|
|
|
// mantém régua alinhada na mesma posição
|
|
const mainRuler = tracksParent.querySelector(".timeline-ruler");
|
|
if (mainRuler) mainRuler.scrollLeft = prevScrollLeft;
|
|
} catch {}
|
|
});
|
|
}
|
|
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;
|
|
} |