// 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; // ✅ NÃO recria o container (preserva scroll e evita “voltar pro início”) const newTrackContainer = existingTrackContainer; const scrollEl = newTrackContainer; // ✅ limpa apenas as lanes antigas (mantém o scroller) newTrackContainer.innerHTML = ""; // --- 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 = `
`; 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 = `
`; 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 = ''; if(trackData.type === 'bassline') iconHTML = ''; audioTrackLane.innerHTML = `
${iconHTML} ${trackData.name}
VOL
PAN
`; 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 = `
${clip.name} ${pitchStr}
`; 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 (rebinding para não acumular listeners a cada render) newTrackContainer.addEventListener("scroll", () => { if (newTrackContainer.__onScroll) { newTrackContainer.removeEventListener("scroll", newTrackContainer.__onScroll); } newTrackContainer.__onScroll = () => { const scrollPos = scrollEl.scrollLeft; // guarda no estado 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); } }; newTrackContainer.addEventListener("scroll", newTrackContainer.__onScroll); }); // Event Listener Principal (mousedown) - rebinding if (newTrackContainer.__onMouseDown) { newTrackContainer.removeEventListener("mousedown", newTrackContainer.__onMouseDown); } // Event Listener Principal (mousedown) newTrackContainer.__onMouseDown = (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); } }; newTrackContainer.addEventListener("mousedown", newTrackContainer.__onMouseDown); // Menu Contexto Pista if (newTrackContainer.__onContextMenu) { newTrackContainer.removeEventListener("contextmenu", newTrackContainer.__onContextMenu); } newTrackContainer.__onContextMenu = (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"; } } }; newTrackContainer.addEventListener("contextmenu", newTrackContainer.__onContextMenu); // ✅ 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; }