// js/pattern/pattern_ui.js import { appState } from "../state.js"; import { updateTrackSample, setPatternTrackMute, setPatternTrackVolume, setPatternTrackPan, } from "./pattern_state.js"; import { playSample, stopPlayback } from "./pattern_audio.js"; import { getTotalSteps } from "../utils.js"; import { sendAction } from "../socket.js"; import { initializeAudioContext } from "../audio.js"; import * as Tone from "https://esm.sh/tone"; // ------------------------------------------------------------ // Knob helpers (drag vertical para ajustar) // ------------------------------------------------------------ const _KNOB_MIN_DEG = -135; const _KNOB_MAX_DEG = 135; const _MAX_TRACK_VOLUME = 1.5; function _clamp(n, min, max) { const x = Number(n); if (!Number.isFinite(x)) return min; return Math.min(max, Math.max(min, x)); } function _valueToDeg(control, value) { if (control === "pan") { const v = _clamp(value, -1, 1); const norm = (v + 1) / 2; // 0..1 return _KNOB_MIN_DEG + norm * (_KNOB_MAX_DEG - _KNOB_MIN_DEG); } const v = _clamp(value, 0, _MAX_TRACK_VOLUME); const norm = v / _MAX_TRACK_VOLUME; return _KNOB_MIN_DEG + norm * (_KNOB_MAX_DEG - _KNOB_MIN_DEG); } function _setKnobIndicator(knobEl, control, value) { const ind = knobEl.querySelector(".knob-indicator"); if (!ind) return; ind.style.transform = `rotate(${_valueToDeg(control, value)}deg)`; } function _attachKnobDrag(knobEl, { control, getCurrent, setLocal, commit }) { if (!knobEl) return; knobEl.addEventListener("mousedown", (e) => { e.preventDefault(); e.stopPropagation(); initializeAudioContext(); const startY = e.clientY; const startVal = Number(getCurrent?.() ?? 0); const sensitivity = control === "pan" ? 0.01 : 0.005; let lastVal = startVal; const onMove = (ev) => { const delta = (startY - ev.clientY) * sensitivity; let v = startVal + delta; if (control === "pan") v = _clamp(v, -1, 1); else v = _clamp(v, 0, _MAX_TRACK_VOLUME); lastVal = v; setLocal?.(v); _setKnobIndicator(knobEl, control, v); }; const onUp = () => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); commit?.(lastVal); }; window.addEventListener("mousemove", onMove); window.addEventListener("mouseup", onUp); }); } // Função principal de renderização para o editor de patterns export function renderPatternEditor() { const trackContainer = document.getElementById("track-container"); trackContainer.innerHTML = ""; // 1. LÓGICA DE FILTRAGEM E CONTEXTO // Decide quais trilhas mostrar baseando-se no ID de Bassline em foco let tracksToRender = []; let isFocusedMode = false; let contextName = "Song Editor"; // Nome da visualização atual if (appState.pattern.focusedBasslineId) { isFocusedMode = true; // 1) acha o container (Caixa/Kick/...) focado const basslineTrack = appState.pattern.tracks.find( t => t.id === appState.pattern.focusedBasslineId && t.type === "bassline" ); // 2) usa o rack real como "pai" (fallback: id do próprio container) const srcId = basslineTrack?.instrumentSourceId || appState.pattern.focusedBasslineId; // 3) mostra somente instrumentos pertencentes ao rack tracksToRender = appState.pattern.tracks.filter( t => t.type !== "bassline" && (t.parentBasslineId === srcId || t.parentBasslineId == null) ); // Nome no header if (basslineTrack) contextName = basslineTrack.name; } else { // Modo Padrão: Mostra trilhas da raiz (sem pai) e que NÃO são containers de bassline tracksToRender = appState.pattern.tracks.filter( t => t.type !== "bassline" && t.parentBasslineId == null ); // Fallback: Se a lista ficar vazia (projeto antigo ou mal formatado), mostra tudo que não é container if (tracksToRender.length === 0) { tracksToRender = appState.pattern.tracks.filter(t => t.type !== 'bassline'); } } // 2. RENDERIZAÇÃO DO CABEÇALHO DE NAVEGAÇÃO const navHeader = document.createElement("div"); navHeader.className = "editor-nav-header"; // Estilos inline para garantir aparência imediata (mova para CSS depois se preferir) navHeader.style.padding = "8px 12px"; navHeader.style.backgroundColor = "#2b2b2b"; navHeader.style.marginBottom = "8px"; navHeader.style.display = "flex"; navHeader.style.alignItems = "center"; navHeader.style.justifyContent = "space-between"; navHeader.style.color = "#ddd"; navHeader.style.borderBottom = "1px solid #444"; navHeader.style.fontSize = "0.9rem"; const titleIcon = isFocusedMode ? "fa-th-large" : "fa-music"; const title = document.createElement("span"); title.innerHTML = ` ${contextName}`; navHeader.appendChild(title); // não visualizar tracks nem instrumentos sem pattern selecionada const hasSelection = Number.isInteger(appState.pattern.activePatternIndex); if (!isFocusedMode && !hasSelection) { const msg = document.createElement("div"); msg.style.padding = "12px"; msg.style.color = "#bbb"; msg.textContent = "Selecione uma pattern para visualizar/editar."; trackContainer.appendChild(msg); updateGlobalPatternSelector(); return; } // Botão Voltar (Aparece apenas se estiver dentro de uma Bassline) if (isFocusedMode) { const backBtn = document.createElement("button"); backBtn.className = "control-btn"; backBtn.innerHTML = ` Voltar`; backBtn.style.padding = "4px 10px"; backBtn.style.backgroundColor = "#444"; backBtn.style.border = "1px solid #555"; backBtn.style.borderRadius = "4px"; backBtn.style.color = "white"; backBtn.style.cursor = "pointer"; backBtn.onmouseover = () => backBtn.style.backgroundColor = "#555"; backBtn.onmouseout = () => backBtn.style.backgroundColor = "#444"; backBtn.onclick = () => { if (window.exitPatternFocus) { window.exitPatternFocus(); } else { console.error("Função window.exitPatternFocus não encontrada no escopo global."); } }; navHeader.appendChild(backBtn); } trackContainer.appendChild(navHeader); // 3. RENDERIZAÇÃO DAS TRILHAS FILTRADAS tracksToRender.forEach((trackData) => { // IMPORTANTE: Precisamos encontrar o índice real no array global // para que as ações (volume, pan, delete) afetem a trilha certa. const originalIndex = appState.pattern.tracks.findIndex(t => t.id === trackData.id); const trackLane = document.createElement("div"); trackLane.className = "track-lane"; trackLane.dataset.trackIndex = originalIndex; if (trackData.id === appState.pattern.activeTrackId) { trackLane.classList.add('active-track'); } trackLane.innerHTML = `
${trackData.name}
VOL
PAN
`; // -------------------------- // Mute (ativar/desativar canal) // -------------------------- const muteBtn = trackLane.querySelector(".track-mute"); if (muteBtn) { const isMutedNow = !!(trackData.isMuted || trackData.muted); muteBtn.classList.toggle("muted", isMutedNow); muteBtn.title = isMutedNow ? "Unmute" : "Mute"; muteBtn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); initializeAudioContext(); const t = appState.pattern.tracks[originalIndex]; const cur = !!(t?.isMuted || t?.muted); const next = !cur; // aplica local (instantâneo) setPatternTrackMute(trackData.id, next); // feedback visual muteBtn.classList.toggle("muted", next); muteBtn.title = next ? "Unmute" : "Mute"; // broadcast (colaboração) sendAction({ type: "SET_PATTERN_TRACK_MUTE", trackId: trackData.id, isMuted: next, }); }); } // -------------------------- // Knobs (VOL / PAN) // -------------------------- trackLane.querySelectorAll(".knob").forEach((knobEl) => { const control = knobEl.dataset.control; const trackId = knobEl.dataset.trackId; const t = appState.pattern.tracks[originalIndex]; const initial = control === "pan" ? (t?.pan ?? 0) : (t?.volume ?? 1); _setKnobIndicator(knobEl, control, initial); _attachKnobDrag(knobEl, { control, getCurrent: () => { const tr = appState.pattern.tracks.find((x) => String(x.id) === String(trackId)); return control === "pan" ? (tr?.pan ?? 0) : (tr?.volume ?? 1); }, setLocal: (v) => { if (control === "pan") setPatternTrackPan(trackId, v); else setPatternTrackVolume(trackId, v); }, commit: (v) => { if (control === "pan") { sendAction({ type: "SET_PATTERN_TRACK_PAN", trackId, pan: v }); } else { sendAction({ type: "SET_PATTERN_TRACK_VOLUME", trackId, volume: v }); } }, }); }); // Eventos de Seleção trackLane.addEventListener('click', () => { if (appState.pattern.activeTrackId === trackData.id) return; // stopPlayback(); // (Opcional: descomente se quiser parar o som ao trocar de track) appState.pattern.activeTrackId = trackData.id; document.querySelectorAll('.track-lane').forEach(lane => lane.classList.remove('active-track')); trackLane.classList.add('active-track'); updateGlobalPatternSelector(); redrawSequencer(); }); // Drag and Drop de Samples trackLane.addEventListener("dragover", (e) => { e.preventDefault(); trackLane.classList.add("drag-over"); }); trackLane.addEventListener("dragleave", () => trackLane.classList.remove("drag-over")); trackLane.addEventListener("drop", (e) => { e.preventDefault(); trackLane.classList.remove("drag-over"); const filePath = e.dataTransfer.getData("text/plain"); if (filePath) { sendAction({ type: 'SET_TRACK_SAMPLE', trackIndex: originalIndex, filePath: filePath }); } }); trackContainer.appendChild(trackLane); }); updateGlobalPatternSelector(); redrawSequencer(); } // Renderiza o grid de steps ou piano roll miniatura export function redrawSequencer() { const totalGridSteps = getTotalSteps(); document.querySelectorAll(".step-sequencer-wrapper").forEach((wrapper) => { let sequencerContainer = wrapper.querySelector(".step-sequencer"); if (!sequencerContainer) { sequencerContainer = document.createElement("div"); sequencerContainer.className = "step-sequencer"; wrapper.appendChild(sequencerContainer); } else { sequencerContainer.innerHTML = ""; } // Busca o dado da trilha usando o índice global const parentTrackElement = wrapper.closest(".track-lane"); const trackIndex = parseInt(parentTrackElement.dataset.trackIndex, 10); const trackData = appState.pattern.tracks[trackIndex]; if (!trackData || !trackData.patterns || trackData.patterns.length === 0) return; const isFocused = !!appState.pattern.focusedBasslineId; const globalIdx = appState.pattern.activePatternIndex; // ✅ Song Editor sem pattern selecionada: não mostra composição if (!isFocused && !Number.isInteger(globalIdx)) { // opcional: desenha uma grade "apagada" pra manter o layout for (let i = 0; i < totalGridSteps; i++) { const stepWrapper = document.createElement("div"); stepWrapper.className = "step-wrapper"; const stepElement = document.createElement("div"); stepElement.className = "step"; // sem .active stepElement.style.opacity = "0.25"; stepElement.style.pointerEvents = "none"; stepWrapper.appendChild(stepElement); sequencerContainer.appendChild(stepWrapper); } return; } // ✅ em foco: se não tiver globalIdx válido, cai no 0 (porque foco sempre edita algo) const activePatternIndex = isFocused ? (Number.isInteger(globalIdx) ? globalIdx : 0) : globalIdx; const activePattern = trackData.patterns[activePatternIndex]; if (!activePattern) return; // --- DECISÃO: STEP SEQUENCER VS PIANO ROLL --- const notes = activePattern.notes || []; let renderMode = 'steps'; if (notes.length > 0) { const firstKey = notes[0].key; // Se houver variação de nota ou notas longas, usa visualização de Piano Roll if (notes.some(n => n.key !== firstKey) || notes.some(n => n.len > 48)) { renderMode = 'piano_roll'; } } if (renderMode === 'piano_roll') { // --- MODO: MINI PIANO ROLL --- sequencerContainer.classList.add('mode-piano'); const miniView = document.createElement('div'); miniView.className = 'track-mini-piano-roll'; miniView.title = "Clique duplo para abrir o Piano Roll completo"; miniView.addEventListener('dblclick', (e) => { e.stopPropagation(); if (window.openPianoRoll) window.openPianoRoll(trackData.id); }); // 48 ticks = 1/16 step. const totalTicks = totalGridSteps * 48; activePattern.notes.forEach(note => { const noteEl = document.createElement('div'); noteEl.className = 'mini-note'; const leftPercent = Math.min((note.pos / totalTicks) * 100, 100); const widthPercent = Math.min((note.len / totalTicks) * 100, 100 - leftPercent); // Mapeia nota MIDI para altura (aprox 4 oitavas visíveis) const relativeKey = Math.max(0, Math.min(note.key - 36, 48)); const topPercent = 100 - ((relativeKey / 48) * 100); noteEl.style.left = `${leftPercent}%`; noteEl.style.width = `${widthPercent}%`; noteEl.style.top = `${topPercent}%`; miniView.appendChild(noteEl); }); sequencerContainer.appendChild(miniView); } else { // --- MODO: STEP SEQUENCER (Bateria) --- sequencerContainer.classList.remove('mode-piano'); const patternSteps = activePattern.steps; for (let i = 0; i < totalGridSteps; i++) { const stepWrapper = document.createElement("div"); stepWrapper.className = "step-wrapper"; const stepElement = document.createElement("div"); stepElement.className = "step"; if (patternSteps[i] === true) stepElement.classList.add("active"); // Evento de Toggle stepElement.addEventListener("click", (e) => { e.stopPropagation(); initializeAudioContext(); const isActive = !activePattern.steps[i]; sendAction({ type: 'TOGGLE_NOTE', trackIndex, patternIndex: activePatternIndex, stepIndex: i, isActive }); // Preview Sonoro if (isActive) { if (trackData.type === 'sampler' && trackData.samplePath) { playSample(trackData.samplePath, trackData.id); } else if (trackData.type === 'plugin' && trackData.instrument) { try { trackData.instrument.triggerAttackRelease("C5", "16n", Tone.now()); } catch(err) {} } } }); // Estilização do Grid (Marcadores de Compasso e Beat) if (Math.floor(i / 4) % 2 === 0) stepElement.classList.add("step-dark"); if (i > 0 && i % 16 === 0) { const marker = document.createElement("div"); marker.className = "step-marker"; marker.textContent = Math.floor(i / 16) + 1; stepWrapper.appendChild(marker); } stepWrapper.appendChild(stepElement); sequencerContainer.appendChild(stepWrapper); } } }); } // Ilumina o step atual durante o playback export function highlightStep(stepIndex, isActive) { if (stepIndex < 0) return; document.querySelectorAll(".track-lane").forEach((track) => { const stepWrapper = track.querySelector(`.step-sequencer .step-wrapper:nth-child(${stepIndex + 1})`); if (stepWrapper) { const stepElement = stepWrapper.querySelector(".step"); if (stepElement) stepElement.classList.toggle("playing", isActive); } }); } // Atualiza UI de um step específico (chamado pelo socket) export function updateStepUI(trackIndex, patternIndex, stepIndex, isActive) { // Redesenhar tudo é mais seguro para garantir que filtros e views (piano vs steps) estejam corretos redrawSequencer(); } // Atualiza o dropdown de patterns na toolbar export function updateGlobalPatternSelector() { const sel = document.getElementById("global-pattern-selector"); if (!sel) return; const isFocused = !!appState.pattern.focusedBasslineId; // qualquer instrumento serve como referência de nomes/quantidade de patterns const referenceTrack = (appState.pattern.tracks || []).find( (t) => t.type !== "bassline" && Array.isArray(t.patterns) && t.patterns.length > 0 ); sel.innerHTML = ""; if (!referenceTrack) { const opt = document.createElement("option"); opt.textContent = "Sem patterns"; sel.appendChild(opt); sel.disabled = true; return; } // ✅ fora do foco: permite "limpar seleção" if (!isFocused) { const noneOpt = document.createElement("option"); noneOpt.value = ""; noneOpt.textContent = "Selecione uma pattern"; sel.appendChild(noneOpt); } referenceTrack.patterns.forEach((p, idx) => { const opt = document.createElement("option"); opt.value = String(idx); opt.textContent = p.name || `Pattern ${idx + 1}`; sel.appendChild(opt); }); // Define seleção atual const idx = appState.pattern.activePatternIndex; if (isFocused) { sel.value = String(Number.isInteger(idx) ? idx : 0); } else { sel.value = Number.isInteger(idx) ? String(idx) : ""; } sel.disabled = false; }