plugin v1.0.0

This commit is contained in:
JotaChina committed 2025-11-20 20:54:29 -03:00
1 parent 12c5b55bce
commit 44fce48bb5
12 files changed
+1287 -606

No files matched your search

+176 -5
View File
@@ -84,6 +84,131 @@ body.slice-tool-active .timeline-container { cursor: crosshair; }
position: relative; 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) /* BARRA LATERAL (SAMPLE BROWSER)
/* =============================================== */ /* =============================================== */
@@ -296,6 +421,7 @@ body.sidebar-hidden .sample-browser {
} }
.track-lane.active-track { background-color: #40454d; } .track-lane.active-track { background-color: #40454d; }
.track-lane.drag-over { border: 2px dashed var(--accent-green); } .track-lane.drag-over { border: 2px dashed var(--accent-green); }
.track-lane .track-info { .track-lane .track-info {
display: flex; display: flex;
align-items: center; 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 { 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:hover { opacity: 0.8; }
.track-mute.active { background-color: var(--text-dark); opacity: 0.7; } .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; } .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; } /* --- CORREÇÃO DE LARGURA DOS STEPS --- */
.step-wrapper { position: relative; } .step-sequencer-wrapper {
.step-marker { position: absolute; top: -16px; left: 1px; font-size: .6rem; color: var(--text-dark); user-select: none; } flex-grow: 1;
.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; } /* 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-dark { background-color: #1e1e1e; }
.step:hover { background-color: #555; border-color: #888; } .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); } .step.active { background-color: var(--accent-green); border: 1px solid #fff; box-shadow: 0 0 8px var(--accent-green); }
+45
View File
@@ -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();
}
}
+49
View File
@@ -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();
}
}
+45
View File
@@ -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();
}
}
+51
View File
@@ -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();
}
}
+61 -94
View File
@@ -1,115 +1,82 @@
export class TripleOscillator { // js/plugins/TripleOscillator.js
constructor(audioCtx, data) { import * as Tone from "https://esm.sh/tone";
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;
// Dados do envelope (ADSR) ficam em 'elvol' (Envelope Volume) export class TripleOscillator {
this.env = data.elvol || {}; 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) { getWaveType(lmmsTypeIndex) {
// Mapeamento: 0=Sine, 1=Triangle, 2=Sawtooth, 3=Square, 4=Noise, 5=Exp const types = ["sine", "triangle", "sawtooth", "square", "sawtooth", "sawtooth"]; // Mapeamento básico
const types = [
"sine",
"triangle",
"sawtooth",
"square",
"sawtooth",
"sawtooth",
];
const idx = parseInt(lmmsTypeIndex); const idx = parseInt(lmmsTypeIndex);
return types[isNaN(idx) ? 0 : idx]; // Default para sine return types[isNaN(idx) ? 0 : idx];
} }
playNote(midiNote, startTime, duration) { // Método chamado pela UI (Step Sequencer)
// Fórmula de conversão MIDI -> Frequência triggerAttackRelease(note, duration, time) {
const freq = 440 * Math.pow(2, (midiNote - 69) / 12); 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) // Cria os 3 osciladores efêmeros (só para esta nota)
const masterGain = this.ctx.createGain(); // Isso é necessário para polifonia (cada nota tem seus osciladores)
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...)
for (let i = 0; i < 3; i++) { 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 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) { if (vol > 0) {
const osc = this.ctx.createOscillator(); const osc = new Tone.Oscillator({
const oscGain = this.ctx.createGain(); 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 // Conecta Osc -> Envelope (Mestre)
osc.type = this.getWaveType(this.params[`wavetype${i}`]); osc.connect(this.envelope);
// Configura Afinação (Coarse = Semitons, Fine = Cents) osc.start(now);
const coarse = parseInt(this.params[`coarse${i}`] || 0); osc.stop(now + dur + this.envelope.release);
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
} }
} }
// Dispara o envelope
this.envelope.triggerAttackRelease(dur, now);
} }
applyEnvelope(gainNode, time, duration) { connect(dest) {
// Valores padrão do LMMS se não existirem no JSON this.output.connect(dest);
// 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); dispose() {
let dec = parseFloat(this.env.dec || 0.5); this.output.dispose();
let sus = parseFloat(this.env.sustain || 0.5); this.envelope.dispose();
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);
} }
} }
+123 -265
View File
@@ -5,16 +5,12 @@ import { renderAll, getSamplePathMap } from "./ui.js";
import { DEFAULT_PAN, DEFAULT_VOLUME, NOTE_LENGTH } from "./config.js"; import { DEFAULT_PAN, DEFAULT_VOLUME, NOTE_LENGTH } from "./config.js";
import { import {
initializeAudioContext, initializeAudioContext,
getAudioContext,
getMainGainNode, getMainGainNode,
} from "./audio.js"; } from "./audio.js";
import * as Tone from "https://esm.sh/tone"; import * as Tone from "https://esm.sh/tone";
// --- NOVA IMPORTAÇÃO ---
import { sendAction } from "./socket.js"; import { sendAction } from "./socket.js";
// --- NOVA ADIÇÃO ---
// Conteúdo do 'teste.mmp' (projeto em branco)
const BLANK_PROJECT_XML = `<?xml version="1.0"?> const BLANK_PROJECT_XML = `<?xml version="1.0"?>
<!DOCTYPE lmms-project> <!DOCTYPE lmms-project>
<lmms-project type="song" version="1.0" creatorversion="1.2.2" creator="LMMS"> <lmms-project type="song" version="1.0" creatorversion="1.2.2" creator="LMMS">
@@ -27,43 +23,32 @@ const BLANK_PROJECT_XML = `<?xml version="1.0"?>
</song> </song>
</lmms-project>`; </lmms-project>`;
/**
* 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() { export function handleLocalProjectReset() {
console.log("Recebido comando de reset. Limpando estado local..."); console.log("Recebido comando de reset. Limpando estado local...");
// 1. Limpa o backup da sessão
if (window.ROOM_NAME) { if (window.ROOM_NAME) {
try { try {
sessionStorage.removeItem(`temp_state_${window.ROOM_NAME}`); sessionStorage.removeItem(`temp_state_${window.ROOM_NAME}`);
console.log("Estado da sessão local limpo.");
} catch (e) { } catch (e) {
console.error("Falha ao limpar estado da sessão:", 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(); resetProjectState();
// 3. Reseta a UI global para os padrões
document.getElementById("bpm-input").value = 140; document.getElementById("bpm-input").value = 140;
document.getElementById("bars-input").value = 1; document.getElementById("bars-input").value = 1;
document.getElementById("compasso-a-input").value = 4; document.getElementById("compasso-a-input").value = 4;
document.getElementById("compasso-b-input").value = 4; document.getElementById("compasso-b-input").value = 4;
// 4. Renderiza a UI vazia renderAll();
renderAll(); // Isso deve redesenhar o editor de patterns vazio
console.log("Reset local concluído.");
} }
export async function handleFileLoad(file) { export async function handleFileLoad(file) {
let xmlContent = ""; let xmlContent = "";
try { try {
if (file.name.toLowerCase().endsWith(".mmpz")) { if (file.name.toLowerCase().endsWith(".mmpz")) {
// eslint-disable-next-line no-undef
const jszip = new JSZip(); const jszip = new JSZip();
const zip = await jszip.loadAsync(file); const zip = await jszip.loadAsync(file);
const projectFile = Object.keys(zip.files).find((name) => const projectFile = Object.keys(zip.files).find((name) =>
@@ -78,9 +63,6 @@ export async function handleFileLoad(file) {
xmlContent = await file.text(); 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 }); sendAction({ type: "LOAD_PROJECT", xml: xmlContent });
} catch (error) { } catch (error) {
console.error("Erro ao carregar o projeto:", 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}`); throw new Error(`Não foi possível carregar o arquivo ${fileName}`);
const xmlContent = await response.text(); const xmlContent = await response.text();
// ANTES:
// await parseMmpContent(xmlContent);
// return true;
// DEPOIS:
// Envia o XML para o servidor
sendAction({ type: "LOAD_PROJECT", xml: xmlContent }); sendAction({ type: "LOAD_PROJECT", xml: xmlContent });
return true; // Retorna true para que o modal de UI feche return true;
} catch (error) { } catch (error) {
console.error("Erro ao carregar projeto do servidor:", error); console.error("Erro ao carregar projeto do servidor:", error);
console.error(error);
alert(`Erro ao carregar projeto: ${error.message}`); alert(`Erro ao carregar projeto: ${error.message}`);
return false; 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) { export async function parseMmpContent(xmlString) {
resetProjectState(); resetProjectState();
initializeAudioContext(); initializeAudioContext();
appState.global.justReset = xmlString === BLANK_PROJECT_XML; 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"); const audioContainer = document.getElementById("audio-track-container");
if (audioContainer) { if (audioContainer) {
audioContainer.innerHTML = ""; audioContainer.innerHTML = "";
@@ -142,81 +112,52 @@ export async function parseMmpContent(xmlString) {
head.getAttribute("timesig_denominator") || 4; head.getAttribute("timesig_denominator") || 4;
} }
const allBBTrackNodes = Array.from( // --- CORREÇÃO DA SELEÇÃO DE TRACKS ---
xmlDoc.querySelectorAll(
'song > trackcontainer[type="song"] > track[type="1"]' // 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 --- // 2. Identifica os nomes dos patterns (colunas do B/B Editor) - tag <bbtco>
// O resetProjectState() [na linha 105] já limpou o appState.audio. // Precisamos disso para dar nome aos patterns e saber quantos criar
// No entanto, a UI (DOM) do editor de áudio não foi limpa. let sortedBBTrackNameNodes = [];
// Vamos forçar a limpeza do container aqui: if (bbEditorTrackNodes.length > 0) {
const audioContainer = document.getElementById("audio-track-container"); // Pega do primeiro editor encontrado
if (audioContainer) { sortedBBTrackNameNodes = Array.from(bbEditorTrackNodes[0].querySelectorAll("bbtco"))
audioContainer.innerHTML = ""; // Limpa a UI de áudio .sort((a, b) => {
} const posA = parseInt(a.getAttribute("pos"), 10) || 0;
// --- FIM DA CORREÇÃO --- const posB = parseInt(b.getAttribute("pos"), 10) || 0;
renderAll(); //
return; //
}
}
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; return posA - posB;
}); });
// --- 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.
// Define um nome global (pode usar o da primeira track, se existir)
appState.global.currentBeatBasslineName =
allBBTrackNodes[0]?.getAttribute("name") || "Beat/Bassline";
// 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));
} }
// 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));
}); });
// Se não achou NENHUM instrumento em NENHUMA bassline, encerra // 4. Identifica os instrumentos do Song Editor (Type 0 direto)
const songInstrumentTracks = Array.from(
xmlDoc.querySelectorAll('song > trackcontainer[type="song"] > track[type="0"]')
);
// Junta tudo
const allInstrumentTrackNodes = [...bbInstrumentTracks, ...songInstrumentTracks];
if (allInstrumentTrackNodes.length === 0) { if (allInstrumentTrackNodes.length === 0) {
appState.pattern.tracks = []; appState.pattern.tracks = [];
renderAll(); renderAll();
return; return;
} }
// --- FIM DA CORREÇÃO 1 ---
// Define um nome padrão para referência
appState.global.currentBeatBasslineName = "Main Project";
const pathMap = getSamplePathMap(); const pathMap = getSamplePathMap();
// Agora o map usa o array corrigido (allInstrumentTrackNodes)
newTracks = Array.from(allInstrumentTrackNodes) newTracks = Array.from(allInstrumentTrackNodes)
.map((trackNode) => { .map((trackNode) => {
const instrumentNode = trackNode.querySelector("instrument"); const instrumentNode = trackNode.querySelector("instrument");
@@ -224,48 +165,54 @@ export async function parseMmpContent(xmlString) {
if (!instrumentNode || !instrumentTrackNode) return null; if (!instrumentNode || !instrumentTrackNode) return null;
const trackName = trackNode.getAttribute("name"); const trackName = trackNode.getAttribute("name");
const instrumentName = instrumentNode.getAttribute("name");
if (instrumentNode.getAttribute("name") === "tripleoscillator") {
return null;
}
const allPatternsNodeList = trackNode.querySelectorAll("pattern"); const allPatternsNodeList = trackNode.querySelectorAll("pattern");
const allPatternsArray = Array.from(allPatternsNodeList).sort((a, b) => { const allPatternsArray = Array.from(allPatternsNodeList).sort((a, b) => {
const posA = parseInt(a.getAttribute("pos"), 10) || 0; const posA = parseInt(a.getAttribute("pos"), 10) || 0;
const posB = parseInt(b.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; 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 patternNode = allPatternsArray[index];
const bbTrackName = const bbTrackName = bbTrack.getAttribute("name") || `Pattern ${index + 1}`;
bbTrack.getAttribute("name") || `Pattern ${index + 1}`;
if (!patternNode) { if (!patternNode) {
const firstPattern = allPatternsArray[0];
const stepsLength = firstPattern
? parseInt(firstPattern.getAttribute("steps"), 10) || 16
: 16;
return { return {
name: bbTrackName, name: bbTrackName,
steps: new Array(stepsLength).fill(false), steps: new Array(16).fill(false),
notes: [],
pos: 0, pos: 0,
}; };
} }
const patternSteps = const patternSteps = parseInt(patternNode.getAttribute("steps"), 10) || 16;
parseInt(patternNode.getAttribute("steps"), 10) || 16;
const steps = new Array(patternSteps).fill(false); const steps = new Array(patternSteps).fill(false);
const notes = [];
const ticksPerStep = 12; const ticksPerStep = 12;
patternNode.querySelectorAll("note").forEach((noteNode) => { patternNode.querySelectorAll("note").forEach((noteNode) => {
const noteLocalPos = parseInt(noteNode.getAttribute("pos"), 10); const pos = parseInt(noteNode.getAttribute("pos"), 10);
const stepIndex = Math.round(noteLocalPos / ticksPerStep); 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) { if (stepIndex < patternSteps) {
steps[stepIndex] = true; steps[stepIndex] = true;
} }
@@ -274,16 +221,25 @@ export async function parseMmpContent(xmlString) {
return { return {
name: bbTrackName, name: bbTrackName,
steps: steps, steps: steps,
notes: notes,
pos: parseInt(patternNode.getAttribute("pos"), 10) || 0, pos: parseInt(patternNode.getAttribute("pos"), 10) || 0,
}; };
}); });
const hasNotes = patterns.some((p) => p.steps.includes(true)); // Verifica se tem notas em algum pattern
if (!hasNotes) return null; 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;
let finalSamplePath = null;
let trackType = "plugin";
if (instrumentName === "audiofileprocessor") {
trackType = "sampler";
const afpNode = instrumentNode.querySelector("audiofileprocessor"); const afpNode = instrumentNode.querySelector("audiofileprocessor");
const sampleSrc = afpNode ? afpNode.getAttribute("src") : null; const sampleSrc = afpNode ? afpNode.getAttribute("src") : null;
let finalSamplePath = null;
if (sampleSrc) { if (sampleSrc) {
const filename = sampleSrc.split("/").pop(); const filename = sampleSrc.split("/").pop();
if (pathMap[filename]) { if (pathMap[filename]) {
@@ -296,31 +252,21 @@ export async function parseMmpContent(xmlString) {
finalSamplePath = `src/samples/${cleanSrc}`; finalSamplePath = `src/samples/${cleanSrc}`;
} }
} }
}
const volFromFile = parseFloat(instrumentTrackNode.getAttribute("vol")); const volFromFile = parseFloat(instrumentTrackNode.getAttribute("vol"));
const panFromFile = parseFloat(instrumentTrackNode.getAttribute("pan")); const panFromFile = parseFloat(instrumentTrackNode.getAttribute("pan"));
const firstPatternWithNotesIndex = patterns.findIndex((p) =>
p.steps.includes(true)
);
return { return {
id: Date.now() + Math.random(), id: Date.now() + Math.random(),
name: trackName, name: trackName,
type: trackType,
samplePath: finalSamplePath, samplePath: finalSamplePath,
patterns: patterns, patterns: patterns,
// --- INÍCIO DA CORREÇÃO ---
// ANTES:
// activePatternIndex:
// firstPatternWithNotesIndex !== -1 ? firstPatternWithNotesIndex : 0, //
// DEPOIS (force o Padrão 1):
activePatternIndex: 0, activePatternIndex: 0,
// --- FIM DA CORREÇÃO ---
volume: !isNaN(volFromFile) ? volFromFile / 100 : DEFAULT_VOLUME, volume: !isNaN(volFromFile) ? volFromFile / 100 : DEFAULT_VOLUME,
pan: !isNaN(panFromFile) ? panFromFile / 100 : DEFAULT_PAN, pan: !isNaN(panFromFile) ? panFromFile / 100 : DEFAULT_PAN,
instrumentName: instrumentNode.getAttribute("name"), instrumentName: instrumentName,
instrumentXml: instrumentNode.innerHTML, instrumentXml: instrumentNode.innerHTML,
}; };
}) })
@@ -328,26 +274,19 @@ export async function parseMmpContent(xmlString) {
let isFirstTrackWithNotes = true; let isFirstTrackWithNotes = true;
newTracks.forEach((track) => { 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.volumeNode = new Tone.Volume(Tone.gainToDb(track.volume));
track.pannerNode = new Tone.Panner(track.pan); track.pannerNode = new Tone.Panner(track.pan);
// Cadeia de áudio: Volume(dB) -> Panner -> Saída Principal
track.volumeNode.connect(track.pannerNode); track.volumeNode.connect(track.pannerNode);
track.pannerNode.connect(getMainGainNode()); track.pannerNode.connect(getMainGainNode());
// --- FIM DA CORREÇÃO ---
if (isFirstTrackWithNotes) { if (isFirstTrackWithNotes) {
const activeIdx = track.activePatternIndex || 0; const activeIdx = track.activePatternIndex || 0;
const activePattern = track.patterns[activeIdx]; const activePattern = track.patterns[activeIdx];
if (activePattern) { if (activePattern && activePattern.steps) {
const firstPatternSteps = activePattern.steps.length; const stepsLength = activePattern.steps.length;
const stepsPerBar = 16; const requiredBars = Math.ceil(stepsLength / 16);
const requiredBars = Math.ceil(firstPatternSteps / stepsPerBar); document.getElementById("bars-input").value = requiredBars > 0 ? requiredBars : 1;
document.getElementById("bars-input").value =
requiredBars > 0 ? requiredBars : 1;
isFirstTrackWithNotes = false; isFirstTrackWithNotes = false;
} }
} }
@@ -364,45 +303,31 @@ export async function parseMmpContent(xmlString) {
appState.pattern.tracks = newTracks; appState.pattern.tracks = newTracks;
appState.pattern.activeTrackId = appState.pattern.tracks[0]?.id || null; 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; 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 { try {
const roomName = window.ROOM_NAME || "default_room"; const roomName = window.ROOM_NAME || "default_room";
const tempStateJSON = sessionStorage.getItem(`temp_state_${roomName}`); const tempStateJSON = sessionStorage.getItem(`temp_state_${roomName}`);
if (tempStateJSON) { 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); 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) => { appState.pattern.tracks.forEach((liveTrack) => {
// Encontra o track salvo correspondente
const savedTrack = tempState.pattern.tracks.find( const savedTrack = tempState.pattern.tracks.find(
(t) => t.id === liveTrack.id (t) => t.id === liveTrack.id
); );
if (savedTrack) { if (savedTrack) {
// Copia os dados do 'savedTrack' para o 'liveTrack'
liveTrack.name = savedTrack.name; liveTrack.name = savedTrack.name;
liveTrack.patterns = savedTrack.patterns; liveTrack.patterns = savedTrack.patterns;
liveTrack.activePatternIndex = savedTrack.activePatternIndex; liveTrack.activePatternIndex = savedTrack.activePatternIndex;
liveTrack.volume = savedTrack.volume; liveTrack.volume = savedTrack.volume;
liveTrack.pan = savedTrack.pan; liveTrack.pan = savedTrack.pan;
// ATUALIZA OS NÓS DO TONE.JS com os valores salvos!
if (liveTrack.volumeNode) { if (liveTrack.volumeNode) {
liveTrack.volumeNode.volume.value = Tone.gainToDb( liveTrack.volumeNode.volume.value = Tone.gainToDb(savedTrack.volume);
savedTrack.volume
);
} }
if (liveTrack.pannerNode) { if (liveTrack.pannerNode) {
liveTrack.pannerNode.pan.value = savedTrack.pan; 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) => appState.pattern.tracks = appState.pattern.tracks.filter((liveTrack) =>
tempState.pattern.tracks.some((t) => t.id === liveTrack.id) 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("bpm-input").value = tempState.global.bpm;
document.getElementById("compasso-a-input").value = document.getElementById("compasso-a-input").value = tempState.global.compassoA;
tempState.global.compassoA; document.getElementById("compasso-b-input").value = tempState.global.compassoB;
document.getElementById("compasso-b-input").value =
tempState.global.compassoB;
document.getElementById("bars-input").value = tempState.global.bars; document.getElementById("bars-input").value = tempState.global.bars;
// 4. Restaura o ID do track ativo
appState.pattern.activeTrackId = tempState.pattern.activeTrackId; appState.pattern.activeTrackId = tempState.pattern.activeTrackId;
console.log("Estado da sessão restaurado com sucesso.");
} }
} catch (e) { } catch (e) {
console.error( console.error("Erro ao restaurar sessão:", e);
"Erro ao restaurar estado da sessão (pode estar corrompido)",
e
);
if (window.ROOM_NAME) {
sessionStorage.removeItem(`temp_state_${window.ROOM_NAME}`);
} }
}
// --- FIM DA MÁGICA (V2.0) ---
// Agora sim, renderiza com o estado CORRIGIDO E MESCLADO
await Promise.resolve(); await Promise.resolve();
renderAll(); renderAll();
console.log("[UI] Projeto renderizado após parseMmpContent");
} }
export function generateMmpFile() { 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() { function generateXmlFromState() {
if (!appState.global.originalXmlDoc) { if (!appState.global.originalXmlDoc) {
// Se não houver XML original, precisamos gerar um novo console.warn("Não há XML original. Retornando vazio.");
// 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()
return ""; return "";
} }
@@ -474,76 +373,68 @@ function generateXmlFromState() {
if (head) { if (head) {
head.setAttribute("bpm", document.getElementById("bpm-input").value); head.setAttribute("bpm", document.getElementById("bpm-input").value);
head.setAttribute("num_bars", document.getElementById("bars-input").value); head.setAttribute("num_bars", document.getElementById("bars-input").value);
head.setAttribute( head.setAttribute("timesig_numerator", document.getElementById("compasso-a-input").value);
"timesig_numerator", head.setAttribute("timesig_denominator", document.getElementById("compasso-b-input").value);
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) { if (bbTrackContainer) {
bbTrackContainer bbTrackContainer.querySelectorAll('track[type="0"]').forEach((node) => node.remove());
.querySelectorAll('track[type="0"]')
.forEach((node) => node.remove());
const tracksXml = appState.pattern.tracks const tracksXml = appState.pattern.tracks
.map((track) => createTrackXml(track)) .map((track) => createTrackXml(track))
.join(""); .join("");
const tempDoc = new DOMParser().parseFromString(
`<root>${tracksXml}</root>`, // Gambiarra para inserir o XML gerado como nós DOM
"application/xml" const tempDoc = new DOMParser().parseFromString(`<root>${tracksXml}</root>`, "application/xml");
);
Array.from(tempDoc.documentElement.children).forEach((newTrackNode) => { Array.from(tempDoc.documentElement.children).forEach((newTrackNode) => {
bbTrackContainer.appendChild(newTrackNode); bbTrackContainer.appendChild(newTrackNode);
}); });
} }
const serializer = new XMLSerializer(); const serializer = new XMLSerializer();
return serializer.serializeToString(xmlDoc); 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() { export function syncPatternStateToServer() {
if (!window.ROOM_NAME) return; if (!window.ROOM_NAME) return;
const currentXml = generateXmlFromState(); const currentXml = generateXmlFromState();
sendAction({ type: "SYNC_PATTERN_STATE", xml: currentXml });
sendAction({ saveStateToSession();
type: "SYNC_PATTERN_STATE",
xml: currentXml,
});
// Salva o estado localmente também!
saveStateToSession(); // <-- ADICIONE ISSO
} }
function createTrackXml(track) { function createTrackXml(track) {
if (track.patterns.length === 0) return ""; if (track.patterns.length === 0) return "";
const ticksPerStep = 12; const ticksPerStep = 12;
const lmmsVolume = Math.round(track.volume * 100); const lmmsVolume = Math.round(track.volume * 100);
const lmmsPan = Math.round(track.pan * 100); const lmmsPan = Math.round(track.pan * 100);
const patternsXml = track.patterns
.map((pattern) => { const patternsXml = track.patterns.map((pattern) => {
const patternNotes = pattern.steps let patternNotesXml = "";
.map((isActive, index) => {
// 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 `<note vol="${note.vol}" len="${note.len}" pos="${note.pos}" pan="${note.pan}" key="${note.key}"/>`;
}).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) { if (isActive) {
const notePos = Math.round(index * ticksPerStep); const notePos = Math.round(index * ticksPerStep);
// Key 57 é o padrão do LMMS para samples (Lá)
return `<note vol="100" len="${NOTE_LENGTH}" pos="${notePos}" pan="0" key="57"/>`; return `<note vol="100" len="${NOTE_LENGTH}" pos="${notePos}" pan="0" key="57"/>`;
} }
return ""; return "";
}) }).join("\n ");
.join("\n "); }
return `<pattern type="0" pos="${pattern.pos}" muted="0" steps="${pattern.steps.length}" name="${pattern.name}"> return `<pattern type="0" pos="${pattern.pos}" muted="0" steps="${pattern.steps.length}" name="${pattern.name}">
${patternNotes} ${patternNotesXml}
</pattern>`; </pattern>`;
}) }).join("\n ");
.join("\n ");
return ` return `
<track type="0" solo="0" muted="0" name="${track.name}"> <track type="0" solo="0" muted="0" name="${track.name}">
<instrumenttrack vol="${lmmsVolume}" pitch="0" fxch="0" pitchrange="1" basenote="57" usemasterpitch="1" pan="${lmmsPan}"> <instrumenttrack vol="${lmmsVolume}" pitch="0" fxch="0" pitchrange="1" basenote="57" usemasterpitch="1" pan="${lmmsPan}">
@@ -557,42 +448,8 @@ function createTrackXml(track) {
} }
function modifyAndSaveExistingMmp() { function modifyAndSaveExistingMmp() {
console.log("Modificando arquivo .mmp existente..."); const content = generateXmlFromState();
const xmlDoc = appState.global.originalXmlDoc.cloneNode(true); downloadFile(content, "projeto_editado.mmp");
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(
`<root>${tracksXml}</root>`,
"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");
} }
function generateNewMmp() { function generateNewMmp() {
@@ -603,6 +460,7 @@ function generateNewMmp() {
const tracksXml = appState.pattern.tracks const tracksXml = appState.pattern.tracks
.map((track) => createTrackXml(track)) .map((track) => createTrackXml(track))
.join(""); .join("");
const mmpContent = `<?xml version="1.0"?> const mmpContent = `<?xml version="1.0"?>
<!DOCTYPE lmms-project> <!DOCTYPE lmms-project>
<lmms-project version="1.0" type="song" creator="MMPCreator" creatorversion="1.0"> <lmms-project version="1.0" type="song" creator="MMPCreator" creatorversion="1.0">
+120 -62
View File
@@ -8,6 +8,9 @@ import { initializeAudioContext } from "../audio.js";
const timerDisplay = document.getElementById("timer-display"); const timerDisplay = document.getElementById("timer-display");
// Variável para armazenar as "Parts" (sequências melódicas) do Tone.js
let activeParts = [];
function formatTime(milliseconds) { function formatTime(milliseconds) {
const totalSeconds = Math.floor(milliseconds / 1000); const totalSeconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(totalSeconds / 60) const minutes = Math.floor(totalSeconds / 60)
@@ -46,7 +49,7 @@ export function playSample(filePath, trackId) {
track.pannerNode.pan.value = track.pan ?? 0; track.pannerNode.pan.value = track.pan ?? 0;
} }
// Garante conexão: player -> volumeNode (não usar mais gainNode) // Garante conexão: player -> volumeNode
try { try {
track.player.disconnect(); track.player.disconnect();
} catch {} } catch {}
@@ -103,18 +106,30 @@ function tick() {
// Percorre tracks e toca o step atual se ativo // Percorre tracks e toca o step atual se ativo
appState.pattern.tracks.forEach((track) => { appState.pattern.tracks.forEach((track) => {
if (track.muted) return; // Respeita o Mute
if (!track.patterns || track.patterns.length === 0) return; if (!track.patterns || track.patterns.length === 0) return;
// IMPORTANTE: usar o pattern ativo da PRÓPRIA TRILHA
const activePattern = track.patterns[track.activePatternIndex]; const activePattern = track.patterns[track.activePatternIndex];
if ( // Verifica se o step atual está ativo
activePattern && if (activePattern && activePattern.steps[appState.global.currentStep]) {
activePattern.steps[appState.global.currentStep] &&
track.samplePath // CASO 1: SAMPLER (Arquivo de Áudio)
) { if (track.samplePath) {
playSample(track.samplePath, track.id); 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);
}
}
}
}); });
highlightStep(appState.global.currentStep, true); highlightStep(appState.global.currentStep, true);
@@ -125,6 +140,11 @@ export function startPlayback() {
if (appState.global.isPlaying || appState.pattern.tracks.length === 0) return; if (appState.global.isPlaying || appState.pattern.tracks.length === 0) return;
initializeAudioContext(); initializeAudioContext();
// Garante que o contexto do Tone esteja rodando
if (Tone.context.state !== "running") {
Tone.start();
}
if (appState.global.currentStep === 0) { if (appState.global.currentStep === 0) {
rewindPlayback(); rewindPlayback();
} }
@@ -136,6 +156,11 @@ export function startPlayback() {
if (appState.global.playbackIntervalId) if (appState.global.playbackIntervalId)
clearInterval(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; appState.global.isPlaying = true;
const playBtn = document.getElementById("play-btn"); const playBtn = document.getElementById("play-btn");
if (playBtn) { if (playBtn) {
@@ -154,6 +179,21 @@ export function stopPlayback() {
appState.global.playbackIntervalId = null; appState.global.playbackIntervalId = null;
appState.global.isPlaying = false; 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 document
.querySelectorAll(".step.playing") .querySelectorAll(".step.playing")
.forEach((s) => s.classList.remove("playing")); .forEach((s) => s.classList.remove("playing"));
@@ -173,6 +213,9 @@ export function rewindPlayback() {
? appState.global.currentStep - 1 ? appState.global.currentStep - 1
: getTotalSteps() - 1; : getTotalSteps() - 1;
appState.global.currentStep = 0; appState.global.currentStep = 0;
Tone.Transport.position = 0; // Reseta o tempo do Tone.js
if (!appState.global.isPlaying) { if (!appState.global.isPlaying) {
if (timerDisplay) timerDisplay.textContent = "00:00:00"; if (timerDisplay) timerDisplay.textContent = "00:00:00";
highlightStep(lastStep, false); 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() { 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 bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120;
const totalSteps = getTotalSteps(); const totalSteps = getTotalSteps();
const stepInterval = 60 / (bpm * 4); // Duração de 1 step (em segundos) const stepInterval = 60 / (bpm * 4);
const duration = totalSteps * stepInterval; // Duração total em segundos const duration = totalSteps * stepInterval;
// 2. Descobrir qual pattern está ativo (assume que todos estão no mesmo)
const activePatternIndex = const activePatternIndex =
appState.pattern.tracks[0]?.activePatternIndex || 0; appState.pattern.tracks[0]?.activePatternIndex || 0;
// 3. Renderizar offline usando Tone.Offline
const buffer = await Tone.Offline(async () => { const buffer = await Tone.Offline(async () => {
// ----------------------------------------------------
// Contexto de Áudio OFFLINE
// ----------------------------------------------------
const masterGain = new Tone.Gain().toDestination(); const masterGain = new Tone.Gain().toDestination();
// --- INÍCIO DA CORREÇÃO (Lógica de Polifonia) ---
// 1. Criamos as 'Parts'.
const offlineTracksParts = appState.pattern.tracks const offlineTracksParts = appState.pattern.tracks
.map((track) => { .map((track) => {
const pattern = track.patterns[activePatternIndex]; 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)) { 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; const trackBuffer = track.buffer;
// Cria a cadeia de áudio (Volume/Pan) para esta *trilha*
const panner = new Tone.Panner(track.pan).connect(masterGain); const panner = new Tone.Panner(track.pan).connect(masterGain);
const volume = new Tone.Volume(Tone.gainToDb(track.volume)).connect( const volume = new Tone.Volume(Tone.gainToDb(track.volume)).connect(
panner panner
); );
// Cria a lista de eventos (tempos em que as notas devem tocar)
const events = []; const events = [];
pattern.steps.forEach((isActive, stepIndex) => { pattern.steps.forEach((isActive, stepIndex) => {
if (isActive) { if (isActive) {
@@ -243,48 +323,30 @@ export async function renderActivePatternToBlob() {
} }
}); });
// Cria a Tone.Part
const part = new Tone.Part((time) => { const part = new Tone.Part((time) => {
// *** ESTA É A CORREÇÃO CRÍTICA *** new Tone.Player(trackBuffer)
// Para cada nota (cada 'time' na lista de 'events'), .connect(volume)
// nós criamos um PLAYER "ONE-SHOT" (descartável). .start(time);
// Isso permite que vários sons da mesma trilha }, events);
// se sobreponham (polifonia).
new Tone.Player(trackBuffer) // Usa o buffer carregado return part;
.connect(volume) // Conecta na cadeia de áudio (Volume->Pan->Master)
.start(time); // Toca no tempo agendado
}, events); // Passa a lista de tempos [0, 0.25, 0.5, ...]
return part; // Retorna a Part (que sabe quando disparar)
}) })
.filter((t) => t !== null); // Remove trilhas nulas .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) => { offlineTracksParts.forEach((part) => {
part.start(0); part.start(0);
}); });
// --- FIM DA CORREÇÃO ---
// Define o BPM do transporte offline
Tone.Transport.bpm.value = bpm; Tone.Transport.bpm.value = bpm;
// Inicia o transporte (para a renderização)
Tone.Transport.start(); Tone.Transport.start();
// ---------------------------------------------------- }, duration);
}, duration); // Duração total da renderização
// 5. Converte o AudioBuffer resultante em um Blob (arquivo .wav)
const blob = bufferToWave(buffer); const blob = bufferToWave(buffer);
return blob; return blob;
} }
// ========================================================================= // =========================================================================
// FUNÇÃO UTILITÁRIA: Converte AudioBuffer para Blob WAV // FUNÇÃO UTILITÁRIA: Converte AudioBuffer para Blob WAV (MANTIDO ORIGINAL)
// (Mantenha esta função como está)
// ========================================================================= // =========================================================================
function bufferToWave(abuffer) { function bufferToWave(abuffer) {
@@ -298,7 +360,6 @@ function bufferToWave(abuffer) {
let offset = 0; let offset = 0;
let pos = 0; let pos = 0;
// setAll e setString são helpers
function setAll(data) { function setAll(data) {
for (i = 0; i < data.length; i++) { for (i = 0; i < data.length; i++) {
view.setUint8(pos + i, data[i]); view.setUint8(pos + i, data[i]);
@@ -309,40 +370,37 @@ function bufferToWave(abuffer) {
setAll(s.split("").map((c) => c.charCodeAt(0))); setAll(s.split("").map((c) => c.charCodeAt(0)));
} }
// Cabeçalho WAV
setString("RIFF"); setString("RIFF");
view.setUint32(pos, length - 8, true); view.setUint32(pos, length - 8, true);
pos += 4; pos += 4;
setString("WAVE"); setString("WAVE");
setString("fmt "); setString("fmt ");
view.setUint32(pos, 16, true); view.setUint32(pos, 16, true);
pos += 4; // Sub-chunk size pos += 4;
view.setUint16(pos, 1, true); view.setUint16(pos, 1, true);
pos += 2; // Audio format 1 pos += 2;
view.setUint16(pos, numOfChan, true); view.setUint16(pos, numOfChan, true);
pos += 2; pos += 2;
view.setUint32(pos, abuffer.sampleRate, true); view.setUint32(pos, abuffer.sampleRate, true);
pos += 4; pos += 4;
view.setUint32(pos, abuffer.sampleRate * 2 * numOfChan, true); view.setUint32(pos, abuffer.sampleRate * 2 * numOfChan, true);
pos += 4; // Byte rate pos += 4;
view.setUint16(pos, numOfChan * 2, true); view.setUint16(pos, numOfChan * 2, true);
pos += 2; // Block align pos += 2;
view.setUint16(pos, 16, true); view.setUint16(pos, 16, true);
pos += 2; // Bits per sample pos += 2;
setString("data"); setString("data");
view.setUint32(pos, length - 44, true); view.setUint32(pos, length - 44, true);
pos += 4; pos += 4;
// Pega os dados dos canais
for (i = 0; i < numOfChan; i++) { for (i = 0; i < numOfChan; i++) {
channels.push(abuffer.getChannelData(i)); channels.push(abuffer.getChannelData(i));
} }
// Escreve os dados (intercalando canais)
for (i = 0; i < abuffer.length; i++) { for (i = 0; i < abuffer.length; i++) {
for (let j = 0; j < numOfChan; j++) { for (let j = 0; j < numOfChan; j++) {
sample = Math.max(-1, Math.min(1, channels[j][i])); 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); view.setInt16(pos, sample, true);
pos += 2; pos += 2;
} }
+127 -21
View File
@@ -1,5 +1,10 @@
// js/pattern_state.js // js/pattern_state.js
import * as Tone from "https://esm.sh/tone"; 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 { appState } from "../state.js";
import { DEFAULT_VOLUME, DEFAULT_PAN } from "../config.js"; import { DEFAULT_VOLUME, DEFAULT_PAN } from "../config.js";
@@ -7,29 +12,20 @@ import { getMainGainNode } from "../audio.js";
import { getTotalSteps } from "../utils.js"; import { getTotalSteps } from "../utils.js";
export function initializePatternState() { export function initializePatternState() {
// Limpa players/buffers existentes
appState.pattern.tracks.forEach(track => { appState.pattern.tracks.forEach(track => {
try { track.player?.dispose(); } catch {} try { track.player?.dispose(); } catch {}
try { track.buffer?.dispose?.(); } catch {} try { track.buffer?.dispose?.(); } catch {}
try { track.instrument?.dispose(); } catch {}
}); });
// Reseta estado do editor de pattern
appState.pattern.tracks = []; appState.pattern.tracks = [];
appState.pattern.activeTrackId = null; appState.pattern.activeTrackId = null;
appState.pattern.activePatternIndex = 0; appState.pattern.activePatternIndex = 0;
} }
export async function loadAudioForTrack(track) { export async function loadAudioForTrack(track) {
if (!track.samplePath) return track; // 1. Garante a criação dos nós de Volume e Pan
try { 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) { if (!track.volumeNode) {
track.volumeNode = new Tone.Volume( track.volumeNode = new Tone.Volume(
track.volume === 0 ? -Infinity : Tone.gainToDb(track.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; 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.volumeNode.disconnect(); } catch {}
try { track.pannerNode.disconnect(); } catch {} try { track.pannerNode.disconnect(); } catch {}
track.volumeNode.connect(track.pannerNode); track.volumeNode.connect(track.pannerNode);
track.pannerNode.connect(getMainGainNode()); track.pannerNode.connect(getMainGainNode());
// Cria e carrega o Player } 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 }); const player = new Tone.Player({ url: track.samplePath, autostart: false });
await player.load(track.samplePath); // garante buffer carregado await player.load(track.samplePath);
// Conecta o player ao volumeNode
player.connect(track.volumeNode); player.connect(track.volumeNode);
// Buffer separado (se você usar waveform em outro lugar)
const buffer = new Tone.Buffer(); const buffer = new Tone.Buffer();
await buffer.load(track.samplePath); await buffer.load(track.samplePath);
// Atribuições finais
track.player = player; track.player = player;
track.buffer = buffer; track.buffer = buffer;
track.type = 'sampler'; // Garante o tipo correto
} catch (error) { } catch (error) {
console.error('Erro ao carregar sample:', track.samplePath); 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.player?.dispose(); } catch {}
try { track.buffer?.dispose?.(); } catch {} try { track.buffer?.dispose?.(); } catch {}
track.player = null; track.player = null;
@@ -85,6 +186,7 @@ export function addTrackToState() {
id: Date.now() + Math.random(), id: Date.now() + Math.random(),
name: "novo instrumento", name: "novo instrumento",
samplePath: null, samplePath: null,
type: 'plugin', // Padrão
player: null, player: null,
buffer: null, buffer: null,
patterns: referenceTrack patterns: referenceTrack
@@ -97,15 +199,15 @@ export function addTrackToState() {
activePatternIndex: 0, activePatternIndex: 0,
volume: DEFAULT_VOLUME, volume: DEFAULT_VOLUME,
pan: DEFAULT_PAN, pan: DEFAULT_PAN,
// Opção B: controlar volume em dB
volumeNode: new Tone.Volume(Tone.gainToDb(DEFAULT_VOLUME)), volumeNode: new Tone.Volume(Tone.gainToDb(DEFAULT_VOLUME)),
pannerNode: new Tone.Panner(DEFAULT_PAN), pannerNode: new Tone.Panner(DEFAULT_PAN),
}; };
// Cadeia de áudio nova
newTrack.volumeNode.connect(newTrack.pannerNode); newTrack.volumeNode.connect(newTrack.pannerNode);
newTrack.pannerNode.connect(getMainGainNode()); newTrack.pannerNode.connect(getMainGainNode());
loadAudioForTrack(newTrack);
appState.pattern.tracks.push(newTrack); appState.pattern.tracks.push(newTrack);
appState.pattern.activeTrackId = newTrack.id; appState.pattern.activeTrackId = newTrack.id;
} }
@@ -116,6 +218,7 @@ export function removeLastTrackFromState() {
try { trackToRemove.player?.dispose(); } catch {} try { trackToRemove.player?.dispose(); } catch {}
try { trackToRemove.buffer?.dispose?.(); } catch {} try { trackToRemove.buffer?.dispose?.(); } catch {}
try { trackToRemove.instrument?.dispose(); } catch {}
try { trackToRemove.pannerNode?.disconnect(); } catch {} try { trackToRemove.pannerNode?.disconnect(); } catch {}
try { trackToRemove.volumeNode?.disconnect(); } catch {} try { trackToRemove.volumeNode?.disconnect(); } catch {}
@@ -132,7 +235,10 @@ export async function updateTrackSample(trackIndex, samplePath) {
track.samplePath = samplePath; track.samplePath = samplePath;
track.name = samplePath.split("/").pop(); track.name = samplePath.split("/").pop();
// (re)carrega e reconecta corretamente o player nesta trilha // Reseta o tipo baseado no novo arquivo
const isAudio = /\.(wav|mp3|ogg|flac|m4a)$/i.test(samplePath);
track.type = isAudio ? 'sampler' : 'plugin';
await loadAudioForTrack(track); await loadAudioForTrack(track);
} }
} }
+241 -159
View File
@@ -1,7 +1,7 @@
// js/pattern_ui.js // js/pattern_ui.js
import { appState } from "../state.js"; import { appState } from "../state.js";
import { import {
    updateTrackSample updateTrackSample
} from "./pattern_state.js"; } from "./pattern_state.js";
import { playSample, stopPlayback } from "./pattern_audio.js"; import { playSample, stopPlayback } from "./pattern_audio.js";
import { getTotalSteps } from "../utils.js"; import { getTotalSteps } from "../utils.js";
@@ -10,133 +10,205 @@ import { initializeAudioContext } from '../audio.js';
// Função principal de renderização para o editor de patterns // Função principal de renderização para o editor de patterns
export function renderPatternEditor() { export function renderPatternEditor() {
  const trackContainer = document.getElementById("track-container"); const trackContainer = document.getElementById("track-container");
  trackContainer.innerHTML = ""; trackContainer.innerHTML = "";
// (V7) Adicionado 'trackIndex' appState.pattern.tracks.forEach((trackData, trackIndex) => {
  appState.pattern.tracks.forEach((trackData, trackIndex) => { const trackLane = document.createElement("div");
    const trackLane = document.createElement("div"); trackLane.className = "track-lane";
    trackLane.className = "track-lane"; trackLane.dataset.trackIndex = trackIndex;
    trackLane.dataset.trackIndex = trackIndex; // (V7) Usando índice
    if (trackData.id === appState.pattern.activeTrackId) { if (trackData.id === appState.pattern.activeTrackId) {
        trackLane.classList.add('active-track'); trackLane.classList.add('active-track');
    } }
    trackLane.innerHTML = ` trackLane.innerHTML = `
      <div class="track-info"> <div class="track-info">
        <i class="fa-solid fa-gear"></i> <i class="fa-solid fa-gear"></i>
        <div class="track-mute"></div> <div class="track-mute"></div>
        <span class="track-name">${trackData.name}</span> <span class="track-name">${trackData.name}</span>
      </div> </div>
      <div class="track-controls"> <div class="track-controls">
        <div class="knob-container"> <div class="knob-container">
          <div class="knob" data-control="volume" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div> <div class="knob" data-control="volume" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
          <span>VOL</span> <span>VOL</span>
        </div> </div>
        <div class="knob-container"> <div class="knob-container">
          <div class="knob" data-control="pan" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div> <div class="knob" data-control="pan" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
          <span>PAN</span> <span>PAN</span>
        </div> </div>
      </div> </div>
      <div class="step-sequencer-wrapper"></div> <div class="step-sequencer-wrapper"></div>
    `; `;
// (Listener de clique da track é local, sem mudanças) trackLane.addEventListener('click', () => {
    trackLane.addEventListener('click', () => { if (appState.pattern.activeTrackId === trackData.id) return;
        if (appState.pattern.activeTrackId === trackData.id) return; stopPlayback();
        stopPlayback(); appState.pattern.activeTrackId = trackData.id;
        appState.pattern.activeTrackId = trackData.id; document.querySelectorAll('.track-lane').forEach(lane => lane.classList.remove('active-track'));
        document.querySelectorAll('.track-lane').forEach(lane => lane.classList.remove('active-track')); trackLane.classList.add('active-track');
        trackLane.classList.add('active-track'); updateGlobalPatternSelector();
        updateGlobalPatternSelector(); redrawSequencer();
        redrawSequencer(); });
    });
    trackLane.addEventListener("dragover", (e) => { e.preventDefault(); trackLane.classList.add("drag-over"); }); trackLane.addEventListener("dragover", (e) => { e.preventDefault(); trackLane.classList.add("drag-over"); });
    trackLane.addEventListener("dragleave", () => trackLane.classList.remove("drag-over")); trackLane.addEventListener("dragleave", () => trackLane.classList.remove("drag-over"));
// (V9) Listener de "drop" (arrastar) agora usa 'sendAction' trackLane.addEventListener("drop", (e) => {
    trackLane.addEventListener("drop", (e) => { e.preventDefault();
      e.preventDefault(); trackLane.classList.remove("drag-over");
      trackLane.classList.remove("drag-over"); const filePath = e.dataTransfer.getData("text/plain");
      const filePath = e.dataTransfer.getData("text/plain");
      if (filePath) {
      if (filePath) {
sendAction({ sendAction({
type: 'SET_TRACK_SAMPLE', type: 'SET_TRACK_SAMPLE',
trackIndex: trackIndex, trackIndex: trackIndex,
filePath: filePath filePath: filePath
}); });
      } }
    }); });
    trackContainer.appendChild(trackLane); trackContainer.appendChild(trackLane);
  }); });
 
  updateGlobalPatternSelector(); updateGlobalPatternSelector();
  redrawSequencer(); redrawSequencer();
} }
export function redrawSequencer() { export function redrawSequencer() {
  const totalGridSteps = getTotalSteps(); 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 trackData = appState.pattern.tracks[trackIndex]; document.querySelectorAll(".step-sequencer-wrapper").forEach((wrapper) => {
let sequencerContainer = wrapper.querySelector(".step-sequencer");
    if (!trackData || !trackData.patterns || trackData.patterns.length === 0) { if (!sequencerContainer) {
      sequencerContainer.innerHTML = ""; return; sequencerContainer = document.createElement("div");
    } sequencerContainer.className = "step-sequencer";
wrapper.appendChild(sequencerContainer);
const activePatternIndex = trackData.activePatternIndex; } else {
    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 = ""; sequencerContainer.innerHTML = "";
// E para a execução desta track, deixando o sequenciador vazio. }
const parentTrackElement = wrapper.closest(".track-lane");
const trackIndex = parseInt(parentTrackElement.dataset.trackIndex, 10);
const trackData = appState.pattern.tracks[trackIndex];
if (!trackData || !trackData.patterns || trackData.patterns.length === 0) {
return; return;
} }
// --- FIM DA CORREÇÃO ---
    sequencerContainer.innerHTML = ""; // Agora é seguro limpar a UI const activePatternIndex = trackData.activePatternIndex;
    for (let i = 0; i < totalGridSteps; i++) { const activePattern = trackData.patterns[activePatternIndex];
      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");
      }
      stepElement.addEventListener("click", () => { if (!activePattern) {
initializeAudioContext(); // (V8) return;
}
// ============================================================
// LÓGICA DE DECISÃO V2: STEPS OU PIANO ROLL?
// ============================================================
const notes = activePattern.notes || [];
const hasNotes = notes.length > 0;
let renderMode = 'steps';
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;
}
}
if (isMelodic || hasLongNotes || hasOverlap) {
renderMode = 'piano_roll';
} else {
renderMode = 'steps';
}
}
// ============================================================
// 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);
}
});
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);
noteEl.style.left = `${leftPercent}%`;
noteEl.style.width = `${widthPercent}%`;
noteEl.style.top = `${topPercent}%`;
miniView.appendChild(noteEl);
});
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 currentState = activePattern.steps[i] || false;
const isActive = !currentState; const isActive = !currentState;
sendAction({ // (V7) sendAction({
type: 'TOGGLE_NOTE', type: 'TOGGLE_NOTE',
trackIndex: trackIndex, trackIndex: trackIndex,
patternIndex: activePatternIndex, patternIndex: activePatternIndex,
@@ -144,65 +216,86 @@ export function redrawSequencer() {
isActive: isActive isActive: isActive
}); });
        if (isActive && trackData && trackData.samplePath) { // --- AQUI ESTAVA O PROBLEMA: TOCA O SOM ---
          playSample(trackData.samplePath, trackData.id); 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 beatsPerBar = parseInt(document.getElementById("compasso-a-input").value, 10) || 4;
      const groupIndex = Math.floor(i / beatsPerBar); const groupIndex = Math.floor(i / beatsPerBar);
      if (groupIndex % 2 === 0) { if (groupIndex % 2 === 0) {
        stepElement.classList.add("step-dark"); stepElement.classList.add("step-dark");
      } }
      const stepsPerBar = 16; const stepsPerBar = 16;
      if (i > 0 && i % stepsPerBar === 0) { if (i > 0 && i % stepsPerBar === 0) {
        const marker = document.createElement("div"); const marker = document.createElement("div");
        marker.className = "step-marker"; marker.className = "step-marker";
        marker.textContent = Math.floor(i / stepsPerBar) + 1; marker.textContent = Math.floor(i / stepsPerBar) + 1;
        stepWrapper.appendChild(marker); stepWrapper.appendChild(marker);
      } }
     
      stepWrapper.appendChild(stepElement); stepWrapper.appendChild(stepElement);
      sequencerContainer.appendChild(stepWrapper); sequencerContainer.appendChild(stepWrapper);
    } }
  }); }
});
} }
export function highlightStep(stepIndex, isActive) { export function highlightStep(stepIndex, isActive) {
  if (stepIndex < 0) return; if (stepIndex < 0) return;
  document.querySelectorAll(".track-lane").forEach((track) => { document.querySelectorAll(".track-lane").forEach((track) => {
    const stepWrapper = track.querySelector( const stepWrapper = track.querySelector(
      `.step-sequencer .step-wrapper:nth-child(${stepIndex + 1})` `.step-sequencer .step-wrapper:nth-child(${stepIndex + 1})`
    ); );
    if (stepWrapper) { if (stepWrapper) {
      const stepElement = stepWrapper.querySelector(".step"); const stepElement = stepWrapper.querySelector(".step");
      if (stepElement) { if (stepElement) {
        stepElement.classList.toggle("playing", isActive); stepElement.classList.toggle("playing", isActive);
      } }
    } }
  }); });
} }
export function updateStepUI(trackIndex, patternIndex, stepIndex, 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}"]`); const trackElement = document.querySelector(`.track-lane[data-track-index="${trackIndex}"]`);
if (!trackElement) return; if (!trackElement) return;
const trackData = appState.pattern.tracks[trackIndex]; const trackData = appState.pattern.tracks[trackIndex];
if (!trackData) return; if (!trackData) return;
// A UI só deve ser atualizada cirurgicamente se o pattern clicado const activePattern = trackData.patterns[patternIndex];
// for o MESMO pattern que está VISÍVEL no sequenciador dessa trilha.
if (patternIndex !== trackData.activePatternIndex) { const notes = activePattern.notes || [];
// O estado mudou, mas não é o pattern que estamos vendo, const hasNotes = notes.length > 0;
// então não faz nada na UI (mas o estado no appState está correto). 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;
}
if (isComplex) {
redrawSequencer();
return; return;
} }
// --- FIM DA CORREÇÃO ---
if (patternIndex !== trackData.activePatternIndex) return;
const stepWrapper = trackElement.querySelector( const stepWrapper = trackElement.querySelector(
`.step-sequencer .step-wrapper:nth-child(${stepIndex + 1})` `.step-sequencer .step-wrapper:nth-child(${stepIndex + 1})`
@@ -217,38 +310,27 @@ export function updateGlobalPatternSelector() {
const globalPatternSelector = document.getElementById('global-pattern-selector'); const globalPatternSelector = document.getElementById('global-pattern-selector');
if (!globalPatternSelector) return; if (!globalPatternSelector) return;
// 1. Encontra a track que está ATIVA no momento
const activeTrackId = appState.pattern.activeTrackId; const activeTrackId = appState.pattern.activeTrackId;
const activeTrack = appState.pattern.tracks.find(t => t.id === 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]; const referenceTrack = appState.pattern.tracks[0];
globalPatternSelector.innerHTML = ''; // Limpa as <options> anteriores globalPatternSelector.innerHTML = '';
if (referenceTrack && referenceTrack.patterns.length > 0) { if (referenceTrack && referenceTrack.patterns.length > 0) {
// 3. Popula a lista de <option>
referenceTrack.patterns.forEach((pattern, index) => { referenceTrack.patterns.forEach((pattern, index) => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = index; option.value = index;
option.textContent = pattern.name; // ex: "Pattern 1" option.textContent = pattern.name;
globalPatternSelector.appendChild(option); globalPatternSelector.appendChild(option);
}); });
// 4. CORREÇÃO PRINCIPAL: Define o item selecionado no <select>
if (activeTrack) { if (activeTrack) {
// O valor do seletor (ex: "2") deve ser igual ao índice
// do pattern ativo da track selecionada.
globalPatternSelector.value = activeTrack.activePatternIndex || 0; globalPatternSelector.value = activeTrack.activePatternIndex || 0;
} else { } else {
globalPatternSelector.value = 0; // Padrão globalPatternSelector.value = 0;
} }
globalPatternSelector.disabled = false; globalPatternSelector.disabled = false;
} else { } else {
// 5. Estado desabilitado (nenhum pattern)
const option = document.createElement('option'); const option = document.createElement('option');
option.textContent = 'Sem patterns'; option.textContent = 'Sem patterns';
globalPatternSelector.appendChild(option); globalPatternSelector.appendChild(option);
+1
View File
@@ -42,6 +42,7 @@ export let appState = {
pattern: patternState, // <-- AGORA 'patternState' está definido pattern: patternState, // <-- AGORA 'patternState' está definido
audio: audioState, audio: audioState,
}; };
window.appState = appState;
export function resetProjectState() { export function resetProjectState() {
console.log("Executando resetProjectState completo..."); console.log("Executando resetProjectState completo...");
+248
View File
@@ -285,6 +285,7 @@
<div class="tool-icons"> <div class="tool-icons">
<i class="fa-solid fa-table-cells"></i <i class="fa-solid fa-table-cells"></i
><i class="fa-solid fa-bars-staggered"></i> ><i class="fa-solid fa-bars-staggered"></i>
<i class="fa-solid fa-music" id="open-piano-roll-btn" title="Abrir Piano Roll"></i>
<i <i
class="fa-solid fa-wave-square" class="fa-solid fa-wave-square"
id="bounce-pattern-btn" id="bounce-pattern-btn"
@@ -316,6 +317,39 @@
</div> </div>
</div> </div>
<div id="track-container"></div> <div id="track-container"></div>
</div>
<div class="piano-roll-editor" id="piano-roll-editor" style="display: none;">
<div class="editor-header">
<span>Piano Roll - <span id="piano-roll-instrument-name">Instrumento 1</span></span>
<div class="window-controls">
<i class="fa-solid fa-xmark" id="close-piano-roll-btn"></i>
</div>
</div>
<div class="piano-roll-toolbar">
<div class="playback-controls">
<i class="fa-solid fa-pencil active" title="Draw Tool"></i>
<i class="fa-solid fa-eraser" title="Erase Tool"></i>
</div>
<div class="snap-controls">
<label>Snap:</label>
<select>
<option>1/4</option>
<option>1/8</option>
<option selected>1/16</option>
</select>
</div>
</div>
<div class="piano-roll-workspace">
<div class="piano-keys-container" id="piano-keys-container">
<canvas id="piano-keys-canvas"></canvas>
</div>
<div class="piano-grid-container" id="piano-grid-container">
<canvas id="piano-grid-canvas"></canvas>
</div>
</div>
</div> </div>
<div class="audio-editor"> <div class="audio-editor">
<div class="editor-header"> <div class="editor-header">
@@ -486,6 +520,220 @@
document.body.classList.add("embed-mode"); document.body.classList.add("embed-mode");
} }
</script> </script>
<script type="module">
// Importamos o estado global para ler/salvar notas reais
import { appState } from "./assets/js/creations/state.js"; // Ajuste o caminho se necessário
import { sendAction } from "./assets/js/creations/socket.js";
import { renderAll } from "./assets/js/creations/ui.js";
import * as Tone from "https://esm.sh/tone";
// Variáveis de Controle
let currentTrackId = null;
const CONSTANTS = {
NOTE_HEIGHT: 20,
KEY_WIDTH: 60,
BEAT_WIDTH: 40,
TOTAL_KEYS: 84, // 7 oitavas
START_NOTE: 24, // C1
TICKS_PER_PIXEL: 0 // Calculado dinamicamente
};
const pianoRollEditor = document.getElementById('piano-roll-editor');
const keysCanvas = document.getElementById('piano-keys-canvas');
const gridCanvas = document.getElementById('piano-grid-canvas');
const keysCtx = keysCanvas.getContext('2d');
const gridCtx = gridCanvas.getContext('2d');
const gridContainer = document.getElementById('piano-grid-container');
const keysContainer = document.getElementById('piano-keys-container');
// Sintetizador de preview
const previewSynth = new Tone.PolySynth(Tone.Synth).toDestination();
previewSynth.volume.value = -10;
// --- FUNÇÃO GLOBAL PARA ABRIR O EDITOR ---
window.openPianoRoll = function(trackId) {
const track = appState.pattern.tracks.find(t => t.id === trackId);
if (!track) return;
currentTrackId = trackId;
document.getElementById('piano-roll-instrument-name').textContent = track.name;
// Mostra o editor
pianoRollEditor.style.display = 'flex';
// Redimensiona e desenha
resizeCanvas();
// Centraliza o scroll vertical (C5)
const middleY = (CONSTANTS.TOTAL_KEYS / 2) * CONSTANTS.NOTE_HEIGHT;
gridContainer.scrollTop = middleY - 200;
};
// --- DESENHO E LÓGICA ---
function resizeCanvas() {
const totalHeight = CONSTANTS.TOTAL_KEYS * CONSTANTS.NOTE_HEIGHT;
// 64 compassos * 192 ticks / (ticks por beat) * largura... simplificando:
// Vamos fixar uma largura grande por enquanto
const totalWidth = 3000;
keysCanvas.width = CONSTANTS.KEY_WIDTH;
keysCanvas.height = totalHeight;
gridCanvas.width = totalWidth;
gridCanvas.height = totalHeight;
// Importante: Sincronizar a conversão de Pixel <-> Tick
// 1 Beat = 48 ticks (em 16th) ou 192 ticks por bar?
// No seu file.js: ticksPerStep = 12 (1/16).
// Então BEAT_WIDTH (40px) = 4 steps = 48 ticks.
CONSTANTS.TICKS_PER_PIXEL = 48 / CONSTANTS.BEAT_WIDTH;
drawKeys();
drawGrid();
drawNotes();
}
function drawKeys() {
keysCtx.clearRect(0, 0, keysCanvas.width, keysCanvas.height);
for (let i = 0; i < CONSTANTS.TOTAL_KEYS; i++) {
const midiNote = CONSTANTS.START_NOTE + (CONSTANTS.TOTAL_KEYS - 1 - i);
const y = i * CONSTANTS.NOTE_HEIGHT;
const isBlack = isBlackKey(midiNote);
keysCtx.fillStyle = isBlack ? '#333' : '#eee';
keysCtx.fillRect(0, y, CONSTANTS.KEY_WIDTH, CONSTANTS.NOTE_HEIGHT);
keysCtx.strokeStyle = '#555';
keysCtx.strokeRect(0, y, CONSTANTS.KEY_WIDTH, CONSTANTS.NOTE_HEIGHT);
if (midiNote % 12 === 0) {
keysCtx.fillStyle = isBlack ? '#fff' : '#333';
keysCtx.font = '10px Arial';
keysCtx.fillText('C' + (Math.floor(midiNote / 12) - 1), 35, y + 14);
}
}
}
function drawGrid() {
gridCtx.fillStyle = '#292929';
gridCtx.fillRect(0, 0, gridCanvas.width, gridCanvas.height);
// Linhas das notas
for (let i = 0; i < CONSTANTS.TOTAL_KEYS; i++) {
const midiNote = CONSTANTS.START_NOTE + (CONSTANTS.TOTAL_KEYS - 1 - i);
const y = i * CONSTANTS.NOTE_HEIGHT;
const isBlack = isBlackKey(midiNote);
gridCtx.fillStyle = isBlack ? '#222' : '#2a2a2a';
gridCtx.fillRect(0, y, gridCanvas.width, CONSTANTS.NOTE_HEIGHT);
gridCtx.strokeStyle = '#333';
gridCtx.beginPath(); gridCtx.moveTo(0, y); gridCtx.lineTo(gridCanvas.width, y); gridCtx.stroke();
}
// Linhas verticais (Tempo)
for (let x = 0; x < gridCanvas.width; x += CONSTANTS.BEAT_WIDTH) {
gridCtx.beginPath();
gridCtx.strokeStyle = (x % (CONSTANTS.BEAT_WIDTH * 4) === 0) ? '#666' : '#383838';
gridCtx.moveTo(x, 0); gridCtx.lineTo(x, gridCanvas.height); gridCtx.stroke();
}
}
function drawNotes() {
if (!currentTrackId) return;
const track = appState.pattern.tracks.find(t => t.id === currentTrackId);
if (!track) return;
const pattern = track.patterns[track.activePatternIndex];
const notes = pattern.notes || [];
gridCtx.fillStyle = '#ffbb00'; // Cor Laranja
gridCtx.strokeStyle = '#000';
notes.forEach(note => {
// Converter MIDI para Y (Invertido)
const keyIndex = (CONSTANTS.TOTAL_KEYS - 1) - (note.key - CONSTANTS.START_NOTE);
const y = keyIndex * CONSTANTS.NOTE_HEIGHT;
// Converter Ticks (pos) para X
// Se 48 ticks = BEAT_WIDTH (40px) -> pos / 1.2
const x = note.pos / CONSTANTS.TICKS_PER_PIXEL;
const width = note.len / CONSTANTS.TICKS_PER_PIXEL;
gridCtx.fillRect(x + 1, y + 1, width - 2, CONSTANTS.NOTE_HEIGHT - 2);
gridCtx.strokeRect(x + 1, y + 1, width - 2, CONSTANTS.NOTE_HEIGHT - 2);
});
}
// --- INTERAÇÃO: CRIAR NOTAS ---
gridCanvas.addEventListener('mousedown', (e) => {
if (!currentTrackId) return;
const rect = gridCanvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// 1. Calcular Nota (Y)
const keyIndex = Math.floor(y / CONSTANTS.NOTE_HEIGHT);
const midiNote = CONSTANTS.START_NOTE + (CONSTANTS.TOTAL_KEYS - 1 - keyIndex);
// 2. Calcular Posição (X) e Snap
// Snap padrão 1/16 = 12 ticks
const snapTicks = 12;
const rawTicks = x * CONSTANTS.TICKS_PER_PIXEL;
const quantizedPos = Math.floor(rawTicks / snapTicks) * snapTicks;
// 3. Criar Objeto Nota
const newNote = {
pos: quantizedPos,
len: 48, // Duração padrão (1 beat/seminima) - ajuste conforme desejar
key: midiNote,
vol: 100,
pan: 0
};
// 4. Atualizar Estado e Redesenhar
const track = appState.pattern.tracks.find(t => t.id === currentTrackId);
if (track) {
const pattern = track.patterns[track.activePatternIndex];
// Se não existir array de notas, cria
if (!pattern.notes) pattern.notes = [];
pattern.notes.push(newNote);
// Toca som
const noteName = Tone.Frequency(midiNote, "midi").toNote();
previewSynth.triggerAttackRelease(noteName, "8n");
// Redesenha Piano Roll
drawNotes();
// IMPORTANTE: Atualizar a UI da lista de trilhas (para aparecer a miniatura)
// E enviar via Socket para colaboradores
renderAll();
sendAction({
type: "UPDATE_PATTERN_NOTES",
trackId: currentTrackId,
patternIndex: track.activePatternIndex,
notes: pattern.notes
});
}
});
// Scroll Sync
gridContainer.addEventListener('scroll', () => {
keysContainer.scrollTop = gridContainer.scrollTop;
});
// Fechar
document.getElementById('close-piano-roll-btn').addEventListener('click', () => {
pianoRollEditor.style.display = 'none';
currentTrackId = null;
});
function isBlackKey(note) {
const n = note % 12;
return (n === 1 || n === 3 || n === 6 || n === 8 || n === 10);
}
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>