sync mode on

This commit is contained in:
JotaChina committed 2025-10-26 16:43:01 -03:00
1 parent 1818932244
commit 1174b328e5
415 files changed
+44294 -1182

No files matched your search

+46 -21
View File
@@ -1,4 +1,6 @@
// js/pattern_audio.js
import * as Tone from "https://esm.sh/tone";
import { appState } from "../state.js";
import { highlightStep } from "./pattern_ui.js";
import { getTotalSteps } from "../utils.js";
@@ -21,24 +23,39 @@ export function playMetronomeSound(isDownbeat) {
synth.triggerAttackRelease(freq, "8n", Tone.now());
}
// --- FUNÇÃO CORRIGIDA E EFICIENTE ---
// Dispara o sample de uma track, garantindo que o player esteja roteado corretamente
export function playSample(filePath, trackId) {
initializeAudioContext();
const track = trackId ? appState.pattern.tracks.find((t) => t.id == trackId) : null;
// Se a faixa existe e tem um player pré-carregado, apenas o dispara.
// Se a faixa existe e tem um player pré-carregado
if (track && track.player) {
// Atualiza o volume/pan caso tenham sido alterados
track.gainNode.gain.value = Tone.gainToDb(track.volume);
track.pannerNode.pan.value = track.pan;
// Dispara o som imediatamente. Esta operação é instantânea.
track.player.start(Tone.now());
}
// Fallback para preview de samples no navegador (sem trackId)
if (track.player.loaded) {
// Ajusta volume/pan sempre que tocar (robustez a alterações em tempo real)
if (track.volumeNode) {
track.volumeNode.volume.value =
track.volume === 0 ? -Infinity : Tone.gainToDb(track.volume);
}
if (track.pannerNode) {
track.pannerNode.pan.value = track.pan ?? 0;
}
// Garante conexão: player -> volumeNode (não usar mais gainNode)
try { track.player.disconnect(); } catch {}
if (track.volumeNode) {
track.player.connect(track.volumeNode);
}
// Dispara imediatamente
track.player.start(Tone.now());
} else {
console.warn(`Player da trilha "${track.name}" ainda não carregado — pulando este tick.`);
}
}
// Fallback para preview de sample sem trackId
else if (!trackId && filePath) {
const previewPlayer = new Tone.Player(filePath).toDestination();
previewPlayer.autostart = true;
const previewPlayer = new Tone.Player(filePath).toDestination();
previewPlayer.autostart = true;
}
}
@@ -59,6 +76,7 @@ function tick() {
timerDisplay.textContent = formatTime(currentTime);
}
// Metrônomo
if (appState.global.metronomeEnabled) {
const noteValue = parseInt(document.getElementById("compasso-b-input").value, 10) || 4;
const stepsPerBeat = 16 / noteValue;
@@ -67,12 +85,16 @@ function tick() {
}
}
// Percorre tracks e toca o step atual se ativo
appState.pattern.tracks.forEach((track) => {
if (!track.patterns || track.patterns.length === 0) return;
const activePattern = track.patterns[appState.pattern.activePatternIndex];
if (activePattern && activePattern.steps[appState.global.currentStep] && track.samplePath) {
// IMPORTANTE: usar o pattern ativo da PRÓPRIA TRILHA
const activePattern = track.patterns[track.activePatternIndex];
if (activePattern &&
activePattern.steps[appState.global.currentStep] &&
track.samplePath) {
playSample(track.samplePath, track.id);
}
});
@@ -84,9 +106,9 @@ function tick() {
export function startPlayback() {
if (appState.global.isPlaying || appState.pattern.tracks.length === 0) return;
initializeAudioContext();
if (appState.global.currentStep === 0) {
rewindPlayback();
rewindPlayback();
}
const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120;
@@ -96,15 +118,18 @@ export function startPlayback() {
if (appState.global.playbackIntervalId) clearInterval(appState.global.playbackIntervalId);
appState.global.isPlaying = true;
document.getElementById("play-btn").classList.remove("fa-play");
document.getElementById("play-btn").classList.add("fa-pause");
const playBtn = document.getElementById("play-btn");
if (playBtn) {
playBtn.classList.remove("fa-play");
playBtn.classList.add("fa-pause");
}
tick();
appState.global.playbackIntervalId = setInterval(tick, stepInterval);
}
export function stopPlayback() {
if(appState.global.playbackIntervalId) {
if (appState.global.playbackIntervalId) {
clearInterval(appState.global.playbackIntervalId);
}
appState.global.playbackIntervalId = null;
@@ -138,4 +163,4 @@ export function togglePlayback() {
appState.global.currentStep = 0;
startPlayback();
}
}
}
@@ -0,0 +1,108 @@
// js/pattern/pattern_bounce.js
import { appState } from '../state.js';
import { getSecondsPerStep } from '../utils.js';
import { addAudioClipToTimeline, addAudioTrackLane } from '../audio/audio_state.js';
import { getAudioContext } from '../audio.js';
import { renderAudioEditor } from '../audio/audio_ui.js';
/**
* Renderiza (bounce) o pattern de beat atualmente ativo para uma nova pista de áudio.
*/
export async function bounceActivePatternToAudio() {
console.log("Iniciando 'bounce' do pattern...");
// 1. Encontrar o pattern ativo
const activeTrackId = appState.pattern.activeTrackId;
// --- DEBUG ---
console.log(`[DEBUG bounce] activeTrackId lido do estado: ${activeTrackId}`);
const activeTrack = appState.pattern.tracks.find(t => t.id === activeTrackId);
// --- DEBUG ---
if (activeTrack) {
console.log(`[DEBUG bounce] Pista ativa encontrada:`, activeTrack.name);
console.log(`[DEBUG bounce] Verificando track.buffer:`, activeTrack.buffer);
} else {
console.error(`[DEBUG bounce] NENHUMA PISTA ATIVA ENCONTRADA. activeTrackId é nulo ou inválido.`);
console.log(`[DEBUG bounce] Pistas disponíveis no estado:`, appState.pattern.tracks.map(t => ({id: t.id, name: t.name})));
}
// --- FIM DEBUG ---
if (!activeTrack) {
alert('Nenhuma pista de pattern selecionada para renderizar.');
return;
}
const activePattern = activeTrack.patterns[activeTrack.activePatternIndex];
if (!activePattern) {
alert('Nenhum pattern ativo encontrado na pista.');
return;
}
const trackBuffer = activeTrack.buffer;
if (!trackBuffer) {
alert('O áudio (sample) desta pista ainda não foi carregado.');
return;
}
// 2. Calcular a duração do pattern
const steps = activePattern.steps;
const totalSteps = steps.length;
const secondsPerStep = getSecondsPerStep(); //
const duration = totalSteps * secondsPerStep;
if (duration <= 0) {
alert("Pattern está vazio ou com duração zero.");
return;
}
try {
// 3. Usar Tone.Offline para renderizar o áudio
const audioBuffer = await Tone.Offline(async (offlineCtx) => {
const gainNode = new Tone.Gain(Tone.gainToDb(activeTrack.volume)).connect(offlineCtx.destination);
const pannerNode = new Tone.Panner(activeTrack.pan).connect(gainNode); //
const now = offlineCtx.currentTime;
// --- INÍCIO DA CORREÇÃO (que estava na versão anterior) ---
const offlineBuffer = offlineCtx.createBuffer(
trackBuffer.numberOfChannels,
trackBuffer.length,
trackBuffer.sampleRate
);
for (let i = 0; i < trackBuffer.numberOfChannels; i++) {
offlineBuffer.copyToChannel(trackBuffer.getChannelData(i), i);
}
// --- FIM DA CORREÇÃO ---
// Agendar todas as notas (steps)
steps.forEach((isActive, index) => {
if (isActive) {
const time = now + (index * secondsPerStep);
const source = new Tone.BufferSource(offlineBuffer).connect(pannerNode);
source.start(time);
}
});
}, duration);
// 4. Áudio renderizado. Agora, adicione-o ao editor de áudio.
addAudioTrackLane(); //
const newTrack = appState.audio.tracks[appState.audio.tracks.length - 1]; //
const newTrackId = newTrack.id;
const clipName = `${activeTrack.name}_(${activePattern.name})`;
addAudioClipToTimeline(null, newTrackId, 0, clipName, audioBuffer); //
console.log("Pattern renderizado com sucesso!");
} catch (err) {
console.error("Erro ao renderizar o pattern:", err);
alert(`Erro ao renderizar pattern: ${err.message}`);
}
}
+101 -50
View File
@@ -1,97 +1,148 @@
// js/pattern_state.js
import * as Tone from "https://esm.sh/tone";
import { appState } from "../state.js";
import { DEFAULT_VOLUME, DEFAULT_PAN } from "../config.js";
import { getAudioContext, getMainGainNode } from "../audio.js";
import { renderPatternEditor } from "./pattern_ui.js";
import { getMainGainNode } from "../audio.js";
import { getTotalSteps } from "../utils.js";
const initialState = {
tracks: [],
activeTrackId: null,
activePatternIndex: 0,
};
export let patternState = { ...initialState };
export function initializePatternState() {
Object.assign(patternState, initialState, { tracks: [] });
// Limpa players/buffers existentes
appState.pattern.tracks.forEach(track => {
try { track.player?.dispose(); } catch {}
try { track.buffer?.dispose?.(); } catch {}
});
// Reseta estado do editor de pattern
appState.pattern.tracks = [];
appState.pattern.activeTrackId = null;
appState.pattern.activePatternIndex = 0;
}
// --- FUNÇÃO CORRIGIDA ---
// Agora, esta função cria e pré-carrega um Tone.Player para a faixa.
export async function loadAudioForTrack(track) {
if (!track.samplePath) return track;
try {
// Se já existir um player antigo, o descartamos para liberar memória.
if (track.player) {
track.player.dispose();
}
// Cria um novo Tone.Player e o conecta à cadeia de áudio da faixa.
// O 'await' garante que o áudio seja totalmente carregado antes de prosseguirmos.
track.player = await new Tone.Player(track.samplePath).toDestination();
track.player.chain(track.gainNode, track.pannerNode, getMainGainNode());
} catch (error) {
console.error(`Falha ao carregar áudio para a trilha ${track.name}:`, error);
// Descartar player/buffer anteriores com segurança
try { track.player?.dispose(); } catch {}
track.player = null;
try { track.buffer?.dispose?.(); } catch {}
track.buffer = null;
// Garante nós de volume/pan (Opção B: Volume em dB)
if (!track.volumeNode) {
track.volumeNode = new Tone.Volume(
track.volume === 0 ? -Infinity : Tone.gainToDb(track.volume)
);
} else {
track.volumeNode.volume.value =
track.volume === 0 ? -Infinity : Tone.gainToDb(track.volume);
}
if (!track.pannerNode) {
track.pannerNode = new Tone.Panner(track.pan ?? 0);
} else {
track.pannerNode.pan.value = track.pan ?? 0;
}
// Encadeia: Volume(dB) -> Panner -> Master
try { track.volumeNode.disconnect(); } catch {}
try { track.pannerNode.disconnect(); } catch {}
track.volumeNode.connect(track.pannerNode);
track.pannerNode.connect(getMainGainNode());
// Cria e carrega o Player
const player = new Tone.Player({ url: track.samplePath, autostart: false });
await player.load(track.samplePath); // garante buffer carregado
// Conecta o player ao volumeNode
player.connect(track.volumeNode);
// Buffer separado (se você usar waveform em outro lugar)
const buffer = new Tone.Buffer();
await buffer.load(track.samplePath);
// Atribuições finais
track.player = player;
track.buffer = buffer;
} catch (error) {
console.error('Erro ao carregar sample:', track.samplePath);
console.error(`Falha ao carregar áudio para a trilha "${track.name}":`, error);
try { track.player?.dispose(); } catch {}
try { track.buffer?.dispose?.(); } catch {}
track.player = null;
track.buffer = null;
}
return track;
}
export function addTrackToState() {
const mainGainNode = getMainGainNode();
const totalSteps = getTotalSteps();
const referenceTrack = patternState.tracks[0];
const referenceTrack = appState.pattern.tracks[0];
const newTrack = {
id: Date.now(),
id: Date.now() + Math.random(),
name: "novo instrumento",
samplePath: null,
player: null, // <-- ADICIONADO: O player começará como nulo
patterns: referenceTrack
? referenceTrack.patterns.map(p => ({ name: p.name, steps: new Array(p.steps.length).fill(false), pos: p.pos }))
player: null,
buffer: null,
patterns: referenceTrack
? referenceTrack.patterns.map(p => ({
name: p.name,
steps: new Array(p.steps.length).fill(false),
pos: p.pos
}))
: [{ name: "Pattern 1", steps: new Array(totalSteps).fill(false), pos: 0 }],
activePatternIndex: 0,
volume: DEFAULT_VOLUME,
pan: DEFAULT_PAN,
gainNode: new Tone.Gain(Tone.gainToDb(DEFAULT_VOLUME)),
// Opção B: controlar volume em dB
volumeNode: new Tone.Volume(Tone.gainToDb(DEFAULT_VOLUME)),
pannerNode: new Tone.Panner(DEFAULT_PAN),
};
newTrack.gainNode.chain(newTrack.pannerNode, mainGainNode);
// Cadeia de áudio nova
newTrack.volumeNode.connect(newTrack.pannerNode);
newTrack.pannerNode.connect(getMainGainNode());
patternState.tracks.push(newTrack);
renderPatternEditor();
appState.pattern.tracks.push(newTrack);
appState.pattern.activeTrackId = newTrack.id;
}
export function removeLastTrackFromState() {
if (patternState.tracks.length > 0) {
const trackToRemove = patternState.tracks[patternState.tracks.length - 1];
if (trackToRemove.player) trackToRemove.player.dispose();
if (trackToRemove.pannerNode) trackToRemove.pannerNode.dispose();
if (trackToRemove.gainNode) trackToRemove.gainNode.dispose();
patternState.tracks.pop();
renderPatternEditor();
if (appState.pattern.tracks.length > 0) {
const trackToRemove = appState.pattern.tracks[appState.pattern.tracks.length - 1];
try { trackToRemove.player?.dispose(); } catch {}
try { trackToRemove.buffer?.dispose?.(); } catch {}
try { trackToRemove.pannerNode?.disconnect(); } catch {}
try { trackToRemove.volumeNode?.disconnect(); } catch {}
appState.pattern.tracks.pop();
if (appState.pattern.activeTrackId === trackToRemove.id) {
appState.pattern.activeTrackId = appState.pattern.tracks[0]?.id ?? null;
}
}
}
export async function updateTrackSample(trackId, samplePath) {
const track = patternState.tracks.find((t) => t.id == trackId);
export async function updateTrackSample(trackIndex, samplePath) {
const track = appState.pattern.tracks[trackIndex];
if (track) {
track.samplePath = samplePath;
track.name = samplePath.split("/").pop();
await loadAudioForTrack(track); // Carrega o novo player
renderPatternEditor();
// (re)carrega e reconecta corretamente o player nesta trilha
await loadAudioForTrack(track);
}
}
export function toggleStepState(trackId, stepIndex) {
const track = patternState.tracks.find((t) => t.id == trackId);
export function toggleStepState(trackIndex, stepIndex) {
const track = appState.pattern.tracks[trackIndex];
if (track && track.patterns && track.patterns.length > 0) {
const activePattern = track.patterns[track.activePatternIndex];
if (activePattern && activePattern.steps.length > stepIndex) {
activePattern.steps[stepIndex] = !activePattern.steps[stepIndex];
}
}
}
}
+187 -140
View File
@@ -1,171 +1,218 @@
// js/pattern_ui.js
import { appState } from "../state.js";
import {
toggleStepState,
updateTrackSample
    updateTrackSample
} from "./pattern_state.js";
import { playSample, stopPlayback } from "./pattern_audio.js"; // Será criado no próximo passo
import { playSample, stopPlayback } from "./pattern_audio.js";
import { getTotalSteps } from "../utils.js";
import { sendAction } from '../socket.js';
import { initializeAudioContext } from '../audio.js';
// Função principal de renderização para o editor de patterns
export function renderPatternEditor() {
const trackContainer = document.getElementById("track-container");
trackContainer.innerHTML = "";
  const trackContainer = document.getElementById("track-container");
  trackContainer.innerHTML = "";
appState.pattern.tracks.forEach((trackData) => {
const trackLane = document.createElement("div");
trackLane.className = "track-lane";
trackLane.dataset.trackId = trackData.id;
// (V7) Adicionado 'trackIndex'
  appState.pattern.tracks.forEach((trackData, trackIndex) => {
    const trackLane = document.createElement("div");
    trackLane.className = "track-lane";
    trackLane.dataset.trackIndex = trackIndex; // (V7) Usando índice
if (trackData.id === appState.pattern.activeTrackId) {
trackLane.classList.add('active-track');
}
    if (trackData.id === appState.pattern.activeTrackId) {
        trackLane.classList.add('active-track');
    }
trackLane.innerHTML = `
<div class="track-info">
<i class="fa-solid fa-gear"></i>
<div class="track-mute"></div>
<span class="track-name">${trackData.name}</span>
</div>
<div class="track-controls">
<div class="knob-container">
<div class="knob" data-control="volume" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
<span>VOL</span>
</div>
<div class="knob-container">
<div class="knob" data-control="pan" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
<span>PAN</span>
</div>
</div>
<div class="step-sequencer-wrapper"></div>
`;
    trackLane.innerHTML = `
      <div class="track-info">
        <i class="fa-solid fa-gear"></i>
        <div class="track-mute"></div>
        <span class="track-name">${trackData.name}</span>
      </div>
      <div class="track-controls">
        <div class="knob-container">
          <div class="knob" data-control="volume" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
          <span>VOL</span>
        </div>
        <div class="knob-container">
          <div class="knob" data-control="pan" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
          <span>PAN</span>
        </div>
      </div>
      <div class="step-sequencer-wrapper"></div>
    `;
trackLane.addEventListener('click', () => {
if (appState.pattern.activeTrackId === trackData.id) return;
stopPlayback();
appState.pattern.activeTrackId = trackData.id;
document.querySelectorAll('.track-lane').forEach(lane => lane.classList.remove('active-track'));
trackLane.classList.add('active-track');
updateGlobalPatternSelector();
redrawSequencer();
});
// (Listener de clique da track é local, sem mudanças)
    trackLane.addEventListener('click', () => {
        if (appState.pattern.activeTrackId === trackData.id) return;
        stopPlayback();
        appState.pattern.activeTrackId = trackData.id;
        document.querySelectorAll('.track-lane').forEach(lane => lane.classList.remove('active-track'));
        trackLane.classList.add('active-track');
        updateGlobalPatternSelector();
        redrawSequencer();
    });
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) {
updateTrackSample(trackData.id, filePath);
}
});
    trackLane.addEventListener("dragover", (e) => { e.preventDefault(); trackLane.classList.add("drag-over"); });
    trackLane.addEventListener("dragleave", () => trackLane.classList.remove("drag-over"));
trackContainer.appendChild(trackLane);
// A lógica dos knobs precisará ser reimplementada ou movida para um arquivo de componentes
});
updateGlobalPatternSelector();
redrawSequencer();
// (V9) Listener de "drop" (arrastar) agora usa 'sendAction'
    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: trackIndex,
filePath: filePath
});
      }
    });
    trackContainer.appendChild(trackLane);
  });
 
  updateGlobalPatternSelector();
  redrawSequencer();
}
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);
}
const parentTrackElement = wrapper.closest(".track-lane");
const trackId = parentTrackElement.dataset.trackId;
const trackData = appState.pattern.tracks.find((t) => t.id == trackId);
  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);
    }
   
    const parentTrackElement = wrapper.closest(".track-lane");
    const trackIndex = parseInt(parentTrackElement.dataset.trackIndex, 10); // (V7)
    // ... dentro da função redrawSequencer() ...
if (!trackData || !trackData.patterns || trackData.patterns.length === 0) {
sequencerContainer.innerHTML = ""; return;
}
    const trackData = appState.pattern.tracks[trackIndex];
const activePattern = trackData.patterns[appState.pattern.activePatternIndex];
if (!activePattern) {
sequencerContainer.innerHTML = ""; return;
}
const patternSteps = activePattern.steps;
    if (!trackData || !trackData.patterns || trackData.patterns.length === 0) {
      sequencerContainer.innerHTML = ""; return;
    }
sequencerContainer.innerHTML = "";
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");
}
// --- CORRIJA ESTAS DUAS LINHAS ---
// ANTES:
// const activePatternIndex = appState.pattern.activePatternIndex;
// const activePattern = trackData.patterns[activePatternIndex];
//
// DEPOIS:
    const activePatternIndex = trackData.activePatternIndex;
    const activePattern = trackData.patterns[activePatternIndex];
stepElement.addEventListener("click", () => {
toggleStepState(trackData.id, i);
stepElement.classList.toggle("active");
if (trackData && trackData.samplePath) {
playSample(trackData.samplePath, trackData.id);
}
});
    if (!activePattern) {
        sequencerContainer.innerHTML = ""; return;
    }
// ... resto da função ...
    const patternSteps = activePattern.steps;
const beatsPerBar = parseInt(document.getElementById("compasso-a-input").value, 10) || 4;
const groupIndex = Math.floor(i / beatsPerBar);
if (groupIndex % 2 === 0) {
stepElement.classList.add("step-dark");
}
    sequencerContainer.innerHTML = "";
    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");
      }
const stepsPerBar = 16;
if (i > 0 && i % stepsPerBar === 0) {
const marker = document.createElement("div");
marker.className = "step-marker";
marker.textContent = Math.floor(i / stepsPerBar) + 1;
stepWrapper.appendChild(marker);
}
stepWrapper.appendChild(stepElement);
sequencerContainer.appendChild(stepWrapper);
}
});
      stepElement.addEventListener("click", () => {
initializeAudioContext(); // (V8)
const currentState = activePattern.steps[i] || false;
const isActive = !currentState;
sendAction({ // (V7)
type: 'TOGGLE_NOTE',
trackIndex: trackIndex,
patternIndex: activePatternIndex,
stepIndex: i,
isActive: isActive
});
        if (isActive && trackData && trackData.samplePath) {
          playSample(trackData.samplePath, trackData.id);
        }
      });
      const beatsPerBar = parseInt(document.getElementById("compasso-a-input").value, 10) || 4;
      const groupIndex = Math.floor(i / beatsPerBar);
      if (groupIndex % 2 === 0) {
        stepElement.classList.add("step-dark");
      }
      const stepsPerBar = 16;
      if (i > 0 && i % stepsPerBar === 0) {
        const marker = document.createElement("div");
        marker.className = "step-marker";
        marker.textContent = Math.floor(i / stepsPerBar) + 1;
        stepWrapper.appendChild(marker);
      }
     
      stepWrapper.appendChild(stepElement);
      sequencerContainer.appendChild(stepWrapper);
    }
  });
}
export function updateGlobalPatternSelector() {
const globalPatternSelector = document.getElementById('global-pattern-selector');
if (!globalPatternSelector) return;
    const globalPatternSelector = document.getElementById('global-pattern-selector');
    if (!globalPatternSelector) return;
const referenceTrack = appState.pattern.tracks[0];
globalPatternSelector.innerHTML = '';
if (referenceTrack && referenceTrack.patterns.length > 0) {
referenceTrack.patterns.forEach((pattern, index) => {
const option = document.createElement('option');
option.value = index;
option.textContent = pattern.name;
globalPatternSelector.appendChild(option);
});
globalPatternSelector.selectedIndex = appState.pattern.activePatternIndex;
globalPatternSelector.disabled = false;
} else {
const option = document.createElement('option');
option.textContent = 'Sem patterns';
globalPatternSelector.appendChild(option);
globalPatternSelector.disabled = true;
}
    const referenceTrack = appState.pattern.tracks[0];
    globalPatternSelector.innerHTML = '';
    if (referenceTrack && referenceTrack.patterns.length > 0) {
        referenceTrack.patterns.forEach((pattern, index) => {
            const option = document.createElement('option');
            option.value = index;
            option.textContent = pattern.name;
            globalPatternSelector.appendChild(option);
        });
        globalPatternSelector.selectedIndex = appState.pattern.activePatternIndex;
        globalPatternSelector.disabled = false;
    } else {
        const option = document.createElement('option');
        option.textContent = 'Sem patterns';
        globalPatternSelector.appendChild(option);
        globalPatternSelector.disabled = true;
    }
}
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);
}
}
});
  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);
      }
    }
  });
}
// (V7) Função de UI "cirúrgica"
export function updateStepUI(trackIndex, patternIndex, stepIndex, isActive) {
if (patternIndex !== appState.pattern.activePatternIndex) {
return;
}
const trackElement = document.querySelector(`.track-lane[data-track-index="${trackIndex}"]`);
if (!trackElement) return;
const stepWrapper = trackElement.querySelector(
`.step-sequencer .step-wrapper:nth-child(${stepIndex + 1})`
);
if (!stepWrapper) return;
const stepElement = stepWrapper.querySelector(".step");
if (!stepElement) return;
stepElement.classList.toggle("active", isActive);
}