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
+1316 -635

No files matched your search

+138 -280
View File
@@ -5,16 +5,12 @@ import { renderAll, getSamplePathMap } from "./ui.js";
import { DEFAULT_PAN, DEFAULT_VOLUME, NOTE_LENGTH } from "./config.js";
import {
initializeAudioContext,
getAudioContext,
getMainGainNode,
} from "./audio.js";
import * as Tone from "https://esm.sh/tone";
// --- NOVA IMPORTAÇÃO ---
import { sendAction } from "./socket.js";
// --- NOVA ADIÇÃO ---
// Conteúdo do 'teste.mmp' (projeto em branco)
const BLANK_PROJECT_XML = `<?xml version="1.0"?>
<!DOCTYPE lmms-project>
<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>
</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() {
console.log("Recebido comando de reset. Limpando estado local...");
// 1. Limpa o backup da sessão
if (window.ROOM_NAME) {
try {
sessionStorage.removeItem(`temp_state_${window.ROOM_NAME}`);
console.log("Estado da sessão local limpo.");
} catch (e) {
console.error("Falha ao limpar estado da sessão:", e);
}
}
// 2. Reseta o estado da memória (appState)
// (Isso deve zerar o appState.pattern.tracks, etc)
resetProjectState();
// 3. Reseta a UI global para os padrões
document.getElementById("bpm-input").value = 140;
document.getElementById("bars-input").value = 1;
document.getElementById("compasso-a-input").value = 4;
document.getElementById("compasso-b-input").value = 4;
// 4. Renderiza a UI vazia
renderAll(); // Isso deve redesenhar o editor de patterns vazio
console.log("Reset local concluído.");
renderAll();
}
export async function handleFileLoad(file) {
let xmlContent = "";
try {
if (file.name.toLowerCase().endsWith(".mmpz")) {
// eslint-disable-next-line no-undef
const jszip = new JSZip();
const zip = await jszip.loadAsync(file);
const projectFile = Object.keys(zip.files).find((name) =>
@@ -78,9 +63,6 @@ export async function handleFileLoad(file) {
xmlContent = await file.text();
}
// ANTES: await parseMmpContent(xmlContent);
// DEPOIS:
// Envia o XML para o servidor, que o transmitirá para todos (incluindo nós)
sendAction({ type: "LOAD_PROJECT", xml: xmlContent });
} catch (error) {
console.error("Erro ao carregar o projeto:", error);
@@ -95,32 +77,20 @@ export async function loadProjectFromServer(fileName) {
throw new Error(`Não foi possível carregar o arquivo ${fileName}`);
const xmlContent = await response.text();
// ANTES:
// await parseMmpContent(xmlContent);
// return true;
// DEPOIS:
// Envia o XML para o servidor
sendAction({ type: "LOAD_PROJECT", xml: xmlContent });
return true; // Retorna true para que o modal de UI feche
return true;
} catch (error) {
console.error("Erro ao carregar projeto do servidor:", error);
console.error(error);
alert(`Erro ao carregar projeto: ${error.message}`);
return false;
}
}
// 'parseMmpContent' agora é chamado pelo 'socket.js'
// quando ele recebe a ação 'LOAD_PROJECT' ou 'load_project_state'.
export async function parseMmpContent(xmlString) {
resetProjectState();
initializeAudioContext();
appState.global.justReset = xmlString === BLANK_PROJECT_XML;
// Limpa manualmente a UI de áudio, pois resetProjectState()
// só limpa os *dados* (appState.audio.clips).
const audioContainer = document.getElementById("audio-track-container");
if (audioContainer) {
audioContainer.innerHTML = "";
@@ -142,81 +112,52 @@ export async function parseMmpContent(xmlString) {
head.getAttribute("timesig_denominator") || 4;
}
const allBBTrackNodes = Array.from(
xmlDoc.querySelectorAll(
'song > trackcontainer[type="song"] > track[type="1"]'
)
// --- CORREÇÃO DA SELEÇÃO DE TRACKS ---
// 1. Identifica as faixas containers de Beat/Bassline (Type 1)
const bbEditorTrackNodes = Array.from(
xmlDoc.querySelectorAll('song > trackcontainer[type="song"] > track[type="1"]')
);
if (allBBTrackNodes.length === 0) {
const allBBTrackNodes = Array.from(
xmlDoc.querySelectorAll(
'song > trackcontainer[type="song"] > track[type="1"]'
)
);
if (allBBTrackNodes.length === 0) {
appState.pattern.tracks = [];
// --- INÍCIO DA CORREÇÃO ---
// O resetProjectState() [na linha 105] já limpou o appState.audio.
// No entanto, a UI (DOM) do editor de áudio não foi limpa.
// Vamos forçar a limpeza do container aqui:
const audioContainer = document.getElementById("audio-track-container");
if (audioContainer) {
audioContainer.innerHTML = ""; // Limpa a UI de áudio
}
// --- FIM DA CORREÇÃO ---
renderAll(); //
return; //
}
// 2. Identifica os nomes dos patterns (colunas do B/B Editor) - tag <bbtco>
// Precisamos disso para dar nome aos patterns e saber quantos criar
let sortedBBTrackNameNodes = [];
if (bbEditorTrackNodes.length > 0) {
// Pega do primeiro editor encontrado
sortedBBTrackNameNodes = Array.from(bbEditorTrackNodes[0].querySelectorAll("bbtco"))
.sort((a, b) => {
const posA = parseInt(a.getAttribute("pos"), 10) || 0;
const posB = parseInt(b.getAttribute("pos"), 10) || 0;
return posA - posB;
});
}
const sortedBBTrackNameNodes = [...allBBTrackNodes].sort((a, b) => {
const bbtcoA = a.querySelector("bbtco");
const bbtcoB = a.querySelector("bbtco");
const posA = bbtcoA ? parseInt(bbtcoA.getAttribute("pos"), 10) : Infinity;
const posB = bbtcoB ? parseInt(bbtcoB.getAttribute("pos"), 10) : Infinity;
return posA - posB;
// 3. Identifica os instrumentos dentro do Beat/Bassline (Type 0 aninhado)
const bbInstrumentTracks = [];
bbEditorTrackNodes.forEach(container => {
const instruments = container.querySelectorAll('bbtrack > trackcontainer > track[type="0"]');
bbInstrumentTracks.push(...Array.from(instruments));
});
// --- INÍCIO DA CORREÇÃO 1: Lendo TODAS as Basslines (Tracks type="1") ---
// O bug anterior era que o código só lia os instrumentos (tracks type="0")
// da PRIMEIRA bassline encontrada (allBBTrackNodes[0]).
// A correção abaixo itera em TODAS as basslines (allBBTrackNodes.forEach)
// e coleta os instrumentos de CADA UMA delas.
// 4. Identifica os instrumentos do Song Editor (Type 0 direto)
const songInstrumentTracks = Array.from(
xmlDoc.querySelectorAll('song > trackcontainer[type="song"] > track[type="0"]')
);
// Define um nome global (pode usar o da primeira track, se existir)
appState.global.currentBeatBasslineName =
allBBTrackNodes[0]?.getAttribute("name") || "Beat/Bassline";
// Junta tudo
const allInstrumentTrackNodes = [...bbInstrumentTracks, ...songInstrumentTracks];
// Cria um array para guardar TODOS os instrumentos de TODAS as basslines
const allInstrumentTrackNodes = [];
// Loop em CADA bassline (allBBTrackNodes) em vez de apenas na [0]
allBBTrackNodes.forEach((bbTrackNode) => {
const bbTrackContainer = bbTrackNode.querySelector(
"bbtrack > trackcontainer"
);
if (bbTrackContainer) {
// Encontra os instrumentos (type="0") DENTRO desta bassline
const instrumentTracks =
bbTrackContainer.querySelectorAll('track[type="0"]');
// Adiciona os instrumentos encontrados ao array principal
allInstrumentTrackNodes.push(...Array.from(instrumentTracks));
}
});
// Se não achou NENHUM instrumento em NENHUMA bassline, encerra
if (allInstrumentTrackNodes.length === 0) {
appState.pattern.tracks = [];
renderAll();
return;
appState.pattern.tracks = [];
renderAll();
return;
}
// --- FIM DA CORREÇÃO 1 ---
// Define um nome padrão para referência
appState.global.currentBeatBasslineName = "Main Project";
const pathMap = getSamplePathMap();
// Agora o map usa o array corrigido (allInstrumentTrackNodes)
newTracks = Array.from(allInstrumentTrackNodes)
.map((trackNode) => {
const instrumentNode = trackNode.querySelector("instrument");
@@ -224,48 +165,54 @@ export async function parseMmpContent(xmlString) {
if (!instrumentNode || !instrumentTrackNode) return null;
const trackName = trackNode.getAttribute("name");
if (instrumentNode.getAttribute("name") === "tripleoscillator") {
return null;
}
const instrumentName = instrumentNode.getAttribute("name");
const allPatternsNodeList = trackNode.querySelectorAll("pattern");
const allPatternsArray = Array.from(allPatternsNodeList).sort((a, b) => {
const posA = parseInt(a.getAttribute("pos"), 10) || 0;
const posB = parseInt(b.getAttribute("pos"), 10) || 0;
// --- CORREÇÃO 2: Ordenação dos Patterns ---
// O bug aqui era `posB - posA`, que invertia a ordem dos patterns
// (o "Pattern 1" recebia as notas do "Pattern 8", etc.)
// `posA - posB` garante a ordem correta (crescente: P1, P2, P3...).
return posA - posB;
});
const patterns = sortedBBTrackNameNodes.map((bbTrack, index) => {
// Mapeia os patterns baseados nas colunas do B/B editor (sortedBBTrackNameNodes)
// Se não houver colunas B/B (ex: projeto só Song Editor), cria 1 pattern padrão
const patternsToCreate = sortedBBTrackNameNodes.length > 0 ? sortedBBTrackNameNodes : [{ getAttribute: () => "Pattern 1" }];
const patterns = patternsToCreate.map((bbTrack, index) => {
const patternNode = allPatternsArray[index];
const bbTrackName =
bbTrack.getAttribute("name") || `Pattern ${index + 1}`;
const bbTrackName = bbTrack.getAttribute("name") || `Pattern ${index + 1}`;
if (!patternNode) {
const firstPattern = allPatternsArray[0];
const stepsLength = firstPattern
? parseInt(firstPattern.getAttribute("steps"), 10) || 16
: 16;
return {
name: bbTrackName,
steps: new Array(stepsLength).fill(false),
steps: new Array(16).fill(false),
notes: [],
pos: 0,
};
}
const patternSteps =
parseInt(patternNode.getAttribute("steps"), 10) || 16;
const patternSteps = parseInt(patternNode.getAttribute("steps"), 10) || 16;
const steps = new Array(patternSteps).fill(false);
const notes = [];
const ticksPerStep = 12;
patternNode.querySelectorAll("note").forEach((noteNode) => {
const noteLocalPos = parseInt(noteNode.getAttribute("pos"), 10);
const stepIndex = Math.round(noteLocalPos / ticksPerStep);
const pos = parseInt(noteNode.getAttribute("pos"), 10);
const len = parseInt(noteNode.getAttribute("len"), 10);
const key = parseInt(noteNode.getAttribute("key"), 10);
const vol = parseInt(noteNode.getAttribute("vol"), 10);
const pan = parseInt(noteNode.getAttribute("pan"), 10);
notes.push({
pos: pos,
len: len,
key: key,
vol: vol,
pan: pan
});
const stepIndex = Math.round(pos / ticksPerStep);
if (stepIndex < patternSteps) {
steps[stepIndex] = true;
}
@@ -274,53 +221,52 @@ export async function parseMmpContent(xmlString) {
return {
name: bbTrackName,
steps: steps,
notes: notes,
pos: parseInt(patternNode.getAttribute("pos"), 10) || 0,
};
});
const hasNotes = patterns.some((p) => p.steps.includes(true));
if (!hasNotes) return null;
// Verifica se tem notas em algum pattern
const hasNotes = patterns.some((p) => p.notes.length > 0 || p.steps.includes(true));
// Opcional: Se quiser carregar tracks vazias, remova a linha abaixo
if (!hasNotes && patterns.length === 0) return null;
const afpNode = instrumentNode.querySelector("audiofileprocessor");
const sampleSrc = afpNode ? afpNode.getAttribute("src") : null;
let finalSamplePath = null;
if (sampleSrc) {
const filename = sampleSrc.split("/").pop();
if (pathMap[filename]) {
finalSamplePath = pathMap[filename];
} else {
let cleanSrc = sampleSrc;
if (cleanSrc.startsWith("samples/")) {
cleanSrc = cleanSrc.substring("samples/".length);
let trackType = "plugin";
if (instrumentName === "audiofileprocessor") {
trackType = "sampler";
const afpNode = instrumentNode.querySelector("audiofileprocessor");
const sampleSrc = afpNode ? afpNode.getAttribute("src") : null;
if (sampleSrc) {
const filename = sampleSrc.split("/").pop();
if (pathMap[filename]) {
finalSamplePath = pathMap[filename];
} else {
let cleanSrc = sampleSrc;
if (cleanSrc.startsWith("samples/")) {
cleanSrc = cleanSrc.substring("samples/".length);
}
finalSamplePath = `src/samples/${cleanSrc}`;
}
finalSamplePath = `src/samples/${cleanSrc}`;
}
}
}
const volFromFile = parseFloat(instrumentTrackNode.getAttribute("vol"));
const panFromFile = parseFloat(instrumentTrackNode.getAttribute("pan"));
const firstPatternWithNotesIndex = patterns.findIndex((p) =>
p.steps.includes(true)
);
return {
id: Date.now() + Math.random(),
name: trackName,
type: trackType,
samplePath: finalSamplePath,
patterns: patterns,
// --- INÍCIO DA CORREÇÃO ---
// ANTES:
// activePatternIndex:
// firstPatternWithNotesIndex !== -1 ? firstPatternWithNotesIndex : 0, //
// DEPOIS (force o Padrão 1):
activePatternIndex: 0,
// --- FIM DA CORREÇÃO ---
volume: !isNaN(volFromFile) ? volFromFile / 100 : DEFAULT_VOLUME,
pan: !isNaN(panFromFile) ? panFromFile / 100 : DEFAULT_PAN,
instrumentName: instrumentNode.getAttribute("name"),
instrumentName: instrumentName,
instrumentXml: instrumentNode.innerHTML,
};
})
@@ -328,26 +274,19 @@ export async function parseMmpContent(xmlString) {
let isFirstTrackWithNotes = true;
newTracks.forEach((track) => {
// --- INÍCIO DA CORREÇÃO ---
// (Esta parte já existia no seu arquivo, mantida)
// Agora usando Volume em dB (Opção B)
track.volumeNode = new Tone.Volume(Tone.gainToDb(track.volume));
track.pannerNode = new Tone.Panner(track.pan);
// Cadeia de áudio: Volume(dB) -> Panner -> Saída Principal
track.volumeNode.connect(track.pannerNode);
track.pannerNode.connect(getMainGainNode());
// --- FIM DA CORREÇÃO ---
if (isFirstTrackWithNotes) {
const activeIdx = track.activePatternIndex || 0;
const activePattern = track.patterns[activeIdx];
if (activePattern) {
const firstPatternSteps = activePattern.steps.length;
const stepsPerBar = 16;
const requiredBars = Math.ceil(firstPatternSteps / stepsPerBar);
document.getElementById("bars-input").value =
requiredBars > 0 ? requiredBars : 1;
if (activePattern && activePattern.steps) {
const stepsLength = activePattern.steps.length;
const requiredBars = Math.ceil(stepsLength / 16);
document.getElementById("bars-input").value = requiredBars > 0 ? requiredBars : 1;
isFirstTrackWithNotes = false;
}
}
@@ -364,45 +303,31 @@ export async function parseMmpContent(xmlString) {
appState.pattern.tracks = newTracks;
appState.pattern.activeTrackId = appState.pattern.tracks[0]?.id || null;
// --- INÍCIO DA CORREÇÃO ---
// Define o estado global para também ser o Padrão 1 (índice 0)
appState.pattern.activePatternIndex = 0;
// --- FIM DA CORREÇÃO ---
// --- A MÁGICA DO F5 (Versão 2.0 - Corrigida) ---
// --- Restauração de Sessão (F5) ---
try {
const roomName = window.ROOM_NAME || "default_room";
const tempStateJSON = sessionStorage.getItem(`temp_state_${roomName}`);
if (tempStateJSON) {
console.log("Restaurando estado temporário da sessão (pós-F5)...");
console.log("Restaurando estado temporário da sessão...");
const tempState = JSON.parse(tempStateJSON);
// NÃO FAÇA: appState.pattern = tempState.pattern; (Isso apaga os Tone.js nodes)
// EM VEZ DISSO, FAÇA O "MERGE" (MESCLAGEM):
// 1. Mescla os 'tracks'
// Itera nos tracks "vivos" (com nós de áudio) que acabamos de criar
appState.pattern.tracks.forEach((liveTrack) => {
// Encontra o track salvo correspondente
const savedTrack = tempState.pattern.tracks.find(
(t) => t.id === liveTrack.id
);
if (savedTrack) {
// Copia os dados do 'savedTrack' para o 'liveTrack'
liveTrack.name = savedTrack.name;
liveTrack.patterns = savedTrack.patterns;
liveTrack.patterns = savedTrack.patterns;
liveTrack.activePatternIndex = savedTrack.activePatternIndex;
liveTrack.volume = savedTrack.volume;
liveTrack.pan = savedTrack.pan;
// ATUALIZA OS NÓS DO TONE.JS com os valores salvos!
if (liveTrack.volumeNode) {
liveTrack.volumeNode.volume.value = Tone.gainToDb(
savedTrack.volume
);
liveTrack.volumeNode.volume.value = Tone.gainToDb(savedTrack.volume);
}
if (liveTrack.pannerNode) {
liveTrack.pannerNode.pan.value = savedTrack.pan;
@@ -410,41 +335,23 @@ export async function parseMmpContent(xmlString) {
}
});
// 2. Remove tracks "vivos" que não existem mais no estado salvo
// (Ex: se o usuário deletou um track antes de dar F5)
appState.pattern.tracks = appState.pattern.tracks.filter((liveTrack) =>
tempState.pattern.tracks.some((t) => t.id === liveTrack.id)
);
// 3. Restaura valores globais da UI
document.getElementById("bpm-input").value = tempState.global.bpm;
document.getElementById("compasso-a-input").value =
tempState.global.compassoA;
document.getElementById("compasso-b-input").value =
tempState.global.compassoB;
document.getElementById("compasso-a-input").value = tempState.global.compassoA;
document.getElementById("compasso-b-input").value = tempState.global.compassoB;
document.getElementById("bars-input").value = tempState.global.bars;
// 4. Restaura o ID do track ativo
appState.pattern.activeTrackId = tempState.pattern.activeTrackId;
console.log("Estado da sessão restaurado com sucesso.");
}
} catch (e) {
console.error(
"Erro ao restaurar estado da sessão (pode estar corrompido)",
e
);
if (window.ROOM_NAME) {
sessionStorage.removeItem(`temp_state_${window.ROOM_NAME}`);
}
console.error("Erro ao restaurar sessão:", e);
}
// --- FIM DA MÁGICA (V2.0) ---
// Agora sim, renderiza com o estado CORRIGIDO E MESCLADO
await Promise.resolve();
renderAll();
console.log("[UI] Projeto renderizado após parseMmpContent");
}
export function generateMmpFile() {
@@ -455,17 +362,9 @@ export function generateMmpFile() {
}
}
// Função auxiliar (pode ser movida para cá) que gera o XML a partir do appState
// Copiada de generateMmpFile/modifyAndSaveExistingMmp
function generateXmlFromState() {
if (!appState.global.originalXmlDoc) {
// Se não houver XML original, precisamos gerar um novo
// Por simplicidade, para este fix, vamos retornar o estado atual do LMMS
// mas o ideal seria gerar o XML completo (como generateNewMmp)
console.warn(
"Não há XML original para modificar. Usando a base atual do appState."
);
// No seu caso, use o conteúdo de generateNewMmp()
console.warn("Não há XML original. Retornando vazio.");
return "";
}
@@ -474,76 +373,68 @@ function generateXmlFromState() {
if (head) {
head.setAttribute("bpm", document.getElementById("bpm-input").value);
head.setAttribute("num_bars", document.getElementById("bars-input").value);
head.setAttribute(
"timesig_numerator",
document.getElementById("compasso-a-input").value
);
head.setAttribute(
"timesig_denominator",
document.getElementById("compasso-b-input").value
);
head.setAttribute("timesig_numerator", document.getElementById("compasso-a-input").value);
head.setAttribute("timesig_denominator", document.getElementById("compasso-b-input").value);
}
const bbTrackContainer = xmlDoc.querySelector(
'track[type="1"] > bbtrack > trackcontainer'
);
const bbTrackContainer = xmlDoc.querySelector('track[type="1"] > bbtrack > trackcontainer');
if (bbTrackContainer) {
bbTrackContainer
.querySelectorAll('track[type="0"]')
.forEach((node) => node.remove());
bbTrackContainer.querySelectorAll('track[type="0"]').forEach((node) => node.remove());
const tracksXml = appState.pattern.tracks
.map((track) => createTrackXml(track))
.join("");
const tempDoc = new DOMParser().parseFromString(
`<root>${tracksXml}</root>`,
"application/xml"
);
// Gambiarra para inserir o XML gerado como nós DOM
const tempDoc = new DOMParser().parseFromString(`<root>${tracksXml}</root>`, "application/xml");
Array.from(tempDoc.documentElement.children).forEach((newTrackNode) => {
bbTrackContainer.appendChild(newTrackNode);
});
}
const serializer = new XMLSerializer();
return serializer.serializeToString(xmlDoc);
}
/**
* Envia o estado ATUAL do projeto (XML dos padrões) para o servidor
* para que ele persista a "cópia temporária" em disco/memória.
* Deve ser chamado APÓS alterações significativas no padrão (steps, tracks).
*/
export function syncPatternStateToServer() {
if (!window.ROOM_NAME) return;
const currentXml = generateXmlFromState();
sendAction({
type: "SYNC_PATTERN_STATE",
xml: currentXml,
});
// Salva o estado localmente também!
saveStateToSession(); // <-- ADICIONE ISSO
sendAction({ type: "SYNC_PATTERN_STATE", xml: currentXml });
saveStateToSession();
}
function createTrackXml(track) {
if (track.patterns.length === 0) return "";
const ticksPerStep = 12;
const lmmsVolume = Math.round(track.volume * 100);
const lmmsPan = Math.round(track.pan * 100);
const patternsXml = track.patterns
.map((pattern) => {
const patternNotes = pattern.steps
.map((isActive, index) => {
const patternsXml = track.patterns.map((pattern) => {
let patternNotesXml = "";
// SE for plugin e tiver notas detalhadas, usa elas
if (track.type === "plugin" && pattern.notes && pattern.notes.length > 0) {
patternNotesXml = pattern.notes.map(note => {
return `<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) {
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 "";
})
.join("\n ");
}).join("\n ");
}
return `<pattern type="0" pos="${pattern.pos}" muted="0" steps="${pattern.steps.length}" name="${pattern.name}">
${patternNotes}
${patternNotesXml}
</pattern>`;
})
.join("\n ");
}).join("\n ");
return `
<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}">
@@ -557,42 +448,8 @@ function createTrackXml(track) {
}
function modifyAndSaveExistingMmp() {
console.log("Modificando arquivo .mmp existente...");
const xmlDoc = appState.global.originalXmlDoc.cloneNode(true);
const head = xmlDoc.querySelector("head");
if (head) {
head.setAttribute("bpm", document.getElementById("bpm-input").value);
head.setAttribute("num_bars", document.getElementById("bars-input").value);
head.setAttribute(
"timesig_numerator",
document.getElementById("compasso-a-input").value
);
head.setAttribute(
"timesig_denominator",
document.getElementById("compasso-b-input").value
);
}
const bbTrackContainer = xmlDoc.querySelector(
'track[type="1"] > bbtrack > trackcontainer'
);
if (bbTrackContainer) {
bbTrackContainer
.querySelectorAll('track[type="0"]')
.forEach((node) => node.remove());
const tracksXml = appState.pattern.tracks
.map((track) => createTrackXml(track))
.join("");
const tempDoc = new DOMParser().parseFromString(
`<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");
const content = generateXmlFromState();
downloadFile(content, "projeto_editado.mmp");
}
function generateNewMmp() {
@@ -603,6 +460,7 @@ function generateNewMmp() {
const tracksXml = appState.pattern.tracks
.map((track) => createTrackXml(track))
.join("");
const mmpContent = `<?xml version="1.0"?>
<!DOCTYPE lmms-project>
<lmms-project version="1.0" type="song" creator="MMPCreator" creatorversion="1.0">
@@ -643,4 +501,4 @@ function downloadFile(content, fileName) {
URL.revokeObjectURL(url);
}
export { BLANK_PROJECT_XML };
export { BLANK_PROJECT_XML };
+122 -64
View File
@@ -8,6 +8,9 @@ import { initializeAudioContext } from "../audio.js";
const timerDisplay = document.getElementById("timer-display");
// Variável para armazenar as "Parts" (sequências melódicas) do Tone.js
let activeParts = [];
function formatTime(milliseconds) {
const totalSeconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(totalSeconds / 60)
@@ -46,7 +49,7 @@ export function playSample(filePath, trackId) {
track.pannerNode.pan.value = track.pan ?? 0;
}
// Garante conexão: player -> volumeNode (não usar mais gainNode)
// Garante conexão: player -> volumeNode
try {
track.player.disconnect();
} catch {}
@@ -103,17 +106,29 @@ function tick() {
// Percorre tracks e toca o step atual se ativo
appState.pattern.tracks.forEach((track) => {
if (track.muted) return; // Respeita o Mute
if (!track.patterns || track.patterns.length === 0) return;
// IMPORTANTE: usar o pattern ativo da PRÓPRIA TRILHA
const activePattern = track.patterns[track.activePatternIndex];
if (
activePattern &&
activePattern.steps[appState.global.currentStep] &&
track.samplePath
) {
playSample(track.samplePath, track.id);
// Verifica se o step atual está ativo
if (activePattern && activePattern.steps[appState.global.currentStep]) {
// CASO 1: SAMPLER (Arquivo de Áudio)
if (track.samplePath) {
playSample(track.samplePath, track.id);
}
// CASO 2: PLUGIN (Sintetizador)
// Se for plugin e tiver step marcado, toca nota padrão (C5)
else if (track.type === 'plugin' && track.instrument) {
// "16n" é a duração de uma semicolcheia
// Usamos um try/catch para evitar travar o loop se o plugin falhar
try {
track.instrument.triggerAttackRelease("C5", "16n", Tone.now());
} catch (e) {
console.warn("Falha ao tocar step do plugin:", e);
}
}
}
});
@@ -125,6 +140,11 @@ export function startPlayback() {
if (appState.global.isPlaying || appState.pattern.tracks.length === 0) return;
initializeAudioContext();
// Garante que o contexto do Tone esteja rodando
if (Tone.context.state !== "running") {
Tone.start();
}
if (appState.global.currentStep === 0) {
rewindPlayback();
}
@@ -136,6 +156,11 @@ export function startPlayback() {
if (appState.global.playbackIntervalId)
clearInterval(appState.global.playbackIntervalId);
// --- NOVO: Agenda o Piano Roll (Melodias) ---
schedulePianoRoll();
Tone.Transport.start(); // Inicia o relógio para as notas melódicas
// --------------------------------------------
appState.global.isPlaying = true;
const playBtn = document.getElementById("play-btn");
if (playBtn) {
@@ -154,6 +179,21 @@ export function stopPlayback() {
appState.global.playbackIntervalId = null;
appState.global.isPlaying = false;
// --- NOVO: Para o Transport e Limpa Synths ---
Tone.Transport.stop();
// Limpa agendamentos melódicos
activeParts.forEach(part => part.dispose());
activeParts = [];
// Solta notas travadas de todos os plugins
appState.pattern.tracks.forEach(track => {
try { track.player?.stop(); } catch {}
try { track.instrument?.releaseAll?.(); } catch {} // Para PolySynths
try { track.instrument?.triggerRelease?.(); } catch {} // Para MonoSynths
});
// --------------------------------------------
document
.querySelectorAll(".step.playing")
.forEach((s) => s.classList.remove("playing"));
@@ -173,6 +213,9 @@ export function rewindPlayback() {
? appState.global.currentStep - 1
: getTotalSteps() - 1;
appState.global.currentStep = 0;
Tone.Transport.position = 0; // Reseta o tempo do Tone.js
if (!appState.global.isPlaying) {
if (timerDisplay) timerDisplay.textContent = "00:00:00";
highlightStep(lastStep, false);
@@ -190,51 +233,88 @@ export function togglePlayback() {
}
// =========================================================================
// FUNÇÃO CORRIGIDA v3: Renderizar o Pattern atual para um Blob de Áudio
// AGENDADOR DE PIANO ROLL (NOVA FUNÇÃO)
// Agenda as notas melódicas (desenhadas no Piano Roll) para tocar no Tone.Transport
// =========================================================================
function schedulePianoRoll() {
// Limpa anteriores por segurança
activeParts.forEach(part => part.dispose());
activeParts = [];
appState.pattern.tracks.forEach(track => {
if (track.muted) return;
const pattern = track.patterns[track.activePatternIndex];
// Só agenda se tiver notas E for um instrumento
if (pattern && pattern.notes && pattern.notes.length > 0 && track.instrument) {
// Mapeia as notas para o formato de evento do Tone.js
const events = pattern.notes.map(note => {
return {
// Converte Ticks (pos) para Tempo Musical do Tone.js
// Assumindo 192 PPQ (padrão LMMS) -> Tone PPQ
time: 0 + (note.pos * (Tone.Transport.PPQ / 192) / Tone.Transport.PPQ),
midi: note.key,
duration: note.len + "i", // 'i' em Tone.js significa ticks
velocity: (note.vol || 100) / 100
};
});
// Cria uma Part (sequência)
const part = new Tone.Part((time, value) => {
if (track.muted) return;
const freq = Tone.Frequency(value.midi, "midi");
// Dispara o método que padronizamos em todos os plugins
if (track.instrument.triggerAttackRelease) {
track.instrument.triggerAttackRelease(freq, value.duration, time, value.velocity);
}
}, events).start(0);
// Configura o Loop da Melodia
const bars = parseInt(document.getElementById('bars-input')?.value || 1);
part.loop = true;
part.loopEnd = bars + "m"; // 'm' = measure (compasso)
activeParts.push(part);
}
});
}
// =========================================================================
// Renderizar o Pattern atual para um Blob de Áudio (MANTIDO ORIGINAL)
// =========================================================================
export async function renderActivePatternToBlob() {
initializeAudioContext(); // Garante que o contexto de áudio principal existe
initializeAudioContext();
// 1. Obter configs atuais
const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120;
const totalSteps = getTotalSteps();
const stepInterval = 60 / (bpm * 4); // Duração de 1 step (em segundos)
const duration = totalSteps * stepInterval; // Duração total em segundos
const stepInterval = 60 / (bpm * 4);
const duration = totalSteps * stepInterval;
// 2. Descobrir qual pattern está ativo (assume que todos estão no mesmo)
const activePatternIndex =
appState.pattern.tracks[0]?.activePatternIndex || 0;
// 3. Renderizar offline usando Tone.Offline
const buffer = await Tone.Offline(async () => {
// ----------------------------------------------------
// Contexto de Áudio OFFLINE
// ----------------------------------------------------
const masterGain = new Tone.Gain().toDestination();
// --- INÍCIO DA CORREÇÃO (Lógica de Polifonia) ---
// 1. Criamos as 'Parts'.
const offlineTracksParts = appState.pattern.tracks
.map((track) => {
const pattern = track.patterns[activePatternIndex];
// Verificação crucial: Precisamos do 'track.buffer' (áudio carregado)
// Nota: O render atual suporta apenas Samplers (buffers carregados)
// Para suportar Plugins no futuro, precisaríamos recriar o synth aqui dentro.
if (!pattern || !track.buffer || !pattern.steps.includes(true)) {
return null; // Pula trilha se não tiver áudio ou notas
return null;
}
// Obtém o buffer de áudio (que já está carregado)
const trackBuffer = track.buffer;
// Cria a cadeia de áudio (Volume/Pan) para esta *trilha*
const panner = new Tone.Panner(track.pan).connect(masterGain);
const volume = new Tone.Volume(Tone.gainToDb(track.volume)).connect(
panner
);
// Cria a lista de eventos (tempos em que as notas devem tocar)
const events = [];
pattern.steps.forEach((isActive, stepIndex) => {
if (isActive) {
@@ -243,48 +323,30 @@ export async function renderActivePatternToBlob() {
}
});
// Cria a Tone.Part
const part = new Tone.Part((time) => {
// *** ESTA É A CORREÇÃO CRÍTICA ***
// Para cada nota (cada 'time' na lista de 'events'),
// nós criamos um PLAYER "ONE-SHOT" (descartável).
// Isso permite que vários sons da mesma trilha
// se sobreponham (polifonia).
new Tone.Player(trackBuffer)
.connect(volume)
.start(time);
}, events);
new Tone.Player(trackBuffer) // Usa o buffer carregado
.connect(volume) // Conecta na cadeia de áudio (Volume->Pan->Master)
.start(time); // Toca no tempo agendado
}, events); // Passa a lista de tempos [0, 0.25, 0.5, ...]
return part; // Retorna a Part (que sabe quando disparar)
return part;
})
.filter((t) => t !== null); // Remove trilhas nulas
.filter((t) => t !== null);
// 2. Como estamos usando buffers já carregados,
// não precisamos esperar (remover 'await Tone.loaded()')
// 3. Agenda todas as 'Parts' para começar
offlineTracksParts.forEach((part) => {
part.start(0);
});
// --- FIM DA CORREÇÃO ---
// Define o BPM do transporte offline
Tone.Transport.bpm.value = bpm;
// Inicia o transporte (para a renderização)
Tone.Transport.start();
// ----------------------------------------------------
}, duration); // Duração total da renderização
}, duration);
// 5. Converte o AudioBuffer resultante em um Blob (arquivo .wav)
const blob = bufferToWave(buffer);
return blob;
}
// =========================================================================
// FUNÇÃO UTILITÁRIA: Converte AudioBuffer para Blob WAV
// (Mantenha esta função como está)
// FUNÇÃO UTILITÁRIA: Converte AudioBuffer para Blob WAV (MANTIDO ORIGINAL)
// =========================================================================
function bufferToWave(abuffer) {
@@ -298,7 +360,6 @@ function bufferToWave(abuffer) {
let offset = 0;
let pos = 0;
// setAll e setString são helpers
function setAll(data) {
for (i = 0; i < data.length; i++) {
view.setUint8(pos + i, data[i]);
@@ -309,44 +370,41 @@ function bufferToWave(abuffer) {
setAll(s.split("").map((c) => c.charCodeAt(0)));
}
// Cabeçalho WAV
setString("RIFF");
view.setUint32(pos, length - 8, true);
pos += 4;
setString("WAVE");
setString("fmt ");
view.setUint32(pos, 16, true);
pos += 4; // Sub-chunk size
pos += 4;
view.setUint16(pos, 1, true);
pos += 2; // Audio format 1
pos += 2;
view.setUint16(pos, numOfChan, true);
pos += 2;
view.setUint32(pos, abuffer.sampleRate, true);
pos += 4;
view.setUint32(pos, abuffer.sampleRate * 2 * numOfChan, true);
pos += 4; // Byte rate
pos += 4;
view.setUint16(pos, numOfChan * 2, true);
pos += 2; // Block align
pos += 2;
view.setUint16(pos, 16, true);
pos += 2; // Bits per sample
pos += 2;
setString("data");
view.setUint32(pos, length - 44, true);
pos += 4;
// Pega os dados dos canais
for (i = 0; i < numOfChan; i++) {
channels.push(abuffer.getChannelData(i));
}
// Escreve os dados (intercalando canais)
for (i = 0; i < abuffer.length; i++) {
for (let j = 0; j < numOfChan; j++) {
sample = Math.max(-1, Math.min(1, channels[j][i]));
sample = (0.5 + sample * 32767.5) | 0; // Converte para 16-bit PCM
sample = (0.5 + sample * 32767.5) | 0;
view.setInt16(pos, sample, true);
pos += 2;
}
}
return new Blob([buffer], { type: "audio/wav" });
}
}
+128 -22
View File
@@ -1,5 +1,10 @@
// js/pattern_state.js
import * as Tone from "https://esm.sh/tone";
import { TripleOscillator } from "../../audio/plugins/TripleOscillator.js";
import { Kicker } from "../../audio/plugins/Kicker.js";
import { Lb302 } from "../../audio/plugins/Lb302.js";
import { Nes } from "../../audio/plugins/Nes.js";
import { SuperSaw } from "../../audio/plugins/SuperSaw.js";
import { appState } from "../state.js";
import { DEFAULT_VOLUME, DEFAULT_PAN } from "../config.js";
@@ -7,29 +12,20 @@ import { getMainGainNode } from "../audio.js";
import { getTotalSteps } from "../utils.js";
export function initializePatternState() {
// Limpa players/buffers existentes
appState.pattern.tracks.forEach(track => {
try { track.player?.dispose(); } catch {}
try { track.buffer?.dispose?.(); } catch {}
try { track.instrument?.dispose(); } catch {}
});
// Reseta estado do editor de pattern
appState.pattern.tracks = [];
appState.pattern.activeTrackId = null;
appState.pattern.activePatternIndex = 0;
}
export async function loadAudioForTrack(track) {
if (!track.samplePath) return track;
// 1. Garante a criação dos nós de Volume e Pan
try {
// Descartar player/buffer anteriores com segurança
try { track.player?.dispose(); } catch {}
track.player = null;
try { track.buffer?.dispose?.(); } catch {}
track.buffer = null;
// Garante nós de volume/pan (Opção B: Volume em dB)
if (!track.volumeNode) {
track.volumeNode = new Tone.Volume(
track.volume === 0 ? -Infinity : Tone.gainToDb(track.volume)
@@ -45,30 +41,135 @@ export async function loadAudioForTrack(track) {
track.pannerNode.pan.value = track.pan ?? 0;
}
// Encadeia: Volume(dB) -> Panner -> Master
try { track.instrument?.disconnect(); } catch {}
try { track.player?.disconnect(); } catch {}
try { track.volumeNode.disconnect(); } catch {}
try { track.pannerNode.disconnect(); } catch {}
track.volumeNode.connect(track.pannerNode);
track.pannerNode.connect(getMainGainNode());
// Cria e carrega o Player
} catch (e) {
console.error("Erro ao criar nós de áudio base:", e);
}
// --- DETECÇÃO DE TIPO DE ARQUIVO ---
// Verifica se é um formato de áudio que o navegador suporta
// Verifica tipo de arquivo
const isStandardAudio = track.samplePath && /\.(wav|mp3|ogg|flac|m4a)$/i.test(track.samplePath);
const isDrumSynth = track.samplePath && /\.ds$/i.test(track.samplePath);
// Se não for áudio (ou for .ds/.xpf), é Plugin
if (!track.samplePath || !isStandardAudio) {
try {
if (track.instrument) { try { track.instrument.dispose(); } catch {} }
let synth;
const name = (track.instrumentName || "").toLowerCase();
// DADOS DO PLUGIN (Tenta parsear XML se for string, ou usa vazio)
// Dica: O ideal seria ter um parser real aqui, mas vamos passar {} por enquanto
const pluginData = {};
// SELETOR DE PLUGINS
switch (name) {
case "tripleoscillator":
case "3osc":
synth = new TripleOscillator(Tone.getContext(), pluginData);
break;
case "kicker":
synth = new Kicker(Tone.getContext(), pluginData);
break;
case "lb302":
synth = new Lb302(Tone.getContext(), pluginData);
break;
case "nes":
case "freeboy": // Freeboy é parecido com NES
case "papu": // Papu também é Gameboy
case "sid": // SID é 8-bit também, usaremos NES como fallback por enquanto
synth = new Nes(Tone.getContext(), pluginData);
break;
// --- PACOTE SUPER SAW ---
case "zynaddsubfx": // O clássico
case "watsyn": // Wavetable Synth
case "monstro": // 3 Osciladores monstruosos
case "vibedstrings": // Strings vibrantes (fatsaw funciona bem como base)
case "SuperSaw":
synth = new SuperSaw(Tone.getContext(), pluginData);
break;
case "organic": // Fallback simples para Organic (Additive)
synth = new Tone.PolySynth(Tone.Synth, {
oscillator: { type: "sine", count: 8, spread: 20 }
});
break;
case "zynaddsubfx": // O monstro!
// Zyn é impossível de clonar rápido. Vamos usar um "SuperSaw" gordo como placeholder
synth = new Tone.PolySynth(Tone.Synth, {
oscillator: { type: "fatsawtooth", count: 3, spread: 30 },
envelope: { attack: 0.1, decay: 0.3, sustain: 0.8, release: 1 }
});
break;
default:
console.warn(`Plugin ${name} desconhecido, usando fallback.`);
// Fallback genérico
synth = new Tone.PolySynth(Tone.Synth, { oscillator: { type: "triangle" } });
}
// Se o plugin criou uma classe wrapper (nossas classes .js),
// ele tem o método .connect. Se for Tone nativo (Organic/Zyn fallback), também tem.
if (synth.output) {
// Nossas classes customizadas
synth.connect(track.volumeNode);
} else {
// Objetos Tone.js puros
synth.connect(track.volumeNode);
}
track.instrument = synth;
track.player = null;
track.type = 'plugin';
console.log(`[Audio] Plugin carregado: ${name}`);
} catch (e) {
console.error("Erro ao carregar plugin:", name, e);
}
return track;
}
// 3. Lógica para SAMPLERS (Arquivos de Áudio Reais)
try {
try { track.player?.dispose(); } catch {}
track.player = null;
try { track.buffer?.dispose?.(); } catch {}
track.buffer = null;
if (track.instrument) {
try { track.instrument.dispose(); } catch {}
track.instrument = null;
}
const player = new Tone.Player({ url: track.samplePath, autostart: false });
await player.load(track.samplePath); // garante buffer carregado
await player.load(track.samplePath);
// Conecta o player ao volumeNode
player.connect(track.volumeNode);
// Buffer separado (se você usar waveform em outro lugar)
const buffer = new Tone.Buffer();
await buffer.load(track.samplePath);
// Atribuições finais
track.player = player;
track.buffer = buffer;
track.type = 'sampler'; // Garante o tipo correto
} catch (error) {
console.error('Erro ao carregar sample:', track.samplePath);
console.error(`Falha ao carregar áudio para a trilha "${track.name}":`, error);
try { track.player?.dispose(); } catch {}
try { track.buffer?.dispose?.(); } catch {}
track.player = null;
@@ -85,6 +186,7 @@ export function addTrackToState() {
id: Date.now() + Math.random(),
name: "novo instrumento",
samplePath: null,
type: 'plugin', // Padrão
player: null,
buffer: null,
patterns: referenceTrack
@@ -97,14 +199,14 @@ export function addTrackToState() {
activePatternIndex: 0,
volume: DEFAULT_VOLUME,
pan: DEFAULT_PAN,
// Opção B: controlar volume em dB
volumeNode: new Tone.Volume(Tone.gainToDb(DEFAULT_VOLUME)),
pannerNode: new Tone.Panner(DEFAULT_PAN),
};
// Cadeia de áudio nova
newTrack.volumeNode.connect(newTrack.pannerNode);
newTrack.pannerNode.connect(getMainGainNode());
loadAudioForTrack(newTrack);
appState.pattern.tracks.push(newTrack);
appState.pattern.activeTrackId = newTrack.id;
@@ -116,6 +218,7 @@ export function removeLastTrackFromState() {
try { trackToRemove.player?.dispose(); } catch {}
try { trackToRemove.buffer?.dispose?.(); } catch {}
try { trackToRemove.instrument?.dispose(); } catch {}
try { trackToRemove.pannerNode?.disconnect(); } catch {}
try { trackToRemove.volumeNode?.disconnect(); } catch {}
@@ -131,8 +234,11 @@ export async function updateTrackSample(trackIndex, samplePath) {
if (track) {
track.samplePath = samplePath;
track.name = samplePath.split("/").pop();
// Reseta o tipo baseado no novo arquivo
const isAudio = /\.(wav|mp3|ogg|flac|m4a)$/i.test(samplePath);
track.type = isAudio ? 'sampler' : 'plugin';
// (re)carrega e reconecta corretamente o player nesta trilha
await loadAudioForTrack(track);
}
}
@@ -145,4 +251,4 @@ export function toggleStepState(trackIndex, stepIndex) {
activePattern.steps[stepIndex] = !activePattern.steps[stepIndex];
}
}
}
}
+249 -167
View File
@@ -1,7 +1,7 @@
// js/pattern_ui.js
import { appState } from "../state.js";
import {
    updateTrackSample
updateTrackSample
} from "./pattern_state.js";
import { playSample, stopPlayback } from "./pattern_audio.js";
import { getTotalSteps } from "../utils.js";
@@ -10,199 +10,292 @@ import { initializeAudioContext } from '../audio.js';
// Função principal de renderização para o editor de patterns
export function renderPatternEditor() {
  const trackContainer = document.getElementById("track-container");
  trackContainer.innerHTML = "";
const trackContainer = document.getElementById("track-container");
trackContainer.innerHTML = "";
// (V7) Adicionado 'trackIndex'
  appState.pattern.tracks.forEach((trackData, trackIndex) => {
    const trackLane = document.createElement("div");
    trackLane.className = "track-lane";
    trackLane.dataset.trackIndex = trackIndex; // (V7) Usando índice
appState.pattern.tracks.forEach((trackData, trackIndex) => {
const trackLane = document.createElement("div");
trackLane.className = "track-lane";
trackLane.dataset.trackIndex = trackIndex;
    if (trackData.id === appState.pattern.activeTrackId) {
        trackLane.classList.add('active-track');
    }
if (trackData.id === appState.pattern.activeTrackId) {
trackLane.classList.add('active-track');
}
    trackLane.innerHTML = `
      <div class="track-info">
        <i class="fa-solid fa-gear"></i>
        <div class="track-mute"></div>
        <span class="track-name">${trackData.name}</span>
      </div>
      <div class="track-controls">
        <div class="knob-container">
          <div class="knob" data-control="volume" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
          <span>VOL</span>
        </div>
        <div class="knob-container">
          <div class="knob" data-control="pan" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
          <span>PAN</span>
        </div>
      </div>
      <div class="step-sequencer-wrapper"></div>
    `;
trackLane.innerHTML = `
<div class="track-info">
<i class="fa-solid fa-gear"></i>
<div class="track-mute"></div>
<span class="track-name">${trackData.name}</span>
</div>
<div class="track-controls">
<div class="knob-container">
<div class="knob" data-control="volume" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
<span>VOL</span>
</div>
<div class="knob-container">
<div class="knob" data-control="pan" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
<span>PAN</span>
</div>
</div>
<div class="step-sequencer-wrapper"></div>
`;
// (Listener de clique da track é local, sem mudanças)
    trackLane.addEventListener('click', () => {
        if (appState.pattern.activeTrackId === trackData.id) return;
        stopPlayback();
        appState.pattern.activeTrackId = trackData.id;
        document.querySelectorAll('.track-lane').forEach(lane => lane.classList.remove('active-track'));
        trackLane.classList.add('active-track');
        updateGlobalPatternSelector();
        redrawSequencer();
    });
trackLane.addEventListener('click', () => {
if (appState.pattern.activeTrackId === trackData.id) return;
stopPlayback();
appState.pattern.activeTrackId = trackData.id;
document.querySelectorAll('.track-lane').forEach(lane => lane.classList.remove('active-track'));
trackLane.classList.add('active-track');
updateGlobalPatternSelector();
redrawSequencer();
});
    trackLane.addEventListener("dragover", (e) => { e.preventDefault(); trackLane.classList.add("drag-over"); });
    trackLane.addEventListener("dragleave", () => trackLane.classList.remove("drag-over"));
trackLane.addEventListener("dragover", (e) => { e.preventDefault(); trackLane.classList.add("drag-over"); });
trackLane.addEventListener("dragleave", () => trackLane.classList.remove("drag-over"));
// (V9) Listener de "drop" (arrastar) agora usa 'sendAction'
    trackLane.addEventListener("drop", (e) => {
      e.preventDefault();
      trackLane.classList.remove("drag-over");
      const filePath = e.dataTransfer.getData("text/plain");
     
      if (filePath) {
trackLane.addEventListener("drop", (e) => {
e.preventDefault();
trackLane.classList.remove("drag-over");
const filePath = e.dataTransfer.getData("text/plain");
if (filePath) {
sendAction({
type: 'SET_TRACK_SAMPLE',
trackIndex: trackIndex,
filePath: filePath
});
      }
    });
}
});
    trackContainer.appendChild(trackLane);
  });
 
  updateGlobalPatternSelector();
  redrawSequencer();
trackContainer.appendChild(trackLane);
});
updateGlobalPatternSelector();
redrawSequencer();
}
export function redrawSequencer() {
  const totalGridSteps = getTotalSteps();
  document.querySelectorAll(".step-sequencer-wrapper").forEach((wrapper) => {
    let sequencerContainer = wrapper.querySelector(".step-sequencer");
    if (!sequencerContainer) {
      sequencerContainer = document.createElement("div");
      sequencerContainer.className = "step-sequencer";
      wrapper.appendChild(sequencerContainer);
    }
   
    const parentTrackElement = wrapper.closest(".track-lane");
    const trackIndex = parseInt(parentTrackElement.dataset.trackIndex, 10); // (V7)
    // ... dentro da função redrawSequencer() ...
const totalGridSteps = getTotalSteps();
document.querySelectorAll(".step-sequencer-wrapper").forEach((wrapper) => {
let sequencerContainer = wrapper.querySelector(".step-sequencer");
if (!sequencerContainer) {
sequencerContainer = document.createElement("div");
sequencerContainer.className = "step-sequencer";
wrapper.appendChild(sequencerContainer);
} else {
sequencerContainer.innerHTML = "";
}
const parentTrackElement = wrapper.closest(".track-lane");
const trackIndex = parseInt(parentTrackElement.dataset.trackIndex, 10);
const trackData = appState.pattern.tracks[trackIndex];
    const trackData = appState.pattern.tracks[trackIndex];
    if (!trackData || !trackData.patterns || trackData.patterns.length === 0) {
      sequencerContainer.innerHTML = ""; return;
    }
if (!trackData || !trackData.patterns || trackData.patterns.length === 0) {
return;
}
const activePatternIndex = trackData.activePatternIndex;
    const activePattern = trackData.patterns[activePatternIndex];
const activePattern = trackData.patterns[activePatternIndex];
    if (!activePattern) {
        sequencerContainer.innerHTML = ""; return;
    }
    const patternSteps = activePattern.steps;
// --- INÍCIO DA CORREÇÃO ---
// Precisamos verificar se 'patternSteps' é um array real.
// Se for 'null' ou 'undefined' (um bug de dados do .mmp),
// o loop 'for' abaixo quebraria ANTES de limpar a UI.
if (!patternSteps || !Array.isArray(patternSteps)) {
// Limpa a UI (remove os steps antigos)
sequencerContainer.innerHTML = "";
// E para a execução desta track, deixando o sequenciador vazio.
return;
if (!activePattern) {
return;
}
// --- FIM DA CORREÇÃO ---
    sequencerContainer.innerHTML = ""; // Agora é seguro limpar a UI
    for (let i = 0; i < totalGridSteps; i++) {
      const stepWrapper = document.createElement("div");
      stepWrapper.className = "step-wrapper";
      const stepElement = document.createElement("div");
      stepElement.className = "step";
     
      if (patternSteps[i] === true) {
        stepElement.classList.add("active");
      }
// ============================================================
// LÓGICA DE DECISÃO V2: STEPS OU PIANO ROLL?
// ============================================================
const notes = activePattern.notes || [];
const hasNotes = notes.length > 0;
let renderMode = 'steps';
      stepElement.addEventListener("click", () => {
initializeAudioContext(); // (V8)
if (hasNotes) {
const firstKey = notes[0].key;
const isMelodic = notes.some(n => n.key !== firstKey);
const hasLongNotes = notes.some(n => n.len > 48);
// Sobreposição de notas (Acordes)
const sortedNotes = [...notes].sort((a, b) => a.pos - b.pos);
let hasOverlap = false;
for (let i = 0; i < sortedNotes.length - 1; i++) {
if (sortedNotes[i].pos + sortedNotes[i].len > sortedNotes[i+1].pos) {
hasOverlap = true;
break;
}
}
const currentState = activePattern.steps[i] || false;
const isActive = !currentState;
if (isMelodic || hasLongNotes || hasOverlap) {
renderMode = 'piano_roll';
} else {
renderMode = 'steps';
}
}
sendAction({ // (V7)
type: 'TOGGLE_NOTE',
trackIndex: trackIndex,
patternIndex: activePatternIndex,
stepIndex: i,
isActive: isActive
// ============================================================
// RENDERIZAÇÃO
// ============================================================
if (renderMode === 'piano_roll') {
// --- MODO PIANO ROLL ---
sequencerContainer.classList.add('mode-piano');
const miniView = document.createElement('div');
miniView.className = 'track-mini-piano-roll';
miniView.title = "Clique duplo para abrir o Piano Roll";
miniView.addEventListener('dblclick', (e) => {
e.stopPropagation();
if (window.openPianoRoll) {
window.openPianoRoll(trackData.id);
}
});
        if (isActive && trackData && trackData.samplePath) {
          playSample(trackData.samplePath, trackData.id);
        }
      });
activePattern.notes.forEach(note => {
const noteEl = document.createElement('div');
noteEl.className = 'mini-note';
const barsInput = document.getElementById('bars-input');
const barsCount = barsInput ? parseInt(barsInput.value) || 1 : 1;
const totalTicks = 192 * barsCount;
const leftPercent = (note.pos / totalTicks) * 100;
const widthPercent = (note.len / totalTicks) * 100;
const keyRange = 48;
const baseKey = 36;
let relativeKey = note.key - baseKey;
if(relativeKey < 0) relativeKey = 0;
if(relativeKey > keyRange) relativeKey = keyRange;
const topPercent = 100 - ((relativeKey / keyRange) * 100);
      const beatsPerBar = parseInt(document.getElementById("compasso-a-input").value, 10) || 4;
      const groupIndex = Math.floor(i / beatsPerBar);
      if (groupIndex % 2 === 0) {
        stepElement.classList.add("step-dark");
      }
noteEl.style.left = `${leftPercent}%`;
noteEl.style.width = `${widthPercent}%`;
noteEl.style.top = `${topPercent}%`;
miniView.appendChild(noteEl);
});
      const stepsPerBar = 16;
      if (i > 0 && i % stepsPerBar === 0) {
        const marker = document.createElement("div");
        marker.className = "step-marker";
        marker.textContent = Math.floor(i / stepsPerBar) + 1;
        stepWrapper.appendChild(marker);
      }
     
      stepWrapper.appendChild(stepElement);
      sequencerContainer.appendChild(stepWrapper);
    }
  });
sequencerContainer.appendChild(miniView);
} else {
// --- MODO STEP SEQUENCER ---
sequencerContainer.classList.remove('mode-piano');
const patternSteps = activePattern.steps;
if (!patternSteps || !Array.isArray(patternSteps)) return;
for (let i = 0; i < totalGridSteps; i++) {
const stepWrapper = document.createElement("div");
stepWrapper.className = "step-wrapper";
const stepElement = document.createElement("div");
stepElement.className = "step";
if (patternSteps[i] === true) {
stepElement.classList.add("active");
}
// --- EVENTO DE CLIQUE (CORRIGIDO PARA PLUGINS) ---
stepElement.addEventListener("click", (e) => {
e.stopPropagation();
initializeAudioContext();
const currentState = activePattern.steps[i] || false;
const isActive = !currentState;
sendAction({
type: 'TOGGLE_NOTE',
trackIndex: trackIndex,
patternIndex: activePatternIndex,
stepIndex: i,
isActive: isActive
});
// --- AQUI ESTAVA O PROBLEMA: TOCA O SOM ---
if (isActive) {
// Caso 1: Sampler (Áudio)
if (trackData.type === 'sampler' && trackData.samplePath) {
playSample(trackData.samplePath, trackData.id);
}
// Caso 2: Plugin (Sintetizador)
else if (trackData.type === 'plugin' && trackData.instrument) {
// Toca um C5 (Dó) curto para feedback visual/sonoro
try {
// triggerAttackRelease(nota, duração)
trackData.instrument.triggerAttackRelease("C5", "16n");
} catch(err) {
console.warn("Erro ao tocar preview do synth:", err);
}
}
}
});
const beatsPerBar = parseInt(document.getElementById("compasso-a-input").value, 10) || 4;
const groupIndex = Math.floor(i / beatsPerBar);
if (groupIndex % 2 === 0) {
stepElement.classList.add("step-dark");
}
const stepsPerBar = 16;
if (i > 0 && i % stepsPerBar === 0) {
const marker = document.createElement("div");
marker.className = "step-marker";
marker.textContent = Math.floor(i / stepsPerBar) + 1;
stepWrapper.appendChild(marker);
}
stepWrapper.appendChild(stepElement);
sequencerContainer.appendChild(stepWrapper);
}
}
});
}
export function highlightStep(stepIndex, isActive) {
  if (stepIndex < 0) return;
  document.querySelectorAll(".track-lane").forEach((track) => {
    const stepWrapper = track.querySelector(
      `.step-sequencer .step-wrapper:nth-child(${stepIndex + 1})`
    );
    if (stepWrapper) {
      const stepElement = stepWrapper.querySelector(".step");
      if (stepElement) {
        stepElement.classList.toggle("playing", isActive);
      }
    }
  });
if (stepIndex < 0) return;
document.querySelectorAll(".track-lane").forEach((track) => {
const stepWrapper = track.querySelector(
`.step-sequencer .step-wrapper:nth-child(${stepIndex + 1})`
);
if (stepWrapper) {
const stepElement = stepWrapper.querySelector(".step");
if (stepElement) {
stepElement.classList.toggle("playing", isActive);
}
}
});
}
export function updateStepUI(trackIndex, patternIndex, stepIndex, isActive) {
// --- INÍCIO DA CORREÇÃO ---
// A lógica antiga (if (patternIndex !== appState.pattern.activePatternIndex))
// estava errada, pois usava uma variável global.
const trackElement = document.querySelector(`.track-lane[data-track-index="${trackIndex}"]`);
if (!trackElement) return;
const trackData = appState.pattern.tracks[trackIndex];
if (!trackData) return;
// A UI só deve ser atualizada cirurgicamente se o pattern clicado
// for o MESMO pattern que está VISÍVEL no sequenciador dessa trilha.
if (patternIndex !== trackData.activePatternIndex) {
// O estado mudou, mas não é o pattern que estamos vendo,
// então não faz nada na UI (mas o estado no appState está correto).
return;
const activePattern = trackData.patterns[patternIndex];
const notes = activePattern.notes || [];
const hasNotes = notes.length > 0;
let isComplex = false;
if (hasNotes) {
const isMelodic = notes.some(n => n.key !== notes[0].key);
const hasLongNotes = notes.some(n => n.len > 48);
if (isMelodic || hasLongNotes) isComplex = true;
}
// --- FIM DA CORREÇÃO ---
if (isComplex) {
redrawSequencer();
return;
}
if (patternIndex !== trackData.activePatternIndex) return;
const stepWrapper = trackElement.querySelector(
`.step-sequencer .step-wrapper:nth-child(${stepIndex + 1})`
@@ -217,38 +310,27 @@ export function updateGlobalPatternSelector() {
const globalPatternSelector = document.getElementById('global-pattern-selector');
if (!globalPatternSelector) return;
// 1. Encontra a track que está ATIVA no momento
const activeTrackId = appState.pattern.activeTrackId;
const activeTrack = appState.pattern.tracks.find(t => t.id === activeTrackId);
// 2. Usa a track[0] como referência para os NOMES dos patterns
const referenceTrack = appState.pattern.tracks[0];
globalPatternSelector.innerHTML = ''; // Limpa as <options> anteriores
globalPatternSelector.innerHTML = '';
if (referenceTrack && referenceTrack.patterns.length > 0) {
// 3. Popula a lista de <option>
referenceTrack.patterns.forEach((pattern, index) => {
const option = document.createElement('option');
option.value = index;
option.textContent = pattern.name; // ex: "Pattern 1"
option.textContent = pattern.name;
globalPatternSelector.appendChild(option);
});
// 4. CORREÇÃO PRINCIPAL: Define o item selecionado no <select>
if (activeTrack) {
// O valor do seletor (ex: "2") deve ser igual ao índice
// do pattern ativo da track selecionada.
globalPatternSelector.value = activeTrack.activePatternIndex || 0;
} else {
globalPatternSelector.value = 0; // Padrão
globalPatternSelector.value = 0;
}
globalPatternSelector.disabled = false;
} else {
// 5. Estado desabilitado (nenhum pattern)
const option = document.createElement('option');
option.textContent = 'Sem patterns';
globalPatternSelector.appendChild(option);
+1
View File
@@ -42,6 +42,7 @@ export let appState = {
pattern: patternState, // <-- AGORA 'patternState' está definido
audio: audioState,
};
window.appState = appState;
export function resetProjectState() {
console.log("Executando resetProjectState completo...");