// 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"; import { TripleOscillator } from "../../audio/plugins/TripleOscillator.js"; import { Nes } from "../../audio/plugins/Nes.js"; import { SuperSaw } from "../../audio/plugins/SuperSaw.js"; import { Lb302 } from "../../audio/plugins/Lb302.js"; import { Kicker } from "../../audio/plugins/Kicker.js"; function getActivePatternForTrack(track) { const idx = appState.pattern?.activePatternIndex ?? track.activePatternIndex ?? 0; return track.patterns?.[idx] ?? null; } const TICKS_PER_STEP = 12; // LMMS: 12 ticks por 1/16 const STEPS_PER_BAR = 16; // 4/4 em 1/16 // Mapa para facilitar a criação dinâmica const PLUGIN_CLASSES = { tripleoscillator: TripleOscillator, nes: Nes, supersaw: SuperSaw, lb302: Lb302, kicker: Kicker, }; const timerDisplay = document.getElementById("timer-display"); // Variável para armazenar as "Parts" (sequências melódicas) do Tone.js let activeParts = []; // ===================================================== // Proteção: não “vazar” loop do Pattern Editor pro Song // ===================================================== let _transportLoopSnapshot = null; function snapshotTransportLoopOnce() { if (_transportLoopSnapshot) return; _transportLoopSnapshot = { loop: Tone.Transport.loop, loopStart: Tone.Transport.loopStart, loopEnd: Tone.Transport.loopEnd, }; } function restoreTransportLoop() { if (!_transportLoopSnapshot) return; Tone.Transport.loop = _transportLoopSnapshot.loop; Tone.Transport.loopStart = _transportLoopSnapshot.loopStart; Tone.Transport.loopEnd = _transportLoopSnapshot.loopEnd; _transportLoopSnapshot = null; } let currentStep = 0; function updateStepHighlight(step) { // usa a função já existente do seu pattern_ui highlightStep(step, true); setTimeout(() => highlightStep(step, false), 60); } 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 track existe e tem player/preload if (track && (track.previewPlayer || track.player)) { const playerToUse = track.previewPlayer || track.player; if (playerToUse.loaded) { // Atualiza volume/pan ao tocar 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; } // roteia playerToUse -> volumeNode try { playerToUse.disconnect(); } catch {} if (track.volumeNode) playerToUse.connect(track.volumeNode); // Dispara (preview não interfere no player da playlist) try { playerToUse.start(Tone.now()); } catch (e) { console.warn("Falha ao tocar preview/sample:", track.name, e); } } else { console.warn(`Player da trilha "${track.name}" ainda não carregado — pulando.`); } return; } // Fallback: preview sem trackId if (!trackId && filePath) { const previewPlayer = new Tone.Player(filePath).toDestination(); previewPlayer.autostart = true; } } function playSamplerNoteAtTime(track, midi, time, durationSec) { if (!track?.buffer || !track.volumeNode) return; const base = track.baseNote ?? 60; const semitones = (midi - base); const rate = Math.pow(2, semitones / 12); const player = new Tone.Player(track.buffer); player.playbackRate = rate; player.connect(track.volumeNode); player.start(time); // se quiser respeitar duração (bem básico) if (durationSec && durationSec > 0) { player.stop(time + durationSec); } // limpeza player.onstop = () => player.dispose(); } let stepEventId = null; function tick(time) { const totalSteps = getTotalSteps(); updateStepHighlight(currentStep); appState.pattern.tracks.forEach((track) => { const pat = getActivePatternForTrack(track); if (!pat) return; // Se for plugin/sampler e tem piano roll, ele já está agendado via schedulePianoRoll() const hasNotes = Array.isArray(pat.notes) && pat.notes.length > 0; if (hasNotes) return; // Step sequencer (one-shots) if (pat.steps?.[currentStep] && track.type === "sampler" && track.buffer) { // sem midi -> toca “base” (drum one-shot) playSamplerNoteAtTime(track, track.baseNote ?? 60, time, null); } }); currentStep = (currentStep + 1) % totalSteps; } export function startPlayback() { if (appState.global.isPlaying) return; appState.global.isPlaying = true; currentStep = 0; Tone.Transport.stop(); Tone.Transport.cancel(); stopScheduledPianoRoll(); schedulePianoRoll(); stepEventId = Tone.Transport.scheduleRepeat(tick, "16n"); Tone.Transport.start(); } export function stopPlayback(rewind = true) { if (!appState.global.isPlaying) return; appState.global.isPlaying = false; if (stepEventId) { Tone.Transport.clear(stepEventId); stepEventId = null; } Tone.Transport.stop(); Tone.Transport.cancel(); stopScheduledPianoRoll(); restoreTransportLoop(); // ✅ Pattern Editor: para apenas o preview (não mexe no track.player da playlist) appState.pattern.tracks.forEach((track) => { try { track.previewPlayer?.stop(); } catch {} }); if (rewind) { currentStep = 0; updateStepHighlight(currentStep); } } export function rewindPlayback() { const lastStep = appState.global.currentStep > 0 ? appState.global.currentStep - 1 : getTotalSteps() - 1; appState.global.currentStep = 0; Tone.Transport.position = 0; // Reseta o tempo do Tone.js 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(); } } // 2. Agendador de Piano Roll (Melodia) export function schedulePianoRoll() { stopScheduledPianoRoll(); // Limpa agendamentos anteriores const bpm = parseFloat(document.getElementById("bpm-input").value) || 120; const stepSec = 60 / (bpm * 4); // 1/16 // LMMS: 1 bar (4/4) = 192 ticks, 1 step (1/16) = 12 ticks :contentReference[oaicite:3]{index=3} const TICKS_PER_STEP = 12; const TICKS_PER_BAR = 192; // 1) Descobrir quantos compassos são necessários (maior nota “end”) let barsNeeded = parseInt(document.getElementById("bars-input")?.value, 10) || 1; appState.pattern.tracks.forEach((track) => { const pat = getActivePatternForTrack(track); if (!pat?.notes?.length) return; let maxEndTick = 0; pat.notes.forEach((n) => { const end = (n.pos ?? 0) + (n.len ?? 0); if (end > maxEndTick) maxEndTick = end; }); const barsForThis = Math.max(1, Math.ceil(maxEndTick / TICKS_PER_BAR)); if (barsForThis > barsNeeded) barsNeeded = barsForThis; }); // 2) Sincronizar UI + Transport loop com esse tamanho const barsInput = document.getElementById("bars-input"); if (barsInput) { barsInput.value = String(barsNeeded); barsInput.dispatchEvent(new Event("input", { bubbles: true })); } snapshotTransportLoopOnce(); Tone.Transport.loop = true; Tone.Transport.loopStart = 0; Tone.Transport.loopEnd = `${barsNeeded}m`; // 3) Agendar notas (plugins + samplers) appState.pattern.tracks.forEach((track) => { const pat = getActivePatternForTrack(track); if (!pat?.notes?.length) return; // plugin -> track.instrument // sampler -> track.buffer const canPlay = (track.type === "plugin" && track.instrument) || (track.type === "sampler" && track.buffer); if (!canPlay) return; const events = pat.notes.map((note) => { const posSteps = (note.pos ?? 0) / TICKS_PER_STEP; const durSteps = (note.len ?? TICKS_PER_STEP) / TICKS_PER_STEP; return { time: posSteps * stepSec, midi: note.key, duration: Math.max(stepSec / 4, durSteps * stepSec), velocity: (note.vol ?? 100) / 100, }; }); const part = new Tone.Part((time, value) => { if (track.type === "sampler") { playSamplerNoteAtTime(track, value.midi, time, value.duration); } else { const freq = Tone.Frequency(value.midi, "midi").toFrequency(); track.instrument.triggerAttackRelease(freq, value.duration, time, value.velocity); } }, events).start(0); part.loop = true; part.loopEnd = `${barsNeeded}m`; activeParts.push(part); }); } function stopScheduledPianoRoll() { activeParts.forEach((p) => { try { p.stop(); } catch {} try { p.dispose(); } catch {} }); activeParts = []; } // ========================================================================= // Renderizar o Pattern atual para um Blob de Áudio // ========================================================================= export async function renderActivePatternToBlob() { initializeAudioContext(); const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120; // ========================================================= // 1. CÁLCULO DE DURAÇÃO INTELIGENTE // ========================================================= const stepInterval = 60 / (bpm * 4); const activePatternIndex = appState.pattern.tracks[0]?.activePatternIndex || 0; let maxStepFound = getTotalSteps(); // Mínimo: tamanho da tela // Varre todas as tracks para achar a última nota ou step appState.pattern.tracks.forEach((track) => { const p = track.patterns[activePatternIndex]; if (!p) return; // A. Steps (Bateria) if (p.steps && p.steps.includes(true)) { const lastIdx = p.steps.lastIndexOf(true); if (lastIdx + 1 > maxStepFound) maxStepFound = lastIdx + 1; } // B. Notas (Piano Roll) - Assumindo 192 ticks/beat e steps de 1/16 (48 ticks) if (p.notes && p.notes.length > 0) { p.notes.forEach((n) => { const endTick = n.pos + n.len; const endStep = Math.ceil(endTick / 48); if (endStep > maxStepFound) maxStepFound = endStep; }); } }); // Arredonda para o próximo compasso cheio (múltiplo de 16) const stepsPerBar = 16; const totalSteps = Math.ceil(maxStepFound / stepsPerBar) * stepsPerBar; const duration = totalSteps * stepInterval; // ========================================================= // 2. RENDERIZAÇÃO OFFLINE // ========================================================= const buffer = await Tone.Offline(async ({ transport }) => { const masterGain = new Tone.Gain().toDestination(); // Loop por cada trilha do projeto appState.pattern.tracks.forEach((track) => { const pattern = track.patterns[activePatternIndex]; // Se não tem pattern, ou se é uma track muda/vazia, pula if (!pattern || track.muted) return; // Verifica se tem conteúdo (buffer de áudio OU notas MIDI OU steps ativos) const hasAudio = track.buffer; const hasNotes = pattern.notes && pattern.notes.length > 0; const hasSteps = pattern.steps && pattern.steps.includes(true); if (!hasAudio && !hasNotes && !hasSteps) return; // Cria canal de volume/pan para essa track no mundo Offline const panner = new Tone.Panner(track.pan || 0).connect(masterGain); const volume = new Tone.Volume( track.volume === 0 ? -100 : Tone.gainToDb(track.volume) ).connect(panner); // --- CENÁRIO A: É um SAMPLE (Áudio gravado) --- if (track.samplePath && track.buffer) { // Lógica original de steps para samples if (pattern.steps) { const events = []; pattern.steps.forEach((isActive, stepIndex) => { if (isActive) events.push(stepIndex * stepInterval); }); if (events.length > 0) { new Tone.Part((time) => { new Tone.Player(track.buffer).connect(volume).start(time); }, events).start(0); } } } // --- CENÁRIO B: É um PLUGIN (Sintetizador) --- else if (track.type === "plugin") { // Normaliza o nome (ex: "TripleOscillator" -> "tripleoscillator") // Tenta pegar o nome da propriedade 'pluginName', 'instrument.name' ou do próprio objeto params const pluginName = ( track.pluginName || track.instrument?.constructor?.name || "" ).toLowerCase(); const PluginClass = PLUGIN_CLASSES[pluginName]; if (PluginClass) { // INSTANCIA O PLUGIN NO MUNDO OFFLINE // Passamos 'track.params' ou 'track.pluginData' (ajuste conforme seu appState salva os dados) const instrumentInstance = new PluginClass( null, track.params || track.pluginData || {} ); // Conecta na cadeia de áudio offline instrumentInstance.connect(volume); // 1. Agendar Notas do Piano Roll if (hasNotes) { const events = pattern.notes.map((note) => ({ time: 0 + note.pos * (48 / 192) * stepInterval, // Conversão aproximada Ticks -> Segundos // Se quiser precisão exata do Tone, use: note.pos * (Tone.Transport.PPQ / 192) / Tone.Transport.PPQ midi: note.key, duration: (note.len / 192) * (60 / bpm), // Duração em segundos velocity: (note.vol || 100) / 100, })); new Tone.Part((time, val) => { const freq = Tone.Frequency(val.midi, "midi"); instrumentInstance.triggerAttackRelease(freq, val.duration, time); }, events).start(0); } // 2. Agendar Steps (Caso use o TripleOscillator como bateria/efeito no step sequencer) else if (hasSteps) { const stepEvents = []; pattern.steps.forEach((isActive, idx) => { if (isActive) stepEvents.push(idx * stepInterval); }); new Tone.Part((time) => { // Toca um C5 padrão para steps sem nota definida instrumentInstance.triggerAttackRelease( Tone.Frequency("C5"), 0.1, time ); }, stepEvents).start(0); } } else { console.warn( `Render: Plugin não suportado ou não encontrado: ${pluginName}` ); } } }); // Configura e inicia o Transport Offline transport.bpm.value = bpm; transport.start(); }, duration); const blob = bufferToWave(buffer); return blob; } // ========================================================================= // FUNÇÃO UTILITÁRIA: Converte AudioBuffer para Blob WAV // ========================================================================= 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; 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))); } setString("RIFF"); view.setUint32(pos, length - 8, true); pos += 4; setString("WAVE"); setString("fmt "); view.setUint32(pos, 16, true); pos += 4; view.setUint16(pos, 1, true); pos += 2; 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; view.setUint16(pos, numOfChan * 2, true); pos += 2; view.setUint16(pos, 16, true); pos += 2; setString("data"); view.setUint32(pos, length - 44, true); pos += 4; for (i = 0; i < numOfChan; i++) { channels.push(abuffer.getChannelData(i)); } 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; view.setInt16(pos, sample, true); pos += 2; } } return new Blob([buffer], { type: "audio/wav" }); } // =============================== // Song/Playlist Pattern Scheduler // (toca patterns arranjadas na Playlist) // =============================== const LMMS_TICKS_PER_STEP = 12; function ticksToSec(ticks, stepIntervalSec) { // stepIntervalSec = duração de 1 step (1/16) em segundos // LMMS_TICKS_PER_STEP = 12 ticks por 1/16 (porque 48 ticks por semínima e 192 por compasso em 4/4) return (Number(ticks) / LMMS_TICKS_PER_STEP) * stepIntervalSec; } let songPatternScheduleId = null; export function startSongPatternPlaybackOnTransport() { initializeAudioContext(); if (songPatternScheduleId !== null) return; songPatternScheduleId = Tone.Transport.scheduleRepeat((time) => { const bpm = parseInt(document.getElementById("bpm-input")?.value, 10) || 120; const stepIntervalSec = 60 / (bpm * 4); const transportSec = Tone.Transport.getSecondsAtTime ? Tone.Transport.getSecondsAtTime(time) : Tone.Transport.seconds; const songStep = Math.floor(transportSec / stepIntervalSec + 1e-6); const songTick = songStep * LMMS_TICKS_PER_STEP; // Patterns ativas neste tick (pelas basslines/playlist clips) const basslineTracks = appState.pattern.tracks.filter( (t) => t.type === "bassline" && !t.isMuted ); const activePatternHits = []; for (const b of basslineTracks) { const clips = b.playlist_clips || []; const clip = clips.find((c) => songTick >= c.pos && songTick < c.pos + c.len); if (!clip) continue; const localStep = Math.floor((songTick - clip.pos) / LMMS_TICKS_PER_STEP); activePatternHits.push({ patternIndex: b.patternIndex, localStep }); } if (activePatternHits.length === 0) return; // Dispara tracks reais (samplers/plugins) for (const track of appState.pattern.tracks) { if (track.type === "bassline") continue; if (track.muted) continue; for (const hit of activePatternHits) { const patt = track.patterns?.[hit.patternIndex]; if (!patt) continue; // comprimento do pattern em ticks (prioriza notes, depois steps) let pattLenTicksByNotes = 0; if (Array.isArray(patt.notes) && patt.notes.length > 0) { for (const n of patt.notes) { const pos = Number(n.pos) || 0; const rawLen = Number(n.len) || 0; const len = rawLen < 0 ? LMMS_TICKS_PER_STEP : Math.max(rawLen, LMMS_TICKS_PER_STEP); pattLenTicksByNotes = Math.max(pattLenTicksByNotes, pos + len); } } const pattLenTicksBySteps = (patt.steps?.length || 0) * LMMS_TICKS_PER_STEP; const pattLenTicks = Math.max( pattLenTicksByNotes, pattLenTicksBySteps, LMMS_TICKS_PER_STEP ); // tick atual dentro do pattern (loop interno ao esticar clip) const tickInPattern = (hit.localStep * LMMS_TICKS_PER_STEP) % pattLenTicks; // step index (para patterns de steps) const pattLenSteps = patt.steps?.length || 0; const stepInPattern = pattLenSteps > 0 ? (Math.floor(tickInPattern / LMMS_TICKS_PER_STEP) % pattLenSteps) : hit.localStep; // ✅ 1) PLUGIN com piano roll (notes) if ( track.type === "plugin" && track.instrument && Array.isArray(patt.notes) && patt.notes.length > 0 ) { const stepStartTick = tickInPattern; const stepEndTick = stepStartTick + LMMS_TICKS_PER_STEP; for (const n of patt.notes) { const nPos = Number(n.pos) || 0; const wraps = stepEndTick > pattLenTicks; const inWindow = wraps ? (nPos >= stepStartTick || nPos < (stepEndTick - pattLenTicks)) : (nPos >= stepStartTick && nPos < stepEndTick); if (!inWindow) continue; const offsetTicks = wraps && nPos < stepStartTick ? (pattLenTicks - stepStartTick) + nPos : nPos - stepStartTick; const t2 = time + ticksToSec(offsetTicks, stepIntervalSec); const rawLen = Number(n.len) || 0; const lenTicks = rawLen < 0 ? LMMS_TICKS_PER_STEP : Math.max(rawLen, LMMS_TICKS_PER_STEP); const durSec = Math.max(0.01, ticksToSec(lenTicks, stepIntervalSec)); const vel = (Number(n.vol) || 100) / 100; const midi = Number(n.key) || 0; const freq = Tone.Frequency(midi, "midi").toFrequency(); try { track.instrument.triggerAttackRelease(freq, durSec, t2, vel); } catch (e) { console.warn("[Playlist] Falha ao tocar plugin note:", track.name, e); } } continue; // não cai na lógica de steps } // ✅ 1b) SAMPLER com piano roll (notes) if ( track.type === "sampler" && track.buffer && Array.isArray(patt.notes) && patt.notes.length > 0 ) { const stepStartTick = tickInPattern; const stepEndTick = stepStartTick + LMMS_TICKS_PER_STEP; for (const n of patt.notes) { const nPos = Number(n.pos) || 0; const wraps = stepEndTick > pattLenTicks; const inWindow = wraps ? (nPos >= stepStartTick || nPos < (stepEndTick - pattLenTicks)) : (nPos >= stepStartTick && nPos < stepEndTick); if (!inWindow) continue; const offsetTicks = wraps && nPos < stepStartTick ? (pattLenTicks - stepStartTick) + nPos : nPos - stepStartTick; const t2 = time + ticksToSec(offsetTicks, stepIntervalSec); const rawLen = Number(n.len) || 0; const lenTicks = rawLen < 0 ? LMMS_TICKS_PER_STEP : Math.max(rawLen, LMMS_TICKS_PER_STEP); const durSec = Math.max(0.01, ticksToSec(lenTicks, stepIntervalSec)); playSamplerNoteAtTime(track, Number(n.key) || 0, t2, durSec); } continue; // não cai na lógica de steps } // ✅ 2) STEP (sampler/plugin sem notes) if (!patt.steps) continue; if (patt.steps[stepInPattern]) { if (track.type === "sampler" && track.player) { try { // ✅ retrigger LMMS-like: não “some” quando sample é longo if (typeof track.player.restart === "function") { track.player.restart(time); } else { if (track.player.state === "started") track.player.stop(time); track.player.start(time); } } catch (e) { console.warn("[Playlist] Falha ao retrigger sample:", track.name, e); } } else if (track.type === "plugin" && track.instrument) { try { track.instrument.triggerAttackRelease("C5", "16n", time); } catch {} } } } } }, "16n"); } export function stopSongPatternPlaybackOnTransport() { if (songPatternScheduleId === null) return; try { Tone.Transport.clear(songPatternScheduleId); } catch {} songPatternScheduleId = null; }