// 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"; import { initializeAudioContext } from "../audio.js"; const timerDisplay = document.getElementById("timer-display"); function formatTime(milliseconds) { const totalSeconds = Math.floor(milliseconds / 1000); const minutes = Math.floor(totalSeconds / 60) .toString() .padStart(2, "0"); const seconds = (totalSeconds % 60).toString().padStart(2, "0"); const centiseconds = Math.floor((milliseconds % 1000) / 10) .toString() .padStart(2, "0"); return `${minutes}:${seconds}:${centiseconds}`; } export function playMetronomeSound(isDownbeat) { initializeAudioContext(); const synth = new Tone.Synth().toDestination(); const freq = isDownbeat ? 1000 : 800; synth.triggerAttackRelease(freq, "8n", Tone.now()); } // 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 if (track && track.player) { 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; } } function tick() { if (!appState.global.isPlaying) { stopPlayback(); return; } const totalSteps = getTotalSteps(); const lastStepIndex = appState.global.currentStep === 0 ? totalSteps - 1 : appState.global.currentStep - 1; highlightStep(lastStepIndex, false); const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120; const stepInterval = (60 * 1000) / (bpm * 4); const currentTime = appState.global.currentStep * stepInterval; if (timerDisplay) { 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; if (appState.global.currentStep % stepsPerBeat === 0) { playMetronomeSound( appState.global.currentStep % (stepsPerBeat * 4) === 0 ); } } // Percorre tracks e toca o step atual se ativo appState.pattern.tracks.forEach((track) => { if (!track.patterns || track.patterns.length === 0) return; // 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); } }); highlightStep(appState.global.currentStep, true); appState.global.currentStep = (appState.global.currentStep + 1) % totalSteps; } export function startPlayback() { if (appState.global.isPlaying || appState.pattern.tracks.length === 0) return; initializeAudioContext(); if (appState.global.currentStep === 0) { rewindPlayback(); } const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120; Tone.Transport.bpm.value = bpm; const stepInterval = (60 * 1000) / (bpm * 4); if (appState.global.playbackIntervalId) clearInterval(appState.global.playbackIntervalId); appState.global.isPlaying = true; 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) { clearInterval(appState.global.playbackIntervalId); } appState.global.playbackIntervalId = null; appState.global.isPlaying = false; document .querySelectorAll(".step.playing") .forEach((s) => s.classList.remove("playing")); appState.global.currentStep = 0; if (timerDisplay) timerDisplay.textContent = "00:00:00"; const playBtn = document.getElementById("play-btn"); if (playBtn) { playBtn.classList.remove("fa-pause"); playBtn.classList.add("fa-play"); } } export function rewindPlayback() { const lastStep = appState.global.currentStep > 0 ? appState.global.currentStep - 1 : getTotalSteps() - 1; appState.global.currentStep = 0; if (!appState.global.isPlaying) { if (timerDisplay) timerDisplay.textContent = "00:00:00"; highlightStep(lastStep, false); } } export function togglePlayback() { initializeAudioContext(); if (appState.global.isPlaying) { stopPlayback(); } else { appState.global.currentStep = 0; startPlayback(); } } // ========================================================================= // FUNÇÃO CORRIGIDA v3: Renderizar o Pattern atual para um Blob de Áudio // ========================================================================= export async function renderActivePatternToBlob() { initializeAudioContext(); // Garante que o contexto de áudio principal existe // 1. Obter configs atuais const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120; const totalSteps = getTotalSteps(); const stepInterval = 60 / (bpm * 4); // Duração de 1 step (em segundos) const duration = totalSteps * stepInterval; // Duração total em segundos // 2. Descobrir qual pattern está ativo (assume que todos estão no mesmo) const activePatternIndex = appState.pattern.tracks[0]?.activePatternIndex || 0; // 3. Renderizar offline usando Tone.Offline const buffer = await Tone.Offline(async () => { // ---------------------------------------------------- // Contexto de Áudio OFFLINE // ---------------------------------------------------- const masterGain = new Tone.Gain().toDestination(); // --- INÍCIO DA CORREÇÃO (Lógica de Polifonia) --- // 1. Criamos as 'Parts'. const offlineTracksParts = appState.pattern.tracks .map((track) => { const pattern = track.patterns[activePatternIndex]; // Verificação crucial: Precisamos do 'track.buffer' (áudio carregado) if (!pattern || !track.buffer || !pattern.steps.includes(true)) { return null; // Pula trilha se não tiver áudio ou notas } // Obtém o buffer de áudio (que já está carregado) const trackBuffer = track.buffer; // Cria a cadeia de áudio (Volume/Pan) para esta *trilha* const panner = new Tone.Panner(track.pan).connect(masterGain); const volume = new Tone.Volume(Tone.gainToDb(track.volume)).connect( panner ); // Cria a lista de eventos (tempos em que as notas devem tocar) const events = []; pattern.steps.forEach((isActive, stepIndex) => { if (isActive) { const time = stepIndex * stepInterval; events.push(time); } }); // Cria a Tone.Part const part = new Tone.Part((time) => { // *** ESTA É A CORREÇÃO CRÍTICA *** // Para cada nota (cada 'time' na lista de 'events'), // nós criamos um PLAYER "ONE-SHOT" (descartável). // Isso permite que vários sons da mesma trilha // se sobreponham (polifonia). new Tone.Player(trackBuffer) // Usa o buffer carregado .connect(volume) // Conecta na cadeia de áudio (Volume->Pan->Master) .start(time); // Toca no tempo agendado }, events); // Passa a lista de tempos [0, 0.25, 0.5, ...] return part; // Retorna a Part (que sabe quando disparar) }) .filter((t) => t !== null); // Remove trilhas nulas // 2. Como estamos usando buffers já carregados, // não precisamos esperar (remover 'await Tone.loaded()') // 3. Agenda todas as 'Parts' para começar offlineTracksParts.forEach((part) => { part.start(0); }); // --- FIM DA CORREÇÃO --- // Define o BPM do transporte offline Tone.Transport.bpm.value = bpm; // Inicia o transporte (para a renderização) Tone.Transport.start(); // ---------------------------------------------------- }, duration); // Duração total da renderização // 5. Converte o AudioBuffer resultante em um Blob (arquivo .wav) const blob = bufferToWave(buffer); return blob; } // ========================================================================= // FUNÇÃO UTILITÁRIA: Converte AudioBuffer para Blob WAV // (Mantenha esta função como está) // ========================================================================= function bufferToWave(abuffer) { let numOfChan = abuffer.numberOfChannels; let length = abuffer.length * numOfChan * 2 + 44; let buffer = new ArrayBuffer(length); let view = new DataView(buffer); let channels = [], i, sample; let offset = 0; let pos = 0; // setAll e setString são helpers function setAll(data) { for (i = 0; i < data.length; i++) { view.setUint8(pos + i, data[i]); } pos += data.length; } function setString(s) { setAll(s.split("").map((c) => c.charCodeAt(0))); } // Cabeçalho WAV setString("RIFF"); view.setUint32(pos, length - 8, true); pos += 4; setString("WAVE"); setString("fmt "); view.setUint32(pos, 16, true); pos += 4; // Sub-chunk size view.setUint16(pos, 1, true); pos += 2; // Audio format 1 view.setUint16(pos, numOfChan, true); pos += 2; view.setUint32(pos, abuffer.sampleRate, true); pos += 4; view.setUint32(pos, abuffer.sampleRate * 2 * numOfChan, true); pos += 4; // Byte rate view.setUint16(pos, numOfChan * 2, true); pos += 2; // Block align view.setUint16(pos, 16, true); pos += 2; // Bits per sample setString("data"); view.setUint32(pos, length - 44, true); pos += 4; // Pega os dados dos canais for (i = 0; i < numOfChan; i++) { channels.push(abuffer.getChannelData(i)); } // Escreve os dados (intercalando canais) for (i = 0; i < abuffer.length; i++) { for (let j = 0; j < numOfChan; j++) { sample = Math.max(-1, Math.min(1, channels[j][i])); sample = (0.5 + sample * 32767.5) | 0; // Converte para 16-bit PCM view.setInt16(pos, sample, true); pos += 2; } } return new Blob([buffer], { type: "audio/wav" }); }