diff --git a/assets/css/style.css b/assets/css/style.css index 21473505..66822d8d 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -84,6 +84,131 @@ body.slice-tool-active .timeline-container { cursor: crosshair; } position: relative; } +/* Estilos do Piano Roll */ +.piano-roll-editor { + position: absolute; + top: 60px; /* Abaixo do header global */ + left: 0; + right: 0; + bottom: 0; + background-color: #2b2e33; + z-index: 50; /* Fica acima dos outros editores */ + display: flex; + flex-direction: column; + border-top: 1px solid var(--border-color, #444); +} + +.piano-roll-toolbar { + height: 40px; + background-color: #363a40; + border-bottom: 1px solid #444; + display: flex; + align-items: center; + padding: 0 10px; + gap: 15px; + color: #ccc; +} + +.piano-roll-workspace { + flex: 1; + display: flex; + overflow: hidden; + position: relative; + background-color: #222; +} + +.piano-keys-container { + width: 60px; + background-color: #1e1e1e; + border-right: 1px solid #444; + overflow: hidden; /* O scroll será controlado via JS para sincronia */ + position: relative; + z-index: 2; +} + +.piano-grid-container { + flex: 1; + overflow: auto; /* Aqui acontece o scroll real */ + position: relative; + background-color: #252525; + cursor: crosshair; +} + +/* Cores para as notas no canvas */ +.piano-key-white { fill: #fff; } +.piano-key-black { fill: #000; } + +/* Container da visualização melódica na lista de trilhas */ +.track-mini-piano-roll { + position: relative; + width: 100%; + height: 100%; + background-color: #222; + border-radius: 4px; + cursor: pointer; + overflow: hidden; + border: 1px solid #444; +} + +.track-mini-piano-roll:hover { + border-color: #666; + background-color: #2a2a2a; +} + +/* As notinhas dentro da miniatura */ +.mini-note { + position: absolute; + background-color: #ffbb00; /* Cor laranja padrão LMMS */ + height: 4px; /* Notas finas */ + border-radius: 1px; + opacity: 0.9; + box-shadow: 0 0 2px rgba(0,0,0,0.5); +} + +/* =============================================== */ +/* CORREÇÃO VISUAL DO PIANO ROLL NA LISTA +/* =============================================== */ + +/* Quando o container .step-sequencer tiver esta classe, ele muda de comportamento */ +.step-sequencer.mode-piano { + display: block !important; /* Sobrescreve o display: flex original */ + gap: 0 !important; /* Remove o espaçamento de botões */ + width: 100%; /* Ocupa toda a largura disponível */ + height: 54px; /* Altura fixa para garantir visualização */ + position: relative; + background-color: #1e1e1e; + border-radius: 4px; + border: 1px solid var(--border-color); + overflow: hidden; /* Corta notas que passem da borda */ + margin-top: 4px; /* Centraliza verticalmente na track */ +} + +/* Ajuste fino para a miniatura interna */ +.track-mini-piano-roll { + width: 100%; + height: 100%; + position: relative; + background: linear-gradient(to right, #222 0%, #222 50%, #252525 50%, #252525 100%); + background-size: 25% 100%; /* Cria linhas verticais sutis de compasso */ +} + +/* Garante que as notas apareçam */ +.mini-note { + position: absolute; + background-color: #ffbb00; + height: 4px; + border-radius: 2px; + box-shadow: 0 1px 3px rgba(0,0,0,0.8); + pointer-events: none; /* Deixa o clique passar para o container pai */ + min-width: 2px; /* Garante que notas muito curtas sejam visíveis */ +} + +/* Efeito hover para indicar interatividade */ +.step-sequencer.mode-piano:hover { + border-color: #666; + background-color: #262626; +} + /* =============================================== */ /* BARRA LATERAL (SAMPLE BROWSER) /* =============================================== */ @@ -296,6 +421,7 @@ body.sidebar-hidden .sample-browser { } .track-lane.active-track { background-color: #40454d; } .track-lane.drag-over { border: 2px dashed var(--accent-green); } + .track-lane .track-info { display: flex; align-items: center; @@ -308,12 +434,57 @@ body.sidebar-hidden .sample-browser { .track-mute { width: 25px; height: 12px; background-color: var(--accent-green); border-radius: 6px; cursor: pointer; border: 1px solid var(--border-color); box-shadow: inset 0 0 2px #000; transition: background-color 0.2s, opacity 0.2s; } .track-mute:hover { opacity: 0.8; } .track-mute.active { background-color: var(--text-dark); opacity: 0.7; } + .track-lane .track-controls { display: flex; gap: 5px; margin: 0 10px; padding-left: 10px; border-left: 1px solid var(--bg-toolbar); flex-shrink: 0; } -.step-sequencer-wrapper { flex-grow: 1; overflow-x: auto; overflow-y: hidden; padding-bottom: 8px; display: flex; align-items: center; } -.step-sequencer { display: flex; gap: 4px; } -.step-wrapper { position: relative; } -.step-marker { position: absolute; top: -16px; left: 1px; font-size: .6rem; color: var(--text-dark); user-select: none; } -.step { width: 28px; height: 28px; background-color: #2a2a2a; border: 1px solid #4a4a4a; border-radius: 2px; cursor: pointer; transition: background-color .1s, transform 0.1s; flex-shrink: 0; } + +/* --- CORREÇÃO DE LARGURA DOS STEPS --- */ +.step-sequencer-wrapper { + flex-grow: 1; + /* Remove overflow-x para forçar os steps a caberem na tela, ou mantem se quiser scroll horizontal em telas pequenas */ + /* overflow-x: auto; <-- Comentei para testar o alinhamento perfeito */ + overflow-y: hidden; + padding-bottom: 8px; + display: flex; + align-items: center; + padding-right: 10px; /* Espaçamento na direita pra não colar na borda */ +} + +.step-sequencer { + display: flex; + gap: 2px; /* Reduzi o gap para ficar mais compacto e fluido */ + width: 100%; /* Ocupa toda a largura disponível da trilha */ +} + +.step-wrapper { + position: relative; + flex: 1; /* MÁGICA: Faz cada step crescer igualmente para preencher o espaço */ + display: flex; + flex-direction: column; +} + +.step-marker { + position: absolute; + top: -16px; + left: 0; + width: 100%; + text-align: center; /* Centraliza o número do compasso */ + font-size: .6rem; + color: var(--text-dark); + user-select: none; +} + +.step { + width: 100%; /* Ocupa 100% do wrapper (que é flexível agora) */ + height: 28px; + background-color: #2a2a2a; + border: 1px solid #4a4a4a; + border-radius: 2px; + cursor: pointer; + transition: background-color .1s, transform 0.1s; + box-sizing: border-box; /* Garante que a borda não aumente o tamanho total */ +} +/* --------------------------------------- */ + .step-dark { background-color: #1e1e1e; } .step:hover { background-color: #555; border-color: #888; } .step.active { background-color: var(--accent-green); border: 1px solid #fff; box-shadow: 0 0 8px var(--accent-green); } diff --git a/assets/js/audio/plugins/Kicker.js b/assets/js/audio/plugins/Kicker.js new file mode 100644 index 00000000..27787e17 --- /dev/null +++ b/assets/js/audio/plugins/Kicker.js @@ -0,0 +1,45 @@ +import * as Tone from "https://esm.sh/tone"; + +export class Kicker { + constructor(toneContext, data = {}) { + this.params = data.kicker || data || {}; + + // MembraneSynth é perfeito para Kicks e Toms + this.synth = new Tone.MembraneSynth({ + pitchDecay: 0.05, + octaves: 6, // O Kicker desce muitas oitavas + oscillator: { type: "sine" }, + envelope: { + attack: 0.001, + decay: 0.4, + sustain: 0.01, + release: 1.4 + } + }); + + // Distorção opcional (O Kicker tem um drive) + this.dist = new Tone.Distortion(0.1).toDestination(); + this.synth.disconnect(); + this.synth.connect(this.dist); + + this.output = this.dist; + } + + triggerAttackRelease(note, duration, time) { + // O Kicker original geralmente ignora a nota MIDI e usa frequências fixas (Start/End freq) + // Mas para facilitar a composição, vamos permitir que ele toque a nota do Piano Roll + // Se quiser ser fiel ao LMMS, teríamos que ler this.params.start_freq + + // Vamos usar a nota C1 ou C2 como base se a nota vier muito aguda + this.synth.triggerAttackRelease(note, duration, time); + } + + connect(dest) { + this.output.connect(dest); + } + + dispose() { + this.synth.dispose(); + this.dist.dispose(); + } +} \ No newline at end of file diff --git a/assets/js/audio/plugins/Lb302.js b/assets/js/audio/plugins/Lb302.js new file mode 100644 index 00000000..d7c6dab5 --- /dev/null +++ b/assets/js/audio/plugins/Lb302.js @@ -0,0 +1,49 @@ +import * as Tone from "https://esm.sh/tone"; + +export class Lb302 { + constructor(toneContext, data = {}) { + this.params = data.lb302 || data || {}; + + // O som clássico do 303 é Sawtooth ou Square com filtro ressonante + const wave = this.params.wave === 1 ? "square" : "sawtooth"; + + this.synth = new Tone.MonoSynth({ + oscillator: { type: wave }, + envelope: { + attack: 0.01, + decay: 0.3, + sustain: 0.1, + release: 0.2 + }, + filterEnvelope: { + attack: 0.01, + decay: 0.3, + sustain: 0.1, + release: 0.2, + baseFrequency: 200, + octaves: 4, + exponent: 2 + }, + filter: { + Q: 6, // Alta ressonância (o "grito" do acid) + type: "lowpass", + rolloff: -24 + } + }); + + this.output = this.synth; + } + + triggerAttackRelease(note, duration, time) { + // O LB302 é monofônico, mas aceita trigger normal + this.synth.triggerAttackRelease(note, duration, time); + } + + connect(dest) { + this.output.connect(dest); + } + + dispose() { + this.synth.dispose(); + } +} \ No newline at end of file diff --git a/assets/js/audio/plugins/Nes.js b/assets/js/audio/plugins/Nes.js new file mode 100644 index 00000000..d1a45560 --- /dev/null +++ b/assets/js/audio/plugins/Nes.js @@ -0,0 +1,45 @@ +import * as Tone from "https://esm.sh/tone"; + +export class Nes { + constructor(toneContext, data = {}) { + this.params = data.nes || data || {}; + + // Mapear tipo de onda do LMMS para Tone.js + // 0=Pulse12.5, 1=Pulse25, 2=Pulse50, 3=Triangle, 4=Noise + let oscType = "pulse"; + let width = 0.5; + + const mode = parseInt(this.params.mode || 0); + + if (mode === 0) { oscType = "pulse"; width = 0.125; } + else if (mode === 1) { oscType = "pulse"; width = 0.25; } + else if (mode === 2) { oscType = "pulse"; width = 0.5; } + else if (mode === 3) { oscType = "triangle"; } + else if (mode === 4) { oscType = "square"; } // Aproximação para noise tonal + + // Chiptunes geralmente não têm polifonia, mas vamos permitir PolySynth para acordes + this.synth = new Tone.PolySynth(Tone.Synth, { + oscillator: { type: oscType, width: width }, + envelope: { attack: 0.001, decay: 0.1, sustain: 0.1, release: 0.01 } // Envelope rápido "clicky" + }); + + // BitCrusher para dar a sujeira 8-bit + this.crusher = new Tone.BitCrusher(4).toDestination(); // 4 bits + this.synth.connect(this.crusher); + + this.output = this.crusher; + } + + triggerAttackRelease(note, duration, time) { + this.synth.triggerAttackRelease(note, duration, time); + } + + connect(dest) { + this.output.connect(dest); + } + + dispose() { + this.synth.dispose(); + this.crusher.dispose(); + } +} \ No newline at end of file diff --git a/assets/js/audio/plugins/SuperSaw.js b/assets/js/audio/plugins/SuperSaw.js new file mode 100644 index 00000000..074ba8f4 --- /dev/null +++ b/assets/js/audio/plugins/SuperSaw.js @@ -0,0 +1,51 @@ +// js/plugins/SuperSaw.js +import * as Tone from "https://esm.sh/tone"; + +export class SuperSaw { + constructor(toneContext, data = {}) { + // Tenta pegar dados de qualquer um dos plugins que usam esse motor + this.params = data.zynaddsubfx || data.watsyn || data || {}; + + // O "FatSawtooth" do Tone.js cria 3 serras desafinadas automaticamente + this.synth = new Tone.PolySynth(Tone.Synth, { + oscillator: { + type: "fatsawtooth", + count: 3, // 3 serras por voz + spread: 20 // Desafinação entre elas (o "gordura") + }, + envelope: { + attack: 0.01, // Ataque rápido (lead) + decay: 0.1, + sustain: 0.6, + release: 0.4 // Release médio para "encher" o som + }, + volume: -6 // Reduz um pouco pois o Fatsawtooth é alto + }); + + // Efeitos para dar "largura" estéreo (O segredo do SuperSaw) + // Chorus: duplica o sinal e desafina levemente + this.chorus = new Tone.Chorus(4, 2.5, 0.5).start(); + + // Filtro para cortar frequências muito agudas e evitar chiado digital + this.filter = new Tone.Filter(8000, "lowpass"); + + // Cadeia: Synth -> Chorus -> Filter -> Output + this.synth.chain(this.chorus, this.filter); + + this.output = this.filter; + } + + triggerAttackRelease(note, duration, time) { + this.synth.triggerAttackRelease(note, duration, time); + } + + connect(dest) { + this.output.connect(dest); + } + + dispose() { + this.synth.dispose(); + this.chorus.dispose(); + this.filter.dispose(); + } +} \ No newline at end of file diff --git a/assets/js/audio/plugins/TripleOscillator.js b/assets/js/audio/plugins/TripleOscillator.js index 496e07b0..27058cd4 100644 --- a/assets/js/audio/plugins/TripleOscillator.js +++ b/assets/js/audio/plugins/TripleOscillator.js @@ -1,115 +1,82 @@ -export class TripleOscillator { - constructor(audioCtx, data) { - this.ctx = audioCtx; - // Os dados do plugin geralmente vêm dentro de uma chave com o nome dele (ex: data.tripleoscillator) - // Mas às vezes vêm "flat" dependendo do parser. Vamos garantir. - this.params = data.tripleoscillator || data; +// js/plugins/TripleOscillator.js +import * as Tone from "https://esm.sh/tone"; - // Dados do envelope (ADSR) ficam em 'elvol' (Envelope Volume) - this.env = data.elvol || {}; +export class TripleOscillator { + constructor(toneContext, data = {}) { + // Não precisamos mais do contexto cru, usamos o Tone direto + this.params = data.tripleoscillator || data || {}; + + // Volume Mestre do Instrumento + this.output = new Tone.Gain(1); // Começa com volume 1 + + // Envelope de Amplitude (ADSR) + // Convertendo valores do LMMS (geralmente 0-1 ou 0-100) para segundos/níveis + const envData = data.elvol || {}; + this.envelope = new Tone.AmplitudeEnvelope({ + attack: this.normalizeParam(envData.att, 0, 0.01), + decay: this.normalizeParam(envData.dec, 0.5, 0.1), + sustain: this.normalizeParam(envData.sustain, 1, 0.5), + release: this.normalizeParam(envData.rel, 0, 0.1) + }); + + // Conecta Envelope -> Saída + this.envelope.connect(this.output); + } + + normalizeParam(val, defaultVal, scale = 1) { + if (val === undefined || val === null) return defaultVal; + let v = parseFloat(val); + // Se vier > 2, assume escala 0-100 e divide + if (v > 2) v /= 100; + return v || defaultVal; // Fallback se for 0 ou NaN } getWaveType(lmmsTypeIndex) { - // Mapeamento: 0=Sine, 1=Triangle, 2=Sawtooth, 3=Square, 4=Noise, 5=Exp - const types = [ - "sine", - "triangle", - "sawtooth", - "square", - "sawtooth", - "sawtooth", - ]; + const types = ["sine", "triangle", "sawtooth", "square", "sawtooth", "sawtooth"]; // Mapeamento básico const idx = parseInt(lmmsTypeIndex); - return types[isNaN(idx) ? 0 : idx]; // Default para sine + return types[isNaN(idx) ? 0 : idx]; } - playNote(midiNote, startTime, duration) { - // Fórmula de conversão MIDI -> Frequência - const freq = 440 * Math.pow(2, (midiNote - 69) / 12); + // Método chamado pela UI (Step Sequencer) + triggerAttackRelease(note, duration, time) { + const freq = Tone.Frequency(note).toFrequency(); + const dur = Tone.Time(duration).toSeconds(); + const now = time || Tone.now(); - // Ganho Mestre desta nota (evita estouro de áudio) - const masterGain = this.ctx.createGain(); - masterGain.connect(this.ctx.destination); - - // Aplica Envelope ADSR no volume mestre - this.applyEnvelope(masterGain, startTime, duration); - - // O TripleOscillator tem 3 osciladores (Osc1, Osc2, Osc3) - // No XML/JSON eles são sufixados com 0, 1 e 2 (ex: vol0, vol1...) + // Cria os 3 osciladores efêmeros (só para esta nota) + // Isso é necessário para polifonia (cada nota tem seus osciladores) for (let i = 0; i < 3; i++) { - // Volume: O LMMS usa 0-100. O Web Audio usa 0.0-1.0. const volRaw = this.params[`vol${i}`]; - const vol = parseInt(volRaw !== undefined ? volRaw : i === 0 ? 100 : 0); + // Default: Osc 1 (índice 0) = 100%, outros = 0% + const vol = (volRaw !== undefined) ? parseInt(volRaw) : (i === 0 ? 100 : 0); - // Se volume for 0, não gasta processamento criando oscilador if (vol > 0) { - const osc = this.ctx.createOscillator(); - const oscGain = this.ctx.createGain(); + const osc = new Tone.Oscillator({ + type: this.getWaveType(this.params[`wavetype${i}`]), + frequency: freq, + detune: (parseInt(this.params[`coarse${i}`] || 0) * 100) + parseInt(this.params[`fine${i}`] || 0), + volume: Tone.gainToDb((vol / 100) * 0.3), // 0.3 para evitar clipar a soma + onstop: () => { osc.dispose(); } // Limpa memória ao acabar + }); - // Configura Onda - osc.type = this.getWaveType(this.params[`wavetype${i}`]); - - // Configura Afinação (Coarse = Semitons, Fine = Cents) - const coarse = parseInt(this.params[`coarse${i}`] || 0); - const fine = parseInt( - this.params[`fine${i}`] || this.params[`finer${i}`] || 0 - ); - const detuneTotal = coarse * 100 + fine; - - osc.frequency.value = freq; - osc.detune.value = detuneTotal; - - // Configura Volume do Oscilador - // Dividimos por 300 (3 osciladores x 100) para normalizar e não distorcer - oscGain.gain.value = (vol / 100) * 0.3; - - // Conexões: Osc -> OscGain -> MasterGain - osc.connect(oscGain); - oscGain.connect(masterGain); - - // Toca - osc.start(startTime); - osc.stop(startTime + duration + 2.0); // +2s de margem para o release do envelope + // Conecta Osc -> Envelope (Mestre) + osc.connect(this.envelope); + + osc.start(now); + osc.stop(now + dur + this.envelope.release); } } + + // Dispara o envelope + this.envelope.triggerAttackRelease(dur, now); } - applyEnvelope(gainNode, time, duration) { - // Valores padrão do LMMS se não existirem no JSON - // O LMMS usa escala 0-100 para tempo em alguns contextos, mas o parser XML geralmente traz valores brutos. - // Vamos assumir valores pequenos como segundos ou normalizar. - - let att = parseFloat(this.env.att || 0); - let dec = parseFloat(this.env.dec || 0.5); - let sus = parseFloat(this.env.sustain || 0.5); - let rel = parseFloat(this.env.rel || 0.1); - - // Ajuste empírico: Se os valores forem muito grandes (> 5), provavelmente estão em escala 0-100 ou similar - if (att > 2) att /= 100; - if (dec > 5) dec /= 100; - // Sustain é sempre nível (0-1), mas as vezes vem como 100 - if (sus > 1) sus /= 100; - if (rel > 5) rel /= 100; - - const now = time; - - // Garante que começa zerado - gainNode.gain.cancelScheduledValues(now); - gainNode.gain.setValueAtTime(0, now); - - // Attack: Sobe até o volume máximo (1.0 relativo ao masterGain) - gainNode.gain.linearRampToValueAtTime(1.0, now + att + 0.005); - - // Decay: Desce até o nível de Sustain - gainNode.gain.exponentialRampToValueAtTime( - Math.max(sus, 0.001), - now + att + dec - ); - - // Sustain: Mantém o nível até o fim da nota - gainNode.gain.setValueAtTime(Math.max(sus, 0.001), now + duration); - - // Release: Desce a zero após soltar a nota - gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration + rel); + connect(dest) { + this.output.connect(dest); } -} + + dispose() { + this.output.dispose(); + this.envelope.dispose(); + } +} \ No newline at end of file diff --git a/assets/js/creations/file.js b/assets/js/creations/file.js index 75739adf..c0d2b189 100644 --- a/assets/js/creations/file.js +++ b/assets/js/creations/file.js @@ -5,16 +5,12 @@ import { renderAll, getSamplePathMap } from "./ui.js"; import { DEFAULT_PAN, DEFAULT_VOLUME, NOTE_LENGTH } from "./config.js"; import { initializeAudioContext, - getAudioContext, getMainGainNode, } from "./audio.js"; import * as Tone from "https://esm.sh/tone"; -// --- NOVA IMPORTAÇÃO --- import { sendAction } from "./socket.js"; -// --- NOVA ADIÇÃO --- -// Conteúdo do 'teste.mmp' (projeto em branco) const BLANK_PROJECT_XML = ` @@ -27,43 +23,32 @@ const BLANK_PROJECT_XML = ` `; -/** - * Executa um reset completo do estado local do projeto. - * Limpa o backup da sessão, reseta o appState e renderiza a UI. - */ export function handleLocalProjectReset() { console.log("Recebido comando de reset. Limpando estado local..."); - // 1. Limpa o backup da sessão if (window.ROOM_NAME) { try { sessionStorage.removeItem(`temp_state_${window.ROOM_NAME}`); - console.log("Estado da sessão local limpo."); } catch (e) { console.error("Falha ao limpar estado da sessão:", e); } } - // 2. Reseta o estado da memória (appState) - // (Isso deve zerar o appState.pattern.tracks, etc) resetProjectState(); - // 3. Reseta a UI global para os padrões document.getElementById("bpm-input").value = 140; document.getElementById("bars-input").value = 1; document.getElementById("compasso-a-input").value = 4; document.getElementById("compasso-b-input").value = 4; - // 4. Renderiza a UI vazia - renderAll(); // Isso deve redesenhar o editor de patterns vazio - - console.log("Reset local concluído."); + renderAll(); } export async function handleFileLoad(file) { let xmlContent = ""; try { if (file.name.toLowerCase().endsWith(".mmpz")) { + // eslint-disable-next-line no-undef const jszip = new JSZip(); const zip = await jszip.loadAsync(file); const projectFile = Object.keys(zip.files).find((name) => @@ -78,9 +63,6 @@ export async function handleFileLoad(file) { xmlContent = await file.text(); } - // ANTES: await parseMmpContent(xmlContent); - // DEPOIS: - // Envia o XML para o servidor, que o transmitirá para todos (incluindo nós) sendAction({ type: "LOAD_PROJECT", xml: xmlContent }); } catch (error) { console.error("Erro ao carregar o projeto:", error); @@ -95,32 +77,20 @@ export async function loadProjectFromServer(fileName) { throw new Error(`Não foi possível carregar o arquivo ${fileName}`); const xmlContent = await response.text(); - - // ANTES: - // await parseMmpContent(xmlContent); - // return true; - - // DEPOIS: - // Envia o XML para o servidor sendAction({ type: "LOAD_PROJECT", xml: xmlContent }); - return true; // Retorna true para que o modal de UI feche + return true; } catch (error) { console.error("Erro ao carregar projeto do servidor:", error); - console.error(error); alert(`Erro ao carregar projeto: ${error.message}`); return false; } } -// 'parseMmpContent' agora é chamado pelo 'socket.js' -// quando ele recebe a ação 'LOAD_PROJECT' ou 'load_project_state'. - export async function parseMmpContent(xmlString) { resetProjectState(); initializeAudioContext(); appState.global.justReset = xmlString === BLANK_PROJECT_XML; - // Limpa manualmente a UI de áudio, pois resetProjectState() - // só limpa os *dados* (appState.audio.clips). + const audioContainer = document.getElementById("audio-track-container"); if (audioContainer) { audioContainer.innerHTML = ""; @@ -142,81 +112,52 @@ export async function parseMmpContent(xmlString) { head.getAttribute("timesig_denominator") || 4; } - const allBBTrackNodes = Array.from( - xmlDoc.querySelectorAll( - 'song > trackcontainer[type="song"] > track[type="1"]' - ) + // --- CORREÇÃO DA SELEÇÃO DE TRACKS --- + + // 1. Identifica as faixas containers de Beat/Bassline (Type 1) + const bbEditorTrackNodes = Array.from( + xmlDoc.querySelectorAll('song > trackcontainer[type="song"] > track[type="1"]') ); - if (allBBTrackNodes.length === 0) { - const allBBTrackNodes = Array.from( - xmlDoc.querySelectorAll( - 'song > trackcontainer[type="song"] > track[type="1"]' - ) - ); - if (allBBTrackNodes.length === 0) { - appState.pattern.tracks = []; - // --- INÍCIO DA CORREÇÃO --- - // O resetProjectState() [na linha 105] já limpou o appState.audio. - // No entanto, a UI (DOM) do editor de áudio não foi limpa. - // Vamos forçar a limpeza do container aqui: - const audioContainer = document.getElementById("audio-track-container"); - if (audioContainer) { - audioContainer.innerHTML = ""; // Limpa a UI de áudio - } - // --- FIM DA CORREÇÃO --- - - renderAll(); // - return; // - } + // 2. Identifica os nomes dos patterns (colunas do B/B Editor) - tag + // Precisamos disso para dar nome aos patterns e saber quantos criar + let sortedBBTrackNameNodes = []; + if (bbEditorTrackNodes.length > 0) { + // Pega do primeiro editor encontrado + sortedBBTrackNameNodes = Array.from(bbEditorTrackNodes[0].querySelectorAll("bbtco")) + .sort((a, b) => { + const posA = parseInt(a.getAttribute("pos"), 10) || 0; + const posB = parseInt(b.getAttribute("pos"), 10) || 0; + return posA - posB; + }); } - const sortedBBTrackNameNodes = [...allBBTrackNodes].sort((a, b) => { - const bbtcoA = a.querySelector("bbtco"); - const bbtcoB = a.querySelector("bbtco"); - const posA = bbtcoA ? parseInt(bbtcoA.getAttribute("pos"), 10) : Infinity; - const posB = bbtcoB ? parseInt(bbtcoB.getAttribute("pos"), 10) : Infinity; - return posA - posB; + // 3. Identifica os instrumentos dentro do Beat/Bassline (Type 0 aninhado) + const bbInstrumentTracks = []; + bbEditorTrackNodes.forEach(container => { + const instruments = container.querySelectorAll('bbtrack > trackcontainer > track[type="0"]'); + bbInstrumentTracks.push(...Array.from(instruments)); }); - // --- INÍCIO DA CORREÇÃO 1: Lendo TODAS as Basslines (Tracks type="1") --- - // O bug anterior era que o código só lia os instrumentos (tracks type="0") - // da PRIMEIRA bassline encontrada (allBBTrackNodes[0]). - // A correção abaixo itera em TODAS as basslines (allBBTrackNodes.forEach) - // e coleta os instrumentos de CADA UMA delas. + // 4. Identifica os instrumentos do Song Editor (Type 0 direto) + const songInstrumentTracks = Array.from( + xmlDoc.querySelectorAll('song > trackcontainer[type="song"] > track[type="0"]') + ); - // Define um nome global (pode usar o da primeira track, se existir) - appState.global.currentBeatBasslineName = - allBBTrackNodes[0]?.getAttribute("name") || "Beat/Bassline"; + // Junta tudo + const allInstrumentTrackNodes = [...bbInstrumentTracks, ...songInstrumentTracks]; - // Cria um array para guardar TODOS os instrumentos de TODAS as basslines - const allInstrumentTrackNodes = []; - - // Loop em CADA bassline (allBBTrackNodes) em vez de apenas na [0] - allBBTrackNodes.forEach((bbTrackNode) => { - const bbTrackContainer = bbTrackNode.querySelector( - "bbtrack > trackcontainer" - ); - if (bbTrackContainer) { - // Encontra os instrumentos (type="0") DENTRO desta bassline - const instrumentTracks = - bbTrackContainer.querySelectorAll('track[type="0"]'); - // Adiciona os instrumentos encontrados ao array principal - allInstrumentTrackNodes.push(...Array.from(instrumentTracks)); - } - }); - - // Se não achou NENHUM instrumento em NENHUMA bassline, encerra if (allInstrumentTrackNodes.length === 0) { - appState.pattern.tracks = []; - renderAll(); - return; + appState.pattern.tracks = []; + renderAll(); + return; } - // --- FIM DA CORREÇÃO 1 --- + + // Define um nome padrão para referência + appState.global.currentBeatBasslineName = "Main Project"; const pathMap = getSamplePathMap(); - // Agora o map usa o array corrigido (allInstrumentTrackNodes) newTracks = Array.from(allInstrumentTrackNodes) .map((trackNode) => { const instrumentNode = trackNode.querySelector("instrument"); @@ -224,48 +165,54 @@ export async function parseMmpContent(xmlString) { if (!instrumentNode || !instrumentTrackNode) return null; const trackName = trackNode.getAttribute("name"); - - if (instrumentNode.getAttribute("name") === "tripleoscillator") { - return null; - } + const instrumentName = instrumentNode.getAttribute("name"); const allPatternsNodeList = trackNode.querySelectorAll("pattern"); const allPatternsArray = Array.from(allPatternsNodeList).sort((a, b) => { const posA = parseInt(a.getAttribute("pos"), 10) || 0; const posB = parseInt(b.getAttribute("pos"), 10) || 0; - - // --- CORREÇÃO 2: Ordenação dos Patterns --- - // O bug aqui era `posB - posA`, que invertia a ordem dos patterns - // (o "Pattern 1" recebia as notas do "Pattern 8", etc.) - // `posA - posB` garante a ordem correta (crescente: P1, P2, P3...). return posA - posB; }); - const patterns = sortedBBTrackNameNodes.map((bbTrack, index) => { + // Mapeia os patterns baseados nas colunas do B/B editor (sortedBBTrackNameNodes) + // Se não houver colunas B/B (ex: projeto só Song Editor), cria 1 pattern padrão + const patternsToCreate = sortedBBTrackNameNodes.length > 0 ? sortedBBTrackNameNodes : [{ getAttribute: () => "Pattern 1" }]; + + const patterns = patternsToCreate.map((bbTrack, index) => { const patternNode = allPatternsArray[index]; - const bbTrackName = - bbTrack.getAttribute("name") || `Pattern ${index + 1}`; + const bbTrackName = bbTrack.getAttribute("name") || `Pattern ${index + 1}`; if (!patternNode) { - const firstPattern = allPatternsArray[0]; - const stepsLength = firstPattern - ? parseInt(firstPattern.getAttribute("steps"), 10) || 16 - : 16; return { name: bbTrackName, - steps: new Array(stepsLength).fill(false), + steps: new Array(16).fill(false), + notes: [], pos: 0, }; } - const patternSteps = - parseInt(patternNode.getAttribute("steps"), 10) || 16; + const patternSteps = parseInt(patternNode.getAttribute("steps"), 10) || 16; const steps = new Array(patternSteps).fill(false); + const notes = []; + const ticksPerStep = 12; patternNode.querySelectorAll("note").forEach((noteNode) => { - const noteLocalPos = parseInt(noteNode.getAttribute("pos"), 10); - const stepIndex = Math.round(noteLocalPos / ticksPerStep); + const pos = parseInt(noteNode.getAttribute("pos"), 10); + const len = parseInt(noteNode.getAttribute("len"), 10); + const key = parseInt(noteNode.getAttribute("key"), 10); + const vol = parseInt(noteNode.getAttribute("vol"), 10); + const pan = parseInt(noteNode.getAttribute("pan"), 10); + + notes.push({ + pos: pos, + len: len, + key: key, + vol: vol, + pan: pan + }); + + const stepIndex = Math.round(pos / ticksPerStep); if (stepIndex < patternSteps) { steps[stepIndex] = true; } @@ -274,53 +221,52 @@ export async function parseMmpContent(xmlString) { return { name: bbTrackName, steps: steps, + notes: notes, pos: parseInt(patternNode.getAttribute("pos"), 10) || 0, }; }); - const hasNotes = patterns.some((p) => p.steps.includes(true)); - if (!hasNotes) return null; + // Verifica se tem notas em algum pattern + const hasNotes = patterns.some((p) => p.notes.length > 0 || p.steps.includes(true)); + + // Opcional: Se quiser carregar tracks vazias, remova a linha abaixo + if (!hasNotes && patterns.length === 0) return null; - const afpNode = instrumentNode.querySelector("audiofileprocessor"); - const sampleSrc = afpNode ? afpNode.getAttribute("src") : null; let finalSamplePath = null; - if (sampleSrc) { - const filename = sampleSrc.split("/").pop(); - if (pathMap[filename]) { - finalSamplePath = pathMap[filename]; - } else { - let cleanSrc = sampleSrc; - if (cleanSrc.startsWith("samples/")) { - cleanSrc = cleanSrc.substring("samples/".length); + let trackType = "plugin"; + + if (instrumentName === "audiofileprocessor") { + trackType = "sampler"; + const afpNode = instrumentNode.querySelector("audiofileprocessor"); + const sampleSrc = afpNode ? afpNode.getAttribute("src") : null; + + if (sampleSrc) { + const filename = sampleSrc.split("/").pop(); + if (pathMap[filename]) { + finalSamplePath = pathMap[filename]; + } else { + let cleanSrc = sampleSrc; + if (cleanSrc.startsWith("samples/")) { + cleanSrc = cleanSrc.substring("samples/".length); + } + finalSamplePath = `src/samples/${cleanSrc}`; } - finalSamplePath = `src/samples/${cleanSrc}`; } - } + } const volFromFile = parseFloat(instrumentTrackNode.getAttribute("vol")); const panFromFile = parseFloat(instrumentTrackNode.getAttribute("pan")); - const firstPatternWithNotesIndex = patterns.findIndex((p) => - p.steps.includes(true) - ); return { id: Date.now() + Math.random(), name: trackName, + type: trackType, samplePath: finalSamplePath, patterns: patterns, - - // --- INÍCIO DA CORREÇÃO --- - // ANTES: - // activePatternIndex: - // firstPatternWithNotesIndex !== -1 ? firstPatternWithNotesIndex : 0, // - - // DEPOIS (force o Padrão 1): activePatternIndex: 0, - // --- FIM DA CORREÇÃO --- - volume: !isNaN(volFromFile) ? volFromFile / 100 : DEFAULT_VOLUME, pan: !isNaN(panFromFile) ? panFromFile / 100 : DEFAULT_PAN, - instrumentName: instrumentNode.getAttribute("name"), + instrumentName: instrumentName, instrumentXml: instrumentNode.innerHTML, }; }) @@ -328,26 +274,19 @@ export async function parseMmpContent(xmlString) { let isFirstTrackWithNotes = true; newTracks.forEach((track) => { - // --- INÍCIO DA CORREÇÃO --- - // (Esta parte já existia no seu arquivo, mantida) - // Agora usando Volume em dB (Opção B) track.volumeNode = new Tone.Volume(Tone.gainToDb(track.volume)); track.pannerNode = new Tone.Panner(track.pan); - // Cadeia de áudio: Volume(dB) -> Panner -> Saída Principal track.volumeNode.connect(track.pannerNode); track.pannerNode.connect(getMainGainNode()); - // --- FIM DA CORREÇÃO --- if (isFirstTrackWithNotes) { const activeIdx = track.activePatternIndex || 0; const activePattern = track.patterns[activeIdx]; - if (activePattern) { - const firstPatternSteps = activePattern.steps.length; - const stepsPerBar = 16; - const requiredBars = Math.ceil(firstPatternSteps / stepsPerBar); - document.getElementById("bars-input").value = - requiredBars > 0 ? requiredBars : 1; + if (activePattern && activePattern.steps) { + const stepsLength = activePattern.steps.length; + const requiredBars = Math.ceil(stepsLength / 16); + document.getElementById("bars-input").value = requiredBars > 0 ? requiredBars : 1; isFirstTrackWithNotes = false; } } @@ -364,45 +303,31 @@ export async function parseMmpContent(xmlString) { appState.pattern.tracks = newTracks; appState.pattern.activeTrackId = appState.pattern.tracks[0]?.id || null; - // --- INÍCIO DA CORREÇÃO --- - // Define o estado global para também ser o Padrão 1 (índice 0) appState.pattern.activePatternIndex = 0; - // --- FIM DA CORREÇÃO --- - // --- A MÁGICA DO F5 (Versão 2.0 - Corrigida) --- + // --- Restauração de Sessão (F5) --- try { const roomName = window.ROOM_NAME || "default_room"; const tempStateJSON = sessionStorage.getItem(`temp_state_${roomName}`); if (tempStateJSON) { - console.log("Restaurando estado temporário da sessão (pós-F5)..."); + console.log("Restaurando estado temporário da sessão..."); const tempState = JSON.parse(tempStateJSON); - // NÃO FAÇA: appState.pattern = tempState.pattern; (Isso apaga os Tone.js nodes) - - // EM VEZ DISSO, FAÇA O "MERGE" (MESCLAGEM): - - // 1. Mescla os 'tracks' - // Itera nos tracks "vivos" (com nós de áudio) que acabamos de criar appState.pattern.tracks.forEach((liveTrack) => { - // Encontra o track salvo correspondente const savedTrack = tempState.pattern.tracks.find( (t) => t.id === liveTrack.id ); if (savedTrack) { - // Copia os dados do 'savedTrack' para o 'liveTrack' liveTrack.name = savedTrack.name; - liveTrack.patterns = savedTrack.patterns; + liveTrack.patterns = savedTrack.patterns; liveTrack.activePatternIndex = savedTrack.activePatternIndex; liveTrack.volume = savedTrack.volume; liveTrack.pan = savedTrack.pan; - // ATUALIZA OS NÓS DO TONE.JS com os valores salvos! if (liveTrack.volumeNode) { - liveTrack.volumeNode.volume.value = Tone.gainToDb( - savedTrack.volume - ); + liveTrack.volumeNode.volume.value = Tone.gainToDb(savedTrack.volume); } if (liveTrack.pannerNode) { liveTrack.pannerNode.pan.value = savedTrack.pan; @@ -410,41 +335,23 @@ export async function parseMmpContent(xmlString) { } }); - // 2. Remove tracks "vivos" que não existem mais no estado salvo - // (Ex: se o usuário deletou um track antes de dar F5) appState.pattern.tracks = appState.pattern.tracks.filter((liveTrack) => tempState.pattern.tracks.some((t) => t.id === liveTrack.id) ); - // 3. Restaura valores globais da UI document.getElementById("bpm-input").value = tempState.global.bpm; - document.getElementById("compasso-a-input").value = - tempState.global.compassoA; - document.getElementById("compasso-b-input").value = - tempState.global.compassoB; + document.getElementById("compasso-a-input").value = tempState.global.compassoA; + document.getElementById("compasso-b-input").value = tempState.global.compassoB; document.getElementById("bars-input").value = tempState.global.bars; - // 4. Restaura o ID do track ativo appState.pattern.activeTrackId = tempState.pattern.activeTrackId; - - console.log("Estado da sessão restaurado com sucesso."); } } catch (e) { - console.error( - "Erro ao restaurar estado da sessão (pode estar corrompido)", - e - ); - if (window.ROOM_NAME) { - sessionStorage.removeItem(`temp_state_${window.ROOM_NAME}`); - } + console.error("Erro ao restaurar sessão:", e); } - // --- FIM DA MÁGICA (V2.0) --- - // Agora sim, renderiza com o estado CORRIGIDO E MESCLADO await Promise.resolve(); renderAll(); - - console.log("[UI] Projeto renderizado após parseMmpContent"); } export function generateMmpFile() { @@ -455,17 +362,9 @@ export function generateMmpFile() { } } -// Função auxiliar (pode ser movida para cá) que gera o XML a partir do appState -// Copiada de generateMmpFile/modifyAndSaveExistingMmp function generateXmlFromState() { if (!appState.global.originalXmlDoc) { - // Se não houver XML original, precisamos gerar um novo - // Por simplicidade, para este fix, vamos retornar o estado atual do LMMS - // mas o ideal seria gerar o XML completo (como generateNewMmp) - console.warn( - "Não há XML original para modificar. Usando a base atual do appState." - ); - // No seu caso, use o conteúdo de generateNewMmp() + console.warn("Não há XML original. Retornando vazio."); return ""; } @@ -474,76 +373,68 @@ function generateXmlFromState() { if (head) { head.setAttribute("bpm", document.getElementById("bpm-input").value); head.setAttribute("num_bars", document.getElementById("bars-input").value); - head.setAttribute( - "timesig_numerator", - document.getElementById("compasso-a-input").value - ); - head.setAttribute( - "timesig_denominator", - document.getElementById("compasso-b-input").value - ); + head.setAttribute("timesig_numerator", document.getElementById("compasso-a-input").value); + head.setAttribute("timesig_denominator", document.getElementById("compasso-b-input").value); } - const bbTrackContainer = xmlDoc.querySelector( - 'track[type="1"] > bbtrack > trackcontainer' - ); + + const bbTrackContainer = xmlDoc.querySelector('track[type="1"] > bbtrack > trackcontainer'); if (bbTrackContainer) { - bbTrackContainer - .querySelectorAll('track[type="0"]') - .forEach((node) => node.remove()); + bbTrackContainer.querySelectorAll('track[type="0"]').forEach((node) => node.remove()); const tracksXml = appState.pattern.tracks .map((track) => createTrackXml(track)) .join(""); - const tempDoc = new DOMParser().parseFromString( - `${tracksXml}`, - "application/xml" - ); + + // Gambiarra para inserir o XML gerado como nós DOM + const tempDoc = new DOMParser().parseFromString(`${tracksXml}`, "application/xml"); Array.from(tempDoc.documentElement.children).forEach((newTrackNode) => { bbTrackContainer.appendChild(newTrackNode); }); } + const serializer = new XMLSerializer(); return serializer.serializeToString(xmlDoc); } -/** - * Envia o estado ATUAL do projeto (XML dos padrões) para o servidor - * para que ele persista a "cópia temporária" em disco/memória. - * Deve ser chamado APÓS alterações significativas no padrão (steps, tracks). - */ export function syncPatternStateToServer() { if (!window.ROOM_NAME) return; const currentXml = generateXmlFromState(); - - sendAction({ - type: "SYNC_PATTERN_STATE", - xml: currentXml, - }); - - // Salva o estado localmente também! - saveStateToSession(); // <-- ADICIONE ISSO + sendAction({ type: "SYNC_PATTERN_STATE", xml: currentXml }); + saveStateToSession(); } function createTrackXml(track) { if (track.patterns.length === 0) return ""; + const ticksPerStep = 12; const lmmsVolume = Math.round(track.volume * 100); const lmmsPan = Math.round(track.pan * 100); - const patternsXml = track.patterns - .map((pattern) => { - const patternNotes = pattern.steps - .map((isActive, index) => { + + const patternsXml = track.patterns.map((pattern) => { + let patternNotesXml = ""; + + // SE for plugin e tiver notas detalhadas, usa elas + if (track.type === "plugin" && pattern.notes && pattern.notes.length > 0) { + patternNotesXml = pattern.notes.map(note => { + return ``; + }).join("\n "); + } + // SE for sampler (ou plugin sem notas detalhadas), usa os steps convertidos em notas simples + else { + patternNotesXml = pattern.steps.map((isActive, index) => { if (isActive) { const notePos = Math.round(index * ticksPerStep); + // Key 57 é o padrão do LMMS para samples (Lá) return ``; } return ""; - }) - .join("\n "); + }).join("\n "); + } + return ` - ${patternNotes} + ${patternNotesXml} `; - }) - .join("\n "); + }).join("\n "); + return ` @@ -557,42 +448,8 @@ function createTrackXml(track) { } function modifyAndSaveExistingMmp() { - console.log("Modificando arquivo .mmp existente..."); - const xmlDoc = appState.global.originalXmlDoc.cloneNode(true); - const head = xmlDoc.querySelector("head"); - if (head) { - head.setAttribute("bpm", document.getElementById("bpm-input").value); - head.setAttribute("num_bars", document.getElementById("bars-input").value); - head.setAttribute( - "timesig_numerator", - document.getElementById("compasso-a-input").value - ); - head.setAttribute( - "timesig_denominator", - document.getElementById("compasso-b-input").value - ); - } - const bbTrackContainer = xmlDoc.querySelector( - 'track[type="1"] > bbtrack > trackcontainer' - ); - if (bbTrackContainer) { - bbTrackContainer - .querySelectorAll('track[type="0"]') - .forEach((node) => node.remove()); - const tracksXml = appState.pattern.tracks - .map((track) => createTrackXml(track)) - .join(""); - const tempDoc = new DOMParser().parseFromString( - `${tracksXml}`, - "application/xml" - ); - Array.from(tempDoc.documentElement.children).forEach((newTrackNode) => { - bbTrackContainer.appendChild(newTrackNode); - }); - } - const serializer = new XMLSerializer(); - const mmpContent = serializer.serializeToString(xmlDoc); - downloadFile(mmpContent, "projeto_editado.mmp"); + const content = generateXmlFromState(); + downloadFile(content, "projeto_editado.mmp"); } function generateNewMmp() { @@ -603,6 +460,7 @@ function generateNewMmp() { const tracksXml = appState.pattern.tracks .map((track) => createTrackXml(track)) .join(""); + const mmpContent = ` @@ -643,4 +501,4 @@ function downloadFile(content, fileName) { URL.revokeObjectURL(url); } -export { BLANK_PROJECT_XML }; +export { BLANK_PROJECT_XML }; \ No newline at end of file diff --git a/assets/js/creations/pattern/pattern_audio.js b/assets/js/creations/pattern/pattern_audio.js index 5bf37e2b..beff166d 100644 --- a/assets/js/creations/pattern/pattern_audio.js +++ b/assets/js/creations/pattern/pattern_audio.js @@ -8,6 +8,9 @@ import { initializeAudioContext } from "../audio.js"; const timerDisplay = document.getElementById("timer-display"); +// Variável para armazenar as "Parts" (sequências melódicas) do Tone.js +let activeParts = []; + function formatTime(milliseconds) { const totalSeconds = Math.floor(milliseconds / 1000); const minutes = Math.floor(totalSeconds / 60) @@ -46,7 +49,7 @@ export function playSample(filePath, trackId) { track.pannerNode.pan.value = track.pan ?? 0; } - // Garante conexão: player -> volumeNode (não usar mais gainNode) + // Garante conexão: player -> volumeNode try { track.player.disconnect(); } catch {} @@ -103,17 +106,29 @@ function tick() { // Percorre tracks e toca o step atual se ativo appState.pattern.tracks.forEach((track) => { + if (track.muted) return; // Respeita o Mute 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); + // Verifica se o step atual está ativo + if (activePattern && activePattern.steps[appState.global.currentStep]) { + + // CASO 1: SAMPLER (Arquivo de Áudio) + if (track.samplePath) { + playSample(track.samplePath, track.id); + } + // CASO 2: PLUGIN (Sintetizador) + // Se for plugin e tiver step marcado, toca nota padrão (C5) + else if (track.type === 'plugin' && track.instrument) { + // "16n" é a duração de uma semicolcheia + // Usamos um try/catch para evitar travar o loop se o plugin falhar + try { + track.instrument.triggerAttackRelease("C5", "16n", Tone.now()); + } catch (e) { + console.warn("Falha ao tocar step do plugin:", e); + } + } } }); @@ -125,6 +140,11 @@ export function startPlayback() { if (appState.global.isPlaying || appState.pattern.tracks.length === 0) return; initializeAudioContext(); + // Garante que o contexto do Tone esteja rodando + if (Tone.context.state !== "running") { + Tone.start(); + } + if (appState.global.currentStep === 0) { rewindPlayback(); } @@ -136,6 +156,11 @@ export function startPlayback() { if (appState.global.playbackIntervalId) clearInterval(appState.global.playbackIntervalId); + // --- NOVO: Agenda o Piano Roll (Melodias) --- + schedulePianoRoll(); + Tone.Transport.start(); // Inicia o relógio para as notas melódicas + // -------------------------------------------- + appState.global.isPlaying = true; const playBtn = document.getElementById("play-btn"); if (playBtn) { @@ -154,6 +179,21 @@ export function stopPlayback() { appState.global.playbackIntervalId = null; appState.global.isPlaying = false; + // --- NOVO: Para o Transport e Limpa Synths --- + Tone.Transport.stop(); + + // Limpa agendamentos melódicos + activeParts.forEach(part => part.dispose()); + activeParts = []; + + // Solta notas travadas de todos os plugins + appState.pattern.tracks.forEach(track => { + try { track.player?.stop(); } catch {} + try { track.instrument?.releaseAll?.(); } catch {} // Para PolySynths + try { track.instrument?.triggerRelease?.(); } catch {} // Para MonoSynths + }); + // -------------------------------------------- + document .querySelectorAll(".step.playing") .forEach((s) => s.classList.remove("playing")); @@ -173,6 +213,9 @@ export function rewindPlayback() { ? 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); @@ -190,51 +233,88 @@ export function togglePlayback() { } // ========================================================================= -// FUNÇÃO CORRIGIDA v3: Renderizar o Pattern atual para um Blob de Áudio +// AGENDADOR DE PIANO ROLL (NOVA FUNÇÃO) +// Agenda as notas melódicas (desenhadas no Piano Roll) para tocar no Tone.Transport +// ========================================================================= +function schedulePianoRoll() { + // Limpa anteriores por segurança + activeParts.forEach(part => part.dispose()); + activeParts = []; + + appState.pattern.tracks.forEach(track => { + if (track.muted) return; + const pattern = track.patterns[track.activePatternIndex]; + + // Só agenda se tiver notas E for um instrumento + if (pattern && pattern.notes && pattern.notes.length > 0 && track.instrument) { + + // Mapeia as notas para o formato de evento do Tone.js + const events = pattern.notes.map(note => { + return { + // Converte Ticks (pos) para Tempo Musical do Tone.js + // Assumindo 192 PPQ (padrão LMMS) -> Tone PPQ + time: 0 + (note.pos * (Tone.Transport.PPQ / 192) / Tone.Transport.PPQ), + midi: note.key, + duration: note.len + "i", // 'i' em Tone.js significa ticks + velocity: (note.vol || 100) / 100 + }; + }); + + // Cria uma Part (sequência) + const part = new Tone.Part((time, value) => { + if (track.muted) return; + + const freq = Tone.Frequency(value.midi, "midi"); + // Dispara o método que padronizamos em todos os plugins + if (track.instrument.triggerAttackRelease) { + track.instrument.triggerAttackRelease(freq, value.duration, time, value.velocity); + } + }, events).start(0); + + // Configura o Loop da Melodia + const bars = parseInt(document.getElementById('bars-input')?.value || 1); + part.loop = true; + part.loopEnd = bars + "m"; // 'm' = measure (compasso) + + activeParts.push(part); + } + }); +} + +// ========================================================================= +// Renderizar o Pattern atual para um Blob de Áudio (MANTIDO ORIGINAL) // ========================================================================= export async function renderActivePatternToBlob() { - initializeAudioContext(); // Garante que o contexto de áudio principal existe + initializeAudioContext(); - // 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 + const stepInterval = 60 / (bpm * 4); + const duration = totalSteps * stepInterval; - // 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) + // Nota: O render atual suporta apenas Samplers (buffers carregados) + // Para suportar Plugins no futuro, precisaríamos recriar o synth aqui dentro. if (!pattern || !track.buffer || !pattern.steps.includes(true)) { - return null; // Pula trilha se não tiver áudio ou notas + return null; } - // 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) { @@ -243,48 +323,30 @@ export async function renderActivePatternToBlob() { } }); - // 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) + .connect(volume) + .start(time); + }, events); - 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) + return part; }) - .filter((t) => t !== null); // Remove trilhas nulas + .filter((t) => t !== null); - // 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 + }, duration); - // 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á) +// FUNÇÃO UTILITÁRIA: Converte AudioBuffer para Blob WAV (MANTIDO ORIGINAL) // ========================================================================= function bufferToWave(abuffer) { @@ -298,7 +360,6 @@ function bufferToWave(abuffer) { 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]); @@ -309,44 +370,41 @@ function bufferToWave(abuffer) { 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 + pos += 4; view.setUint16(pos, 1, true); - pos += 2; // Audio format 1 + 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; // Byte rate + pos += 4; view.setUint16(pos, numOfChan * 2, true); - pos += 2; // Block align + pos += 2; view.setUint16(pos, 16, true); - pos += 2; // Bits per sample + pos += 2; 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 + sample = (0.5 + sample * 32767.5) | 0; view.setInt16(pos, sample, true); pos += 2; } } return new Blob([buffer], { type: "audio/wav" }); -} +} \ No newline at end of file diff --git a/assets/js/creations/pattern/pattern_state.js b/assets/js/creations/pattern/pattern_state.js index df65e212..f105c8e0 100644 --- a/assets/js/creations/pattern/pattern_state.js +++ b/assets/js/creations/pattern/pattern_state.js @@ -1,5 +1,10 @@ // js/pattern_state.js import * as Tone from "https://esm.sh/tone"; +import { TripleOscillator } from "../../audio/plugins/TripleOscillator.js"; +import { Kicker } from "../../audio/plugins/Kicker.js"; +import { Lb302 } from "../../audio/plugins/Lb302.js"; +import { Nes } from "../../audio/plugins/Nes.js"; +import { SuperSaw } from "../../audio/plugins/SuperSaw.js"; import { appState } from "../state.js"; import { DEFAULT_VOLUME, DEFAULT_PAN } from "../config.js"; @@ -7,29 +12,20 @@ import { getMainGainNode } from "../audio.js"; import { getTotalSteps } from "../utils.js"; export function initializePatternState() { - // Limpa players/buffers existentes appState.pattern.tracks.forEach(track => { try { track.player?.dispose(); } catch {} try { track.buffer?.dispose?.(); } catch {} + try { track.instrument?.dispose(); } catch {} }); - // Reseta estado do editor de pattern appState.pattern.tracks = []; appState.pattern.activeTrackId = null; appState.pattern.activePatternIndex = 0; } export async function loadAudioForTrack(track) { - if (!track.samplePath) return track; - + // 1. Garante a criação dos nós de Volume e Pan try { - // 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) @@ -45,30 +41,135 @@ export async function loadAudioForTrack(track) { track.pannerNode.pan.value = track.pan ?? 0; } - // Encadeia: Volume(dB) -> Panner -> Master + try { track.instrument?.disconnect(); } catch {} + try { track.player?.disconnect(); } catch {} 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 + } catch (e) { + console.error("Erro ao criar nós de áudio base:", e); + } + + // --- DETECÇÃO DE TIPO DE ARQUIVO --- + // Verifica se é um formato de áudio que o navegador suporta + // Verifica tipo de arquivo + const isStandardAudio = track.samplePath && /\.(wav|mp3|ogg|flac|m4a)$/i.test(track.samplePath); + const isDrumSynth = track.samplePath && /\.ds$/i.test(track.samplePath); + + // Se não for áudio (ou for .ds/.xpf), é Plugin + if (!track.samplePath || !isStandardAudio) { + try { + if (track.instrument) { try { track.instrument.dispose(); } catch {} } + + let synth; + const name = (track.instrumentName || "").toLowerCase(); + + // DADOS DO PLUGIN (Tenta parsear XML se for string, ou usa vazio) + // Dica: O ideal seria ter um parser real aqui, mas vamos passar {} por enquanto + const pluginData = {}; + + // SELETOR DE PLUGINS + switch (name) { + case "tripleoscillator": + case "3osc": + synth = new TripleOscillator(Tone.getContext(), pluginData); + break; + + case "kicker": + synth = new Kicker(Tone.getContext(), pluginData); + break; + + case "lb302": + synth = new Lb302(Tone.getContext(), pluginData); + break; + + case "nes": + case "freeboy": // Freeboy é parecido com NES + case "papu": // Papu também é Gameboy + case "sid": // SID é 8-bit também, usaremos NES como fallback por enquanto + synth = new Nes(Tone.getContext(), pluginData); + break; + + // --- PACOTE SUPER SAW --- + case "zynaddsubfx": // O clássico + case "watsyn": // Wavetable Synth + case "monstro": // 3 Osciladores monstruosos + case "vibedstrings": // Strings vibrantes (fatsaw funciona bem como base) + case "SuperSaw": + synth = new SuperSaw(Tone.getContext(), pluginData); + break; + + case "organic": // Fallback simples para Organic (Additive) + synth = new Tone.PolySynth(Tone.Synth, { + oscillator: { type: "sine", count: 8, spread: 20 } + }); + break; + + case "zynaddsubfx": // O monstro! + // Zyn é impossível de clonar rápido. Vamos usar um "SuperSaw" gordo como placeholder + synth = new Tone.PolySynth(Tone.Synth, { + oscillator: { type: "fatsawtooth", count: 3, spread: 30 }, + envelope: { attack: 0.1, decay: 0.3, sustain: 0.8, release: 1 } + }); + break; + + default: + console.warn(`Plugin ${name} desconhecido, usando fallback.`); + // Fallback genérico + synth = new Tone.PolySynth(Tone.Synth, { oscillator: { type: "triangle" } }); + } + + // Se o plugin criou uma classe wrapper (nossas classes .js), + // ele tem o método .connect. Se for Tone nativo (Organic/Zyn fallback), também tem. + if (synth.output) { + // Nossas classes customizadas + synth.connect(track.volumeNode); + } else { + // Objetos Tone.js puros + synth.connect(track.volumeNode); + } + + track.instrument = synth; + track.player = null; + track.type = 'plugin'; + + console.log(`[Audio] Plugin carregado: ${name}`); + + } catch (e) { + console.error("Erro ao carregar plugin:", name, e); + } + return track; + } + + // 3. Lógica para SAMPLERS (Arquivos de Áudio Reais) + try { + try { track.player?.dispose(); } catch {} + track.player = null; + try { track.buffer?.dispose?.(); } catch {} + track.buffer = null; + + if (track.instrument) { + try { track.instrument.dispose(); } catch {} + track.instrument = null; + } + + const player = new Tone.Player({ url: track.samplePath, autostart: false }); + await player.load(track.samplePath); - // 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; + track.type = 'sampler'; // Garante o tipo correto } 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; @@ -85,6 +186,7 @@ export function addTrackToState() { id: Date.now() + Math.random(), name: "novo instrumento", samplePath: null, + type: 'plugin', // Padrão player: null, buffer: null, patterns: referenceTrack @@ -97,14 +199,14 @@ export function addTrackToState() { activePatternIndex: 0, volume: DEFAULT_VOLUME, pan: DEFAULT_PAN, - // Opção B: controlar volume em dB volumeNode: new Tone.Volume(Tone.gainToDb(DEFAULT_VOLUME)), pannerNode: new Tone.Panner(DEFAULT_PAN), }; - // Cadeia de áudio nova newTrack.volumeNode.connect(newTrack.pannerNode); newTrack.pannerNode.connect(getMainGainNode()); + + loadAudioForTrack(newTrack); appState.pattern.tracks.push(newTrack); appState.pattern.activeTrackId = newTrack.id; @@ -116,6 +218,7 @@ export function removeLastTrackFromState() { try { trackToRemove.player?.dispose(); } catch {} try { trackToRemove.buffer?.dispose?.(); } catch {} + try { trackToRemove.instrument?.dispose(); } catch {} try { trackToRemove.pannerNode?.disconnect(); } catch {} try { trackToRemove.volumeNode?.disconnect(); } catch {} @@ -131,8 +234,11 @@ export async function updateTrackSample(trackIndex, samplePath) { if (track) { track.samplePath = samplePath; track.name = samplePath.split("/").pop(); + + // Reseta o tipo baseado no novo arquivo + const isAudio = /\.(wav|mp3|ogg|flac|m4a)$/i.test(samplePath); + track.type = isAudio ? 'sampler' : 'plugin'; - // (re)carrega e reconecta corretamente o player nesta trilha await loadAudioForTrack(track); } } @@ -145,4 +251,4 @@ export function toggleStepState(trackIndex, stepIndex) { activePattern.steps[stepIndex] = !activePattern.steps[stepIndex]; } } -} +} \ No newline at end of file diff --git a/assets/js/creations/pattern/pattern_ui.js b/assets/js/creations/pattern/pattern_ui.js index 936b1e61..9c91a489 100644 --- a/assets/js/creations/pattern/pattern_ui.js +++ b/assets/js/creations/pattern/pattern_ui.js @@ -1,7 +1,7 @@ // js/pattern_ui.js import { appState } from "../state.js"; import { -    updateTrackSample + updateTrackSample } from "./pattern_state.js"; import { playSample, stopPlayback } from "./pattern_audio.js"; import { getTotalSteps } from "../utils.js"; @@ -10,199 +10,292 @@ 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 = ""; - // (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 + appState.pattern.tracks.forEach((trackData, trackIndex) => { + const trackLane = document.createElement("div"); + trackLane.className = "track-lane"; + trackLane.dataset.trackIndex = trackIndex; -    if (trackData.id === appState.pattern.activeTrackId) { -        trackLane.classList.add('active-track'); -    } + if (trackData.id === appState.pattern.activeTrackId) { + trackLane.classList.add('active-track'); + } -    trackLane.innerHTML = ` -     
-        -       
-        ${trackData.name} -     
-     
-       
-         
-          VOL -       
-       
-         
-          PAN -       
-     
-     
-    `; + trackLane.innerHTML = ` +
+ +
+ ${trackData.name} +
+
+
+
+ VOL +
+
+
+ PAN +
+
+
+ `; - // (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('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("dragover", (e) => { e.preventDefault(); trackLane.classList.add("drag-over"); }); + trackLane.addEventListener("dragleave", () => trackLane.classList.remove("drag-over")); - // (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) { + 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(); + 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 trackIndex = parseInt(parentTrackElement.dataset.trackIndex, 10); // (V7) -    // ... dentro da função 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 = ""; + } + + const parentTrackElement = wrapper.closest(".track-lane"); + const trackIndex = parseInt(parentTrackElement.dataset.trackIndex, 10); + const trackData = appState.pattern.tracks[trackIndex]; -    const trackData = appState.pattern.tracks[trackIndex]; - -    if (!trackData || !trackData.patterns || trackData.patterns.length === 0) { -      sequencerContainer.innerHTML = ""; return; -    } + if (!trackData || !trackData.patterns || trackData.patterns.length === 0) { + return; + } const activePatternIndex = trackData.activePatternIndex; -    const activePattern = trackData.patterns[activePatternIndex]; + const activePattern = trackData.patterns[activePatternIndex]; -    if (!activePattern) { -        sequencerContainer.innerHTML = ""; return; -    } - -    const patternSteps = activePattern.steps; - - // --- INÍCIO DA CORREÇÃO --- - // Precisamos verificar se 'patternSteps' é um array real. - // Se for 'null' ou 'undefined' (um bug de dados do .mmp), - // o loop 'for' abaixo quebraria ANTES de limpar a UI. - if (!patternSteps || !Array.isArray(patternSteps)) { - // Limpa a UI (remove os steps antigos) - sequencerContainer.innerHTML = ""; - // E para a execução desta track, deixando o sequenciador vazio. - return; + if (!activePattern) { + return; } - // --- FIM DA CORREÇÃO --- -    sequencerContainer.innerHTML = ""; // Agora é seguro limpar a UI -    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"); -      } + // ============================================================ + // LÓGICA DE DECISÃO V2: STEPS OU PIANO ROLL? + // ============================================================ + + const notes = activePattern.notes || []; + const hasNotes = notes.length > 0; + let renderMode = 'steps'; -      stepElement.addEventListener("click", () => { - initializeAudioContext(); // (V8) + if (hasNotes) { + const firstKey = notes[0].key; + const isMelodic = notes.some(n => n.key !== firstKey); + const hasLongNotes = notes.some(n => n.len > 48); + + // Sobreposição de notas (Acordes) + const sortedNotes = [...notes].sort((a, b) => a.pos - b.pos); + let hasOverlap = false; + for (let i = 0; i < sortedNotes.length - 1; i++) { + if (sortedNotes[i].pos + sortedNotes[i].len > sortedNotes[i+1].pos) { + hasOverlap = true; + break; + } + } - const currentState = activePattern.steps[i] || false; - const isActive = !currentState; + if (isMelodic || hasLongNotes || hasOverlap) { + renderMode = 'piano_roll'; + } else { + renderMode = 'steps'; + } + } - sendAction({ // (V7) - type: 'TOGGLE_NOTE', - trackIndex: trackIndex, - patternIndex: activePatternIndex, - stepIndex: i, - isActive: isActive + // ============================================================ + // RENDERIZAÇÃO + // ============================================================ + + if (renderMode === 'piano_roll') { + // --- MODO 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"; + + miniView.addEventListener('dblclick', (e) => { + e.stopPropagation(); + if (window.openPianoRoll) { + window.openPianoRoll(trackData.id); + } }); -        if (isActive && trackData && trackData.samplePath) { -          playSample(trackData.samplePath, trackData.id); -        } -      }); + activePattern.notes.forEach(note => { + const noteEl = document.createElement('div'); + noteEl.className = 'mini-note'; + + const barsInput = document.getElementById('bars-input'); + const barsCount = barsInput ? parseInt(barsInput.value) || 1 : 1; + const totalTicks = 192 * barsCount; + + const leftPercent = (note.pos / totalTicks) * 100; + const widthPercent = (note.len / totalTicks) * 100; + + const keyRange = 48; + const baseKey = 36; + let relativeKey = note.key - baseKey; + + if(relativeKey < 0) relativeKey = 0; + if(relativeKey > keyRange) relativeKey = keyRange; + + const topPercent = 100 - ((relativeKey / keyRange) * 100); -      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"); -      } + noteEl.style.left = `${leftPercent}%`; + noteEl.style.width = `${widthPercent}%`; + noteEl.style.top = `${topPercent}%`; + + miniView.appendChild(noteEl); + }); -      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); -    } -  }); + sequencerContainer.appendChild(miniView); + + } else { + // --- MODO STEP SEQUENCER --- + sequencerContainer.classList.remove('mode-piano'); + + const patternSteps = activePattern.steps; + if (!patternSteps || !Array.isArray(patternSteps)) return; + + 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 CLIQUE (CORRIGIDO PARA PLUGINS) --- + stepElement.addEventListener("click", (e) => { + e.stopPropagation(); + initializeAudioContext(); + + const currentState = activePattern.steps[i] || false; + const isActive = !currentState; + + sendAction({ + type: 'TOGGLE_NOTE', + trackIndex: trackIndex, + patternIndex: activePatternIndex, + stepIndex: i, + isActive: isActive + }); + + // --- AQUI ESTAVA O PROBLEMA: TOCA O SOM --- + if (isActive) { + // Caso 1: Sampler (Áudio) + if (trackData.type === 'sampler' && trackData.samplePath) { + playSample(trackData.samplePath, trackData.id); + } + // Caso 2: Plugin (Sintetizador) + else if (trackData.type === 'plugin' && trackData.instrument) { + // Toca um C5 (Dó) curto para feedback visual/sonoro + try { + // triggerAttackRelease(nota, duração) + trackData.instrument.triggerAttackRelease("C5", "16n"); + } catch(err) { + console.warn("Erro ao tocar preview do synth:", err); + } + } + } + }); + + 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 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); + } + } + }); } export function updateStepUI(trackIndex, patternIndex, stepIndex, isActive) { - // --- INÍCIO DA CORREÇÃO --- - // A lógica antiga (if (patternIndex !== appState.pattern.activePatternIndex)) - // estava errada, pois usava uma variável global. - const trackElement = document.querySelector(`.track-lane[data-track-index="${trackIndex}"]`); if (!trackElement) return; const trackData = appState.pattern.tracks[trackIndex]; if (!trackData) return; - // A UI só deve ser atualizada cirurgicamente se o pattern clicado - // for o MESMO pattern que está VISÍVEL no sequenciador dessa trilha. - if (patternIndex !== trackData.activePatternIndex) { - // O estado mudou, mas não é o pattern que estamos vendo, - // então não faz nada na UI (mas o estado no appState está correto). - return; + const activePattern = trackData.patterns[patternIndex]; + + const notes = activePattern.notes || []; + const hasNotes = notes.length > 0; + let isComplex = false; + + if (hasNotes) { + const isMelodic = notes.some(n => n.key !== notes[0].key); + const hasLongNotes = notes.some(n => n.len > 48); + if (isMelodic || hasLongNotes) isComplex = true; } - // --- FIM DA CORREÇÃO --- + + if (isComplex) { + redrawSequencer(); + return; + } + + if (patternIndex !== trackData.activePatternIndex) return; const stepWrapper = trackElement.querySelector( `.step-sequencer .step-wrapper:nth-child(${stepIndex + 1})` @@ -217,38 +310,27 @@ export function updateGlobalPatternSelector() { const globalPatternSelector = document.getElementById('global-pattern-selector'); if (!globalPatternSelector) return; - // 1. Encontra a track que está ATIVA no momento const activeTrackId = appState.pattern.activeTrackId; const activeTrack = appState.pattern.tracks.find(t => t.id === activeTrackId); - - // 2. Usa a track[0] como referência para os NOMES dos patterns const referenceTrack = appState.pattern.tracks[0]; - globalPatternSelector.innerHTML = ''; // Limpa as anteriores + globalPatternSelector.innerHTML = ''; if (referenceTrack && referenceTrack.patterns.length > 0) { - - // 3. Popula a lista de + + + + + + +
+
+ +
+ +
+ +
+
+
Editor de Amostras de Áudio @@ -486,6 +520,220 @@ document.body.classList.add("embed-mode"); } +