Padronização da interface e atualização do sync entre clientes
Deploy / Deploy (push) Successful in 2m1s Details

This commit is contained in:
JotaChina 2025-11-27 22:02:02 -03:00
parent dfe558be1c
commit 0d0cd2d7db
17 changed files with 797 additions and 812 deletions

1
.gitignore vendored
View File

@ -8,3 +8,4 @@ mmp
venv venv
.bundle .bundle
src src
assets/js/creations/server/data

View File

@ -142,7 +142,39 @@ body.sidebar-hidden .sample-browser { width: 0; min-width: 0; border-right: none
color: var(--text-light); width: 25px; height: 40px; cursor: pointer; color: var(--text-light); width: 25px; height: 40px; cursor: pointer;
border-radius: 0 4px 4px 0; display: flex; align-items: center; justify-content: center; border-radius: 0 4px 4px 0; display: flex; align-items: center; justify-content: center;
} }
/* Botão Padrão Unificado */
.control-btn {
height: 32px;
min-width: 32px; /* Garante que botões quadrados fiquem quadrados */
padding: 0 10px; /* Padding lateral para botões com texto */
border: 1px solid transparent;
border-radius: 4px;
background-color: #464646; /* Cor base mais neutra */
color: var(--text-light);
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.control-btn:hover {
background-color: #5a5a5a;
transform: translateY(-1px);
}
.control-btn:active {
transform: translateY(0);
}
/* Botão Ativo (Toggle) */
.control-btn.active {
background-color: var(--accent-green);
color: #fff;
border-color: var(--accent-green);
box-shadow: 0 0 8px rgba(46, 204, 113, 0.4);
}
/* =============================================== */ /* =============================================== */
/* ÁREA DE CONTEÚDO E FERRAMENTAS /* ÁREA DE CONTEÚDO E FERRAMENTAS
/* =============================================== */ /* =============================================== */
@ -161,8 +193,35 @@ body.sidebar-hidden .sample-browser { width: 0; min-width: 0; border-right: none
.info-display { background-color: #1a1c1e; padding: 5px 8px; border-radius: 3px; text-align: center; } .info-display { background-color: #1a1c1e; padding: 5px 8px; border-radius: 3px; text-align: center; }
.info-display .label { color: var(--text-dark); font-size: .6rem; text-transform: uppercase; } .info-display .label { color: var(--text-dark); font-size: .6rem; text-transform: uppercase; }
.value-input { background: 0 0; border: 0; outline: 0; color: var(--accent-green); font-weight: 700; font-size: 1.4rem; font-family: Courier New, Courier, monospace; text-align: center; padding: 0; width: 55px; } .value-input { background: 0 0; border: 0; outline: 0; color: var(--accent-green); font-weight: 700; font-size: 1.4rem; font-family: Courier New, Courier, monospace; text-align: center; padding: 0; width: 55px; }
.compasso-input { width: 25px; } /* --- CORREÇÃO DO COMPASSO --- */
.compasso-separator { color: var(--accent-green); font-weight: 700; font-size: 1.4rem; font-family: Courier New, Courier, monospace; margin: 0 2px; }
/* Força o container principal a alinhar tudo em uma linha só */
.interactive-input-container {
display: flex;
align-items: center;
justify-content: center;
}
/* Força cada grupo (botão - input - botão) a ficar em linha */
.compasso-group {
display: flex;
align-items: center;
}
/* Ajuste opcional para a barra separadora ficar bonita */
.compasso-separator {
margin: 0 5px;
color: var(--accent-green); /* Garante que fique verde como na imagem */
font-weight: bold;
font-size: 1.2rem;
}
/* Garante que os inputs do compasso não fiquem largos demais */
.compasso-input {
width: 25px !important; /* Força a largura pequena */
text-align: center;
padding: 0;
}
.value-input::-webkit-outer-spin-button, .value-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } .value-input::-webkit-outer-spin-button, .value-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.value-input[type=number] { -moz-appearance: textfield; } .value-input[type=number] { -moz-appearance: textfield; }
.adjust-btn { background: 0 0; border: 0; color: var(--text-dark); font-size: 1rem; font-weight: 700; cursor: pointer; padding: 0 5px; transition: color .2s; line-height: 1; } .adjust-btn { background: 0 0; border: 0; color: var(--text-dark); font-size: 1rem; font-weight: 700; cursor: pointer; padding: 0 5px; transition: color .2s; line-height: 1; }
@ -191,7 +250,40 @@ body.sidebar-hidden .sample-browser { width: 0; min-width: 0; border-right: none
border-radius: 8px; box-shadow: 0 5px 15px rgba(0, 0, 0, .3); overflow: hidden; border-radius: 8px; box-shadow: 0 5px 15px rgba(0, 0, 0, .3); overflow: hidden;
display: flex; flex-direction: column; display: flex; flex-direction: column;
} }
.editor-header { background-color: var(--bg-toolbar); padding: 4px 10px; font-size: .8rem; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color); flex-shrink: 0; } /* Container do Header do Editor */
.editor-header {
display: flex;
justify-content: space-between; /* Espalha grupos para esquerda e direita */
align-items: center;
height: 50px; /* Altura fixa para ambos */
background-color: var(--panel-bg);
border-bottom: 1px solid var(--border-color);
padding: 0 15px;
box-sizing: border-box;
}
/* Inputs dentro da toolbar (Selects, Numbers) */
.editor-header select,
.editor-header input[type="number"] {
height: 32px;
background-color: #222;
border: 1px solid var(--border-color);
color: var(--text-light);
border-radius: 4px;
padding: 0 8px;
}
/* Grupos de ferramentas (ex: Transporte, Zoom, Edição) */
.toolbar-group {
display: flex;
align-items: center;
gap: 8px; /* Espaço uniforme entre botões */
}
/* Divisor Vertical Visual (opcional, para separar grupos) */
.toolbar-divider {
width: 1px;
height: 24px;
background-color: var(--border-color);
margin: 0 8px;
}
.editor-toolbar { background-color: var(--bg-toolbar); padding: 5px 10px; border-bottom: 2px solid var(--border-color); flex-shrink: 0; display: flex; align-items: center; gap: 15px; } .editor-toolbar { background-color: var(--bg-toolbar); padding: 5px 10px; border-bottom: 2px solid var(--border-color); flex-shrink: 0; display: flex; align-items: center; gap: 15px; }
#track-container { overflow-y: auto; overflow-x: hidden; flex-grow: 1; } #track-container { overflow-y: auto; overflow-x: hidden; flex-grow: 1; }
@ -224,6 +316,15 @@ body.sidebar-hidden .sample-browser { width: 0; min-width: 0; border-right: none
border-radius: 8px; box-shadow: 0 5px 15px rgba(0, 0, 0, .3); overflow: hidden; border-radius: 8px; box-shadow: 0 5px 15px rgba(0, 0, 0, .3); overflow: hidden;
display: flex; flex-direction: column; --track-info-width: 255px; display: flex; flex-direction: column; --track-info-width: 255px;
} }
/* CORREÇÃO CSS: Adicionado para garantir layout correto */
.audio-tracks-wrapper {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
#audio-track-container { flex-grow: 1; overflow: auto; } #audio-track-container { flex-grow: 1; overflow: auto; }
.audio-track-lane { display: flex; flex-direction: row; align-items: stretch; background-color: var(--bg-editor); border-bottom: 1px solid var(--bg-toolbar); min-height: 90px; box-sizing: border-box; } .audio-track-lane { display: flex; flex-direction: row; align-items: stretch; background-color: var(--bg-editor); border-bottom: 1px solid var(--bg-toolbar); min-height: 90px; box-sizing: border-box; }
.audio-track-lane.drag-over { background-color: #40454d; } .audio-track-lane.drag-over { background-color: #40454d; }

View File

@ -23,23 +23,43 @@ import { sendAction } from "../socket.js";
export function renderAudioEditor() { export function renderAudioEditor() {
const audioEditor = document.querySelector(".audio-editor"); const audioEditor = document.querySelector(".audio-editor");
const existingTrackContainer = document.getElementById( const existingTrackContainer = document.getElementById("audio-track-container");
"audio-track-container"
);
if (!audioEditor || !existingTrackContainer) return; if (!audioEditor || !existingTrackContainer) return;
// --- CORREÇÃO DO ERRO DOMException ---
// Identificamos quem é o pai real do container de tracks (agora é .audio-tracks-wrapper)
const tracksParent = existingTrackContainer.parentElement;
// --- CRIAÇÃO E RENDERIZAÇÃO DA RÉGUA --- // --- CRIAÇÃO E RENDERIZAÇÃO DA RÉGUA ---
let rulerWrapper = audioEditor.querySelector(".ruler-wrapper"); // Buscamos a régua dentro desse pai correto
let rulerWrapper = tracksParent.querySelector(".ruler-wrapper");
if (!rulerWrapper) { if (!rulerWrapper) {
// Se a régua criada pelo JS ainda não existe, cria ela
// (Ignora a régua estática do HTML se houver conflito, priorizando esta estrutura)
rulerWrapper = document.createElement("div"); rulerWrapper = document.createElement("div");
rulerWrapper.className = "ruler-wrapper"; rulerWrapper.className = "ruler-wrapper";
rulerWrapper.innerHTML = ` rulerWrapper.innerHTML = `
<div class="ruler-spacer"></div> <div class="ruler-spacer"></div>
<div class="timeline-ruler"></div> <div class="timeline-ruler"></div>
`; `;
audioEditor.insertBefore(rulerWrapper, existingTrackContainer);
// Insere ANTES do container de tracks, dentro do pai correto
tracksParent.insertBefore(rulerWrapper, existingTrackContainer);
} }
// Remove a régua estática antiga se ela ainda estiver lá atrapalhando (opcional, mas recomendado)
const staticRuler = tracksParent.querySelector("#audio-timeline-ruler");
if (staticRuler && staticRuler.parentElement === tracksParent) {
staticRuler.remove();
}
// Remove elementos soltos antigos se existirem para evitar duplicação
const oldLoopRegion = tracksParent.querySelector("#loop-region");
const oldPlayhead = tracksParent.querySelector("#playhead");
if(oldLoopRegion) oldLoopRegion.remove();
if(oldPlayhead) oldPlayhead.remove();
const ruler = rulerWrapper.querySelector(".timeline-ruler"); const ruler = rulerWrapper.querySelector(".timeline-ruler");
ruler.innerHTML = ""; ruler.innerHTML = "";
@ -243,7 +263,9 @@ export function renderAudioEditor() {
// Recriação Container Pistas (sem alterações) // Recriação Container Pistas (sem alterações)
const newTrackContainer = existingTrackContainer.cloneNode(false); const newTrackContainer = existingTrackContainer.cloneNode(false);
audioEditor.replaceChild(newTrackContainer, existingTrackContainer);
// Substitui no pai correto (tracksParent), não no audioEditor
tracksParent.replaceChild(newTrackContainer, existingTrackContainer);
// Render Pistas (sem alterações) // Render Pistas (sem alterações)
appState.audio.tracks.forEach((trackData) => { appState.audio.tracks.forEach((trackData) => {
@ -725,7 +747,6 @@ export function renderAudioEditor() {
const constrainedLeftPx = Math.max(0, newLeftPx); const constrainedLeftPx = Math.max(0, newLeftPx);
let newStartTime = constrainedLeftPx / currentPixelsPerSecond; let newStartTime = constrainedLeftPx / currentPixelsPerSecond;
newStartTime = quantizeTime(newStartTime); newStartTime = quantizeTime(newStartTime);
// (Correção Bug 4 - remove Number())
updateAudioClipProperties(clipId, { updateAudioClipProperties(clipId, {
trackId: newTrackId, trackId: newTrackId,
startTimeInSeconds: newStartTime, startTimeInSeconds: newStartTime,
@ -756,13 +777,8 @@ export function renderAudioEditor() {
const clickX = event.clientX - rect.left; const clickX = event.clientX - rect.left;
const absoluteX = clickX + scrollLeft; const absoluteX = clickX + scrollLeft;
const newTime = absoluteX / currentPixelsPerSecond; const newTime = absoluteX / currentPixelsPerSecond;
// ================================================================= // Sincronia de Seek na Pista)
// 👇 INÍCIO DA CORREÇÃO (Sincronia de Seek na Pista)
// =================================================================
sendAction({ type: "SET_SEEK_TIME", seekTime: newTime }); sendAction({ type: "SET_SEEK_TIME", seekTime: newTime });
// seekAudioEditor(newTime); // 👈 Substituído
// =================================================================
// 👆 FIM DA CORREÇÃO
}; };
handleSeek(e); // Aplica no mousedown handleSeek(e); // Aplica no mousedown
const onMouseMoveSeek = (moveEvent) => handleSeek(moveEvent); const onMouseMoveSeek = (moveEvent) => handleSeek(moveEvent);
@ -856,7 +872,7 @@ export function resetPlayheadVisual() {
}); });
} }
// --- INÍCIO DA NOVA FUNÇÃO (Passo 4: A Função de Desenho) --- // --- A Função de Desenho) ---
// (Adicionada ao final de audio_ui.js) // (Adicionada ao final de audio_ui.js)
/** /**
@ -908,4 +924,3 @@ function createPatternViewElement(patternData) {
return view; return view;
} }
// --- FIM DA NOVA FUNÇÃO ---

View File

@ -1,4 +1,6 @@
// js/file.js //--------------------------------------------------------------
// IMPORTS NECESSÁRIOS
//--------------------------------------------------------------
import { appState, saveStateToSession, resetProjectState, loadStateFromSession } from "./state.js"; import { appState, saveStateToSession, resetProjectState, loadStateFromSession } from "./state.js";
import { loadAudioForTrack } from "./pattern/pattern_state.js"; import { loadAudioForTrack } from "./pattern/pattern_state.js";
import { renderAll, getSamplePathMap } from "./ui.js"; import { renderAll, getSamplePathMap } from "./ui.js";
@ -7,21 +9,13 @@ import {
initializeAudioContext, initializeAudioContext,
getMainGainNode, getMainGainNode,
} from "./audio.js"; } from "./audio.js";
import { DEFAULT_PROJECT_XML } from "./utils.js"
import * as Tone from "https://esm.sh/tone"; import * as Tone from "https://esm.sh/tone";
import { sendAction } from "./socket.js"; import { sendAction } from "./socket.js";
const BLANK_PROJECT_XML = `<?xml version="1.0"?> //--------------------------------------------------------------
<!DOCTYPE lmms-project> //
<lmms-project type="song" version="1.0" creatorversion="1.2.2" creator="LMMS"> //--------------------------------------------------------------
<head bpm="140" mastervol="100" timesig_numerator="4" timesig_denominator="4" masterpitch="0"/>
<song>
<trackcontainer width="600" height="300" type="song" visible="1" maximized="0" x="5" y="5" minimized="0">
</trackcontainer>
<timeline lp0pos="0" lp1pos="192" lpstate="0"/>
<controllers/>
</song>
</lmms-project>`;
export function handleLocalProjectReset() { export function handleLocalProjectReset() {
console.log("Recebido comando de reset. Limpando estado local..."); console.log("Recebido comando de reset. Limpando estado local...");
@ -89,7 +83,7 @@ export async function loadProjectFromServer(fileName) {
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 === DEFAULT_PROJECT_XML;
const audioContainer = document.getElementById("audio-track-container"); const audioContainer = document.getElementById("audio-track-container");
if (audioContainer) { if (audioContainer) {
@ -305,10 +299,7 @@ export async function parseMmpContent(xmlString) {
appState.pattern.activeTrackId = appState.pattern.tracks[0]?.id || null; appState.pattern.activeTrackId = appState.pattern.tracks[0]?.id || null;
appState.pattern.activePatternIndex = 0; appState.pattern.activePatternIndex = 0;
// --- SUBSTUIÇÃO DO BLOCO DE RESTAURAÇÃO ---
// Em vez daquele bloco try/catch gigante, apenas chamamos a função:
loadStateFromSession(); loadStateFromSession();
// ------------------------------------------
await Promise.resolve(); await Promise.resolve();
renderAll(); renderAll();
@ -324,17 +315,19 @@ export function generateMmpFile() {
function generateXmlFromState() { function generateXmlFromState() {
if (!appState.global.originalXmlDoc) { if (!appState.global.originalXmlDoc) {
console.warn("Não há XML original. Retornando vazio."); console.log("Gerando XML a partir do template em branco...");
return ""; const parser = new DOMParser();
appState.global.originalXmlDoc = parser.parseFromString(DEFAULT_PROJECT_XML, "application/xml");
} }
const xmlDoc = appState.global.originalXmlDoc.cloneNode(true); const xmlDoc = appState.global.originalXmlDoc.cloneNode(true);
const head = xmlDoc.querySelector("head"); const head = xmlDoc.querySelector("head");
if (head) { if (head) {
head.setAttribute("bpm", document.getElementById("bpm-input").value); head.setAttribute("bpm", document.getElementById("bpm-input").value || 140);
head.setAttribute("num_bars", document.getElementById("bars-input").value); head.setAttribute("num_bars", document.getElementById("bars-input").value || 1);
head.setAttribute("timesig_numerator", document.getElementById("compasso-a-input").value); head.setAttribute("timesig_numerator", document.getElementById("compasso-a-input").value || 4);
head.setAttribute("timesig_denominator", document.getElementById("compasso-b-input").value); head.setAttribute("timesig_denominator", document.getElementById("compasso-b-input").value || 4);
} }
const bbTrackContainer = xmlDoc.querySelector('track[type="1"] > bbtrack > trackcontainer'); const bbTrackContainer = xmlDoc.querySelector('track[type="1"] > bbtrack > trackcontainer');
@ -344,7 +337,6 @@ function generateXmlFromState() {
.map((track) => createTrackXml(track)) .map((track) => createTrackXml(track))
.join(""); .join("");
// Gambiarra para inserir o XML gerado como nós DOM
const tempDoc = new DOMParser().parseFromString(`<root>${tracksXml}</root>`, "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);
@ -369,21 +361,22 @@ function createTrackXml(track) {
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);
// 🔥 PROTEÇÃO: Se não tiver instrumento definido, usa Kicker padrão
const instrName = track.instrumentName || "kicker";
const instrXml = track.instrumentXml || `<kicker><env amt="0" attack="0.01" hold="0.1" decay="0.1" release="0.1" sustain="0.5" sync_mode="0"/></kicker>`;
const patternsXml = track.patterns.map((pattern) => { const patternsXml = track.patterns.map((pattern) => {
let patternNotesXml = ""; let patternNotesXml = "";
// SE for plugin e tiver notas detalhadas, usa elas
if (track.type === "plugin" && pattern.notes && pattern.notes.length > 0) { if (track.type === "plugin" && pattern.notes && pattern.notes.length > 0) {
patternNotesXml = pattern.notes.map(note => { patternNotesXml = pattern.notes.map(note => {
return `<note vol="${note.vol}" len="${note.len}" pos="${note.pos}" pan="${note.pan}" key="${note.key}"/>`; return `<note vol="${note.vol}" len="${note.len}" pos="${note.pos}" pan="${note.pan}" key="${note.key}"/>`;
}).join("\n "); }).join("\n ");
} }
// SE for sampler (ou plugin sem notas detalhadas), usa os steps convertidos em notas simples
else { else {
patternNotesXml = pattern.steps.map((isActive, index) => { 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 "";
@ -398,8 +391,8 @@ function createTrackXml(track) {
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}">
<instrument name="${track.instrumentName}"> <instrument name="${instrName}">
${track.instrumentXml} ${instrXml}
</instrument> </instrument>
<fxchain enabled="0" numofeffects="0"/> <fxchain enabled="0" numofeffects="0"/>
</instrumenttrack> </instrumenttrack>
@ -462,4 +455,3 @@ function downloadFile(content, fileName) {
} }
export { generateXmlFromState as generateXmlFromStateExported }; export { generateXmlFromState as generateXmlFromStateExported };
export { BLANK_PROJECT_XML }; // Mantenha o que já estava

View File

@ -5,7 +5,7 @@ import {
restartAudioEditorIfPlaying, restartAudioEditorIfPlaying,
} from "./audio/audio_audio.js"; } from "./audio/audio_audio.js";
import { initializeAudioContext } from "./audio.js"; import { initializeAudioContext } from "./audio.js";
import { handleFileLoad, generateMmpFile, BLANK_PROJECT_XML } from "./file.js"; import { handleFileLoad, generateMmpFile } from "./file.js";
import { import {
renderAll, renderAll,
loadAndRenderSampleBrowser, loadAndRenderSampleBrowser,
@ -13,7 +13,7 @@ import {
closeOpenProjectModal, closeOpenProjectModal,
} from "./ui.js"; } from "./ui.js";
import { renderAudioEditor } from "./audio/audio_ui.js"; import { renderAudioEditor } from "./audio/audio_ui.js";
import { adjustValue, enforceNumericInput } from "./utils.js"; import { adjustValue, enforceNumericInput, DEFAULT_PROJECT_XML } from "./utils.js";
import { ZOOM_LEVELS } from "./config.js"; import { ZOOM_LEVELS } from "./config.js";
import { loadProjectFromServer } from "./file.js"; import { loadProjectFromServer } from "./file.js";
import { sendAction, joinRoom, setUserName } from "./socket.js"; import { sendAction, joinRoom, setUserName } from "./socket.js";
@ -61,6 +61,11 @@ const PROJECT_NAME = new URLSearchParams(window.location.search).get("project");
? PROJECT_NAME ? PROJECT_NAME
: `${PROJECT_NAME}.mmp`; : `${PROJECT_NAME}.mmp`;
loadProjectFromServer(filename); loadProjectFromServer(filename);
} else if (!restored && !PROJECT_NAME) {
// CORREÇÃO: Se entrou numa sala vazia sem projeto na URL,
// inicializa o XML base para não dar erro depois.
const parser = new DOMParser();
appState.global.originalXmlDoc = parser.parseFromString(DEFAULT_PROJECT_XML, "application/xml");
} }
})(); // Fim do initApp })(); // Fim do initApp
@ -313,7 +318,7 @@ document.addEventListener("DOMContentLoaded", () => {
) )
) )
return; return;
sendAction({ type: "LOAD_PROJECT", xml: BLANK_PROJECT_XML }); sendAction({ type: "LOAD_PROJECT", xml: DEFAULT_PROJECT_XML });
}); });
addBarBtn?.addEventListener("click", () => { addBarBtn?.addEventListener("click", () => {
@ -342,6 +347,29 @@ document.addEventListener("DOMContentLoaded", () => {
sendAction({ type: "REMOVE_LAST_TRACK" }); sendAction({ type: "REMOVE_LAST_TRACK" });
}); });
// 👇 ATUALIZE ESTE LISTENER
removeInstrumentBtn?.addEventListener("click", () => {
initializeAudioContext();
// Pega o ID da faixa que está selecionada/ativa na interface
const activeId = appState.pattern.activeTrackId;
if (activeId) {
// Envia comando seguro por ID
sendAction({
type: "REMOVE_TRACK_BY_ID",
trackId: activeId
});
} else {
// Fallback: se não tiver ID ativo, tenta pegar a última (comportamento antigo, mas seguro localmente)
const tracks = appState.pattern.tracks;
if (tracks.length > 0) {
const lastId = tracks[tracks.length - 1].id;
sendAction({ type: "REMOVE_TRACK_BY_ID", trackId: lastId });
}
}
});
playBtn?.addEventListener("click", () => { playBtn?.addEventListener("click", () => {
initializeAudioContext(); initializeAudioContext();
sendAction({ type: "TOGGLE_PLAYBACK" }); sendAction({ type: "TOGGLE_PLAYBACK" });
@ -496,6 +524,35 @@ document.addEventListener("DOMContentLoaded", () => {
}); });
} }
const removeAudioTrackBtn = document.getElementById("remove-audio-track-btn");
if (removeAudioTrackBtn) {
removeAudioTrackBtn.addEventListener("click", () => {
initializeAudioContext();
// Verifica quantas faixas existem
const trackCount = appState.audio.tracks.length;
if (trackCount > 0) {
// Pega a ÚLTIMA faixa da lista
const lastTrack = appState.audio.tracks[trackCount - 1];
// Verifica se tem clips nela (segurança)
const hasClips = appState.audio.clips.some(c => c.trackId === lastTrack.id);
if (hasClips && !confirm("A última pista contém clips de áudio. Deseja removê-la mesmo assim?")) {
return;
}
// 🔥 A MUDANÇA CRUCIAL: Envia o ID, e usa o NOME NOVO do comando
sendAction({
type: "REMOVE_AUDIO_LANE_BY_ID",
trackId: lastTrack.id
});
} else {
showToast("Não há pistas de áudio para remover.", "warning");
}
});
}
// Navegador de Samples (local) // Navegador de Samples (local)
loadAndRenderSampleBrowser(); loadAndRenderSampleBrowser();

View File

@ -1,4 +1,4 @@
// js/pattern_state.js // js/pattern/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 { TripleOscillator } from "../../audio/plugins/TripleOscillator.js";
import { Kicker } from "../../audio/plugins/Kicker.js"; import { Kicker } from "../../audio/plugins/Kicker.js";
@ -11,6 +11,11 @@ import { DEFAULT_VOLUME, DEFAULT_PAN } from "../config.js";
import { getMainGainNode } from "../audio.js"; import { getMainGainNode } from "../audio.js";
import { getTotalSteps } from "../utils.js"; import { getTotalSteps } from "../utils.js";
// XML padrão para o instrumento Kicker (bateria simples)
const DEFAULT_KICKER_XML = `<kicker>
<env amt="0" attack="0.01" hold="0.1" decay="0.1" release="0.1" sustain="0.5" sync_mode="0"/>
</kicker>`;
export function initializePatternState() { export function initializePatternState() {
appState.pattern.tracks.forEach(track => { appState.pattern.tracks.forEach(track => {
try { track.player?.dispose(); } catch {} try { track.player?.dispose(); } catch {}
@ -55,20 +60,17 @@ export async function loadAudioForTrack(track) {
// --- DETECÇÃO DE TIPO DE ARQUIVO --- // --- DETECÇÃO DE TIPO DE ARQUIVO ---
// Verifica se é um formato de áudio que o navegador suporta // 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 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 // Se não for áudio padrão, assumimos Plugin (ou Kicker padrão)
if (!track.samplePath || !isStandardAudio) { if (!track.samplePath || !isStandardAudio) {
try { try {
if (track.instrument) { try { track.instrument.dispose(); } catch {} } if (track.instrument) { try { track.instrument.dispose(); } catch {} }
let synth; let synth;
const name = (track.instrumentName || "").toLowerCase(); // Normaliza o nome do instrumento. Se vazio, assume kicker.
const name = (track.instrumentName || "kicker").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 = {}; const pluginData = {};
// SELETOR DE PLUGINS // SELETOR DE PLUGINS
@ -87,64 +89,53 @@ export async function loadAudioForTrack(track) {
break; break;
case "nes": case "nes":
case "freeboy": // Freeboy é parecido com NES case "freeboy":
case "papu": // Papu também é Gameboy case "papu":
case "sid": // SID é 8-bit também, usaremos NES como fallback por enquanto case "sid":
synth = new Nes(Tone.getContext(), pluginData); synth = new Nes(Tone.getContext(), pluginData);
break; break;
// --- PACOTE SUPER SAW --- case "zynaddsubfx":
case "zynaddsubfx": // O clássico case "watsyn":
case "watsyn": // Wavetable Synth case "monstro":
case "monstro": // 3 Osciladores monstruosos case "vibedstrings":
case "vibedstrings": // Strings vibrantes (fatsaw funciona bem como base) case "supersaw":
case "SuperSaw":
synth = new SuperSaw(Tone.getContext(), pluginData); synth = new SuperSaw(Tone.getContext(), pluginData);
break; break;
case "organic": // Fallback simples para Organic (Additive) case "organic":
synth = new Tone.PolySynth(Tone.Synth, { synth = new Tone.PolySynth(Tone.Synth, {
oscillator: { type: "sine", count: 8, spread: 20 } oscillator: { type: "sine", count: 8, spread: 20 }
}); });
break; 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: default:
console.warn(`Plugin ${name} desconhecido, usando fallback.`); console.warn(`Plugin ${name} desconhecido, usando fallback (Kicker).`);
// Fallback genérico // Fallback seguro: Kicker
synth = new Tone.PolySynth(Tone.Synth, { oscillator: { type: "triangle" } }); synth = new Kicker(Tone.getContext(), pluginData);
} }
// 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) { if (synth.output) {
// Nossas classes customizadas
synth.connect(track.volumeNode); synth.connect(track.volumeNode);
} else { } else {
// Objetos Tone.js puros
synth.connect(track.volumeNode); synth.connect(track.volumeNode);
} }
track.instrument = synth; track.instrument = synth;
track.player = null; track.player = null;
track.type = 'plugin'; track.type = 'plugin';
// Atualiza o nome se ele estava vazio
if (!track.instrumentName) track.instrumentName = name;
console.log(`[Audio] Plugin carregado: ${name}`); console.log(`[Audio] Plugin carregado: ${name}`);
} catch (e) { } catch (e) {
console.error("Erro ao carregar plugin:", name, e); console.error("Erro ao carregar plugin:", track.instrumentName, e);
} }
return track; return track;
} }
// 3. Lógica para SAMPLERS (Arquivos de Áudio Reais) // 3. Lógica para SAMPLERS
try { try {
try { track.player?.dispose(); } catch {} try { track.player?.dispose(); } catch {}
track.player = null; track.player = null;
@ -166,7 +157,7 @@ export async function loadAudioForTrack(track) {
track.player = player; track.player = player;
track.buffer = buffer; track.buffer = buffer;
track.type = 'sampler'; // Garante o tipo correto track.type = 'sampler';
} catch (error) { } catch (error) {
console.error('Erro ao carregar sample:', track.samplePath); console.error('Erro ao carregar sample:', track.samplePath);
@ -181,21 +172,29 @@ export async function loadAudioForTrack(track) {
export function addTrackToState() { export function addTrackToState() {
const totalSteps = getTotalSteps(); const totalSteps = getTotalSteps();
const referenceTrack = appState.pattern.tracks[0]; const referenceTrack = appState.pattern.tracks[0];
const newId = Date.now() + Math.random();
const newTrack = { const newTrack = {
id: Date.now() + Math.random(), id: newId,
name: "novo instrumento", name: `Novo Instrumento ${appState.pattern.tracks.length + 1}`,
samplePath: null, samplePath: null,
type: 'plugin', // Padrão type: 'plugin',
// 🔥 CORREÇÃO: Definir instrumento padrão (Kicker) e XML
instrumentName: "kicker",
instrumentXml: DEFAULT_KICKER_XML,
player: null, player: null,
buffer: null, buffer: null,
patterns: referenceTrack patterns: referenceTrack
? referenceTrack.patterns.map(p => ({ ? referenceTrack.patterns.map(p => ({
name: p.name, name: p.name,
steps: new Array(p.steps.length).fill(false), steps: new Array(p.steps.length).fill(false),
notes: [],
pos: p.pos pos: p.pos
})) }))
: [{ name: "Pattern 1", steps: new Array(totalSteps).fill(false), pos: 0 }], : [{ name: "Pattern 1", steps: new Array(totalSteps).fill(false), notes: [], pos: 0 }],
activePatternIndex: 0, activePatternIndex: 0,
volume: DEFAULT_VOLUME, volume: DEFAULT_VOLUME,
pan: DEFAULT_PAN, pan: DEFAULT_PAN,
@ -206,10 +205,39 @@ export function addTrackToState() {
newTrack.volumeNode.connect(newTrack.pannerNode); newTrack.volumeNode.connect(newTrack.pannerNode);
newTrack.pannerNode.connect(getMainGainNode()); newTrack.pannerNode.connect(getMainGainNode());
// Carrega o áudio (vai cair no case "kicker" do loadAudioForTrack)
loadAudioForTrack(newTrack); loadAudioForTrack(newTrack);
appState.pattern.tracks.push(newTrack); appState.pattern.tracks.push(newTrack);
appState.pattern.activeTrackId = newTrack.id; appState.pattern.activeTrackId = newTrack.id;
console.log("Faixa adicionada ao estado com Kicker padrão.");
}
export function removeTrackById(trackId) {
const index = appState.pattern.tracks.findIndex(t => t.id === trackId);
if (index !== -1) {
const trackToRemove = appState.pattern.tracks[index];
// Limpeza de memória
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 {}
// Remove do array
appState.pattern.tracks.splice(index, 1);
// Ajusta seleção se necessário
if (appState.pattern.activeTrackId === trackId) {
appState.pattern.activeTrackId = appState.pattern.tracks[0]?.id ?? null;
}
return true; // Retorna sucesso
}
return false; // Não achou (o fantasma não existe aqui)
} }
export function removeLastTrackFromState() { export function removeLastTrackFromState() {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -9,7 +9,87 @@ const { Server } = require("socket.io");
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const pino = require("pino"); const pino = require("pino");
//const { PORT_SOCK } = require("../config.js");
const DEFAULT_PROJECT_XML = `<?xml version="1.0"?>
<!DOCTYPE lmms-project>
<lmms-project version="1.0" type="song" creatorversion="1.2.2" creator="LMMS">
<head mastervol="100" masterpitch="0" timesig_denominator="4" timesig_numerator="4" bpm="140"/>
<song>
<trackcontainer visible="1" y="5" minimized="0" height="300" type="song" x="5" maximized="0" width="600">
<track muted="0" name="TripleOscillator" type="0" solo="0">
<instrumenttrack pitchrange="1" fxch="0" usemasterpitch="1" vol="100" pitch="0" pan="0" basenote="57">
<instrument name="tripleoscillator">
<tripleoscillator wavetype1="0" pan2="0" finer1="0" finel1="0" userwavefile0="" wavetype0="0" userwavefile1="" finer0="0" stphdetun1="0" vol0="33" wavetype2="0" coarse0="0" finel2="0" finer2="0" modalgo2="2" finel0="0" coarse1="-12" phoffset1="0" pan0="0" modalgo3="2" coarse2="-24" phoffset2="0" pan1="0" vol1="33" phoffset0="0" stphdetun0="0" modalgo1="2" vol2="33" stphdetun2="0" userwavefile2=""/>
</instrument>
<eldata fres="0.5" fwet="0" ftype="0" fcut="14000">
<elvol ctlenvamt="0" lspd_syncmode="0" x100="0" lpdel="0" latt="0" pdel="0" lspd="0.1" lshp="0" hold="0.5" lspd_numerator="4" userwavefile="" att="0" lamt="0" dec="0.5" lspd_denominator="4" sustain="0.5" rel="0.1" amt="0"/>
<elcut ctlenvamt="0" lspd_syncmode="0" x100="0" lpdel="0" latt="0" pdel="0" lspd="0.1" lshp="0" hold="0.5" lspd_numerator="4" userwavefile="" att="0" lamt="0" dec="0.5" lspd_denominator="4" sustain="0.5" rel="0.1" amt="0"/>
<elres ctlenvamt="0" lspd_syncmode="0" x100="0" lpdel="0" latt="0" pdel="0" lspd="0.1" lshp="0" hold="0.5" lspd_numerator="4" userwavefile="" att="0" lamt="0" dec="0.5" lspd_denominator="4" sustain="0.5" rel="0.1" amt="0"/>
</eldata>
<chordcreator chordrange="1" chord-enabled="0" chord="0"/>
<arpeggiator arpcycle="0" arptime_denominator="4" arpgate="100" arpdir="0" arpmode="0" arptime_syncmode="0" arp="0" arpmiss="0" arp-enabled="0" arptime="100" arprange="1" arpskip="0" arptime_numerator="4"/>
<midiport inputchannel="0" outputcontroller="0" fixedoutputvelocity="-1" outputchannel="1" fixedinputvelocity="-1" outputprogram="1" inputcontroller="0" readable="0" fixedoutputnote="-1" basevelocity="63" writable="0"/>
<fxchain enabled="0" numofeffects="0"/>
</instrumenttrack>
</track>
<track muted="0" name="Sample track" type="2" solo="0">
<sampletrack vol="100" pan="0">
<fxchain enabled="0" numofeffects="0"/>
</sampletrack>
</track>
<track muted="0" name="Beat/Bassline 0" type="1" solo="0">
<bbtrack>
<trackcontainer visible="0" y="5" minimized="0" height="400" type="bbtrackcontainer" x="610" maximized="0" width="700">
<track muted="0" name="Kicker" type="0" solo="0">
<instrumenttrack pitchrange="1" fxch="0" usemasterpitch="1" vol="100" pitch="0" pan="0" basenote="57">
<instrument name="kicker">
<kicker decay="440" version="1" noise="0" endfreq="40" decay_syncmode="0" decay_denominator="4" startfreq="150" env="0.163" startnote="1" dist="0.8" slope="0.06" decay_numerator="4" click="0.4" distend="0.8" gain="1" endnote="0"/>
</instrument>
<eldata fres="0.5" fwet="0" ftype="0" fcut="14000">
<elvol ctlenvamt="0" lspd_syncmode="0" x100="0" lpdel="0" latt="0" pdel="0" lspd="0.1" lshp="0" hold="0.5" lspd_numerator="4" userwavefile="" att="0" lamt="0" dec="0.5" lspd_denominator="4" sustain="0.5" rel="0.1" amt="0"/>
<elcut ctlenvamt="0" lspd_syncmode="0" x100="0" lpdel="0" latt="0" pdel="0" lspd="0.1" lshp="0" hold="0.5" lspd_numerator="4" userwavefile="" att="0" lamt="0" dec="0.5" lspd_denominator="4" sustain="0.5" rel="0.1" amt="0"/>
<elres ctlenvamt="0" lspd_syncmode="0" x100="0" lpdel="0" latt="0" pdel="0" lspd="0.1" lshp="0" hold="0.5" lspd_numerator="4" userwavefile="" att="0" lamt="0" dec="0.5" lspd_denominator="4" sustain="0.5" rel="0.1" amt="0"/>
</eldata>
<chordcreator chordrange="1" chord-enabled="0" chord="0"/>
<arpeggiator arpcycle="0" arptime_denominator="4" arpgate="100" arpdir="0" arpmode="0" arptime_syncmode="0" arp="0" arpmiss="0" arp-enabled="0" arptime="100" arprange="1" arpskip="0" arptime_numerator="4"/>
<midiport inputchannel="0" outputcontroller="0" fixedoutputvelocity="-1" outputchannel="1" fixedinputvelocity="-1" outputprogram="1" inputcontroller="0" readable="0" fixedoutputnote="-1" basevelocity="63" writable="0"/>
<fxchain enabled="0" numofeffects="0"/>
</instrumenttrack>
<pattern pos="0" muted="0" name="Kicker" steps="16" type="0"/>
</track>
</trackcontainer>
</bbtrack>
</track>
<track muted="0" name="Automation track" type="5" solo="0">
<automationtrack/>
</track>
</trackcontainer>
<track muted="0" name="Automation track" type="6" solo="0">
<automationtrack/>
<automationpattern pos="0" len="192" name="Numerator" prog="0" tens="1" mute="0"/>
<automationpattern pos="0" len="192" name="Denominator" prog="0" tens="1" mute="0"/>
<automationpattern pos="0" len="192" name="Tempo" prog="0" tens="1" mute="0"/>
<automationpattern pos="0" len="192" name="Master volume" prog="0" tens="1" mute="0"/>
<automationpattern pos="0" len="192" name="Master pitch" prog="0" tens="1" mute="0"/>
</track>
<fxmixer visible="1" y="310" minimized="0" height="333" x="5" maximized="0" width="543">
<fxchannel volume="1" muted="0" num="0" name="Master" soloed="0">
<fxchain enabled="0" numofeffects="0"/>
</fxchannel>
</fxmixer>
<ControllerRackView visible="1" y="310" minimized="0" height="200" x="680" maximized="0" width="350"/>
<pianoroll visible="0" y="5" minimized="0" height="480" x="5" maximized="0" width="860"/>
<automationeditor visible="0" y="1" minimized="0" height="400" x="1" maximized="0" width="860"/>
<projectnotes visible="0" y="10" minimized="0" height="400" x="700" maximized="0" width="679"><![CDATA[<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd">
<html><head><meta name="qrichtext" content="1" /><style type="text/css">
p, li { white-space: pre-wrap; }
</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:7.5pt; font-weight:400; font-style:normal;">
<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p></body></html>]]></projectnotes>
<timeline lp1pos="192" lpstate="0" lp0pos="0"/>
<controllers/>
</song>
</lmms-project>`;
// ------------------------- // -------------------------
// LOGGER DINÂMICO (GERENCIADOR) // LOGGER DINÂMICO (GERENCIADOR)
@ -111,7 +191,8 @@ function ensureRoom(roomName) {
if (fs.existsSync(p)) { if (fs.existsSync(p)) {
const j = JSON.parse(fs.readFileSync(p, "utf8")); const j = JSON.parse(fs.readFileSync(p, "utf8"));
roomStates[roomName] = { roomStates[roomName] = {
projectXml: j.projectXml || null, // 🔥 CORREÇÃO: Fallback para DEFAULT_PROJECT_XML se o JSON salvo estiver sem XML
projectXml: j.projectXml || DEFAULT_PROJECT_XML,
audio: j.audio || { tracks: [], clips: [] }, audio: j.audio || { tracks: [], clips: [] },
seq: j.seq || 0, seq: j.seq || 0,
tokensSeen: new Set(), tokensSeen: new Set(),
@ -122,8 +203,10 @@ function ensureRoom(roomName) {
console.warn(`[persist] falha ao carregar estado da sala ${roomName}:`, e); console.warn(`[persist] falha ao carregar estado da sala ${roomName}:`, e);
} }
// Cria sala NOVA na memória
roomStates[roomName] = { roomStates[roomName] = {
projectXml: null, // 🔥 CORREÇÃO: Inicia com o XML do LMMS completo, não mais null
projectXml: DEFAULT_PROJECT_XML,
audio: { tracks: [], clips: [] }, audio: { tracks: [], clips: [] },
seq: 0, seq: 0,
tokensSeen: new Set(), tokensSeen: new Set(),
@ -245,9 +328,6 @@ function applyAuthoritativeAction(roomName, action) {
} }
case "UPDATE_AUDIO_CLIP": { case "UPDATE_AUDIO_CLIP": {
// =================================================================
// 👇 INÍCIO DA CORREÇÃO (Blindagem do Servidor para Bug 4)
// =================================================================
if (!action.clipId || !action.props) { if (!action.clipId || !action.props) {
console.warn( console.warn(
`[Server] Ação UPDATE_AUDIO_CLIP rejeitada (dados base inválidos):`, `[Server] Ação UPDATE_AUDIO_CLIP rejeitada (dados base inválidos):`,
@ -277,9 +357,6 @@ function applyAuthoritativeAction(roomName, action) {
); );
return null; return null;
} }
// =================================================================
// 👆 FIM DA CORREÇÃO
// =================================================================
const c = state.clips.find((x) => String(x.id) === String(action.clipId)); const c = state.clips.find((x) => String(x.id) === String(action.clipId));
if (c && action.props && typeof action.props === "object") { if (c && action.props && typeof action.props === "object") {
@ -300,6 +377,29 @@ function applyAuthoritativeAction(roomName, action) {
break; break;
} }
case "REMOVE_AUDIO_LANE_BY_ID": {
// 1. Acha e remove a Track
const idx = state.tracks.findIndex((t) => t.id === action.trackId);
if (idx !== -1) {
state.tracks.splice(idx, 1);
mutated = true;
}
// 2. LIMPEZA PROFUNDA: Remove clips dessa track E clips órfãos (trackId null)
// Se removemos a track, os clips dela têm que sumir.
const prevCount = state.clips.length;
state.clips = state.clips.filter((c) => {
// Mantém apenas se tiver trackId E se esse trackId ainda existir na lista de tracks
const parentTrackExists = state.tracks.some(t => t.id === c.trackId);
return c.trackId && parentTrackExists;
});
if (state.clips.length !== prevCount) {
mutated = true;
}
break;
}
default: default:
// outras ações não são da persistência do editor de áudio // outras ações não são da persistência do editor de áudio
mutated = false; mutated = false;
@ -414,13 +514,19 @@ io.on("connection", (socket) => {
// Carrega estado da sala // Carrega estado da sala
const room = ensureRoom(roomName); const room = ensureRoom(roomName);
// Persiste estado se for LOAD_PROJECT (compat) // Persiste estado do Pattern (Notas/Sequenciador)
if (action.type === "LOAD_PROJECT" && action.xml) { // Aceita tanto carregamento total quanto atualização de notas
room.projectXml = action.xml; if ((action.type === "LOAD_PROJECT" || action.type === "SYNC_PATTERN_STATE") && action.xml) {
saveRoom(roomName); // Proteção: Não salva se o XML for vazio (evita corromper a sala com o bug antigo)
console.log( if (action.xml.trim().length > 0) {
`[broadcast_action] Estado da sala ${roomName} atualizado (LOAD_PROJECT).` room.projectXml = action.xml;
); saveRoom(roomName);
console.log(
`[broadcast_action] XML da sala ${roomName} atualizado via ${action.type}.`
);
} else {
console.warn(`[Server] Ignorando XML vazio vindo de ${action.type}`);
}
} }
// Aplica ações autoritativas do editor de áudio // Aplica ações autoritativas do editor de áudio

View File

@ -8,6 +8,7 @@ import {
addTrackToState, addTrackToState,
removeLastTrackFromState, removeLastTrackFromState,
updateTrackSample, updateTrackSample,
removeTrackById,
} from "./pattern/pattern_state.js"; } from "./pattern/pattern_state.js";
import { import {
addAudioTrackLane, addAudioTrackLane,
@ -37,13 +38,13 @@ import {
parseMmpContent, parseMmpContent,
handleLocalProjectReset, handleLocalProjectReset,
syncPatternStateToServer, syncPatternStateToServer,
BLANK_PROJECT_XML,
generateXmlFromStateExported, generateXmlFromStateExported,
} from "./file.js"; } from "./file.js";
import { renderAll, showToast } from "./ui.js"; // showToast() import { renderAll, showToast } from "./ui.js"; // showToast()
import { updateStepUI, renderPatternEditor } from "./pattern/pattern_ui.js"; import { updateStepUI, renderPatternEditor } from "./pattern/pattern_ui.js";
import { PORT_SOCK } from "./config.js"; import { PORT_SOCK } from "./config.js";
import { DEFAULT_PROJECT_XML } from "./utils.js"
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Gera um ID único otimista (ex: "track_1678886401000_abc123") // Gera um ID único otimista (ex: "track_1678886401000_abc123")
@ -200,17 +201,12 @@ socket.on("load_project_state", async (projectXml) => {
// 🔥 CORREÇÃO: Força a restauração da sessão LOCAL logo após carregar o XML // 🔥 CORREÇÃO: Força a restauração da sessão LOCAL logo após carregar o XML
// Isso garante que suas alterações locais (BPM, steps) "ganhem" do servidor // Isso garante que suas alterações locais (BPM, steps) "ganhem" do servidor
if (window.ROOM_NAME) { if (window.ROOM_NAME) {
const raw = sessionStorage.getItem(`temp_state_${window.ROOM_NAME}`); const raw = sessionStorage.getItem(`temp_state_${window.ROOM_NAME}`);
if (raw) { if (raw) {
const parsed = JSON.parse(raw); // Se existe 'raw', confiamos que é o estado mais recente do usuário.
const hasLocalAudio = parsed.audioSnapshot?.clips?.length > 0 || console.log("Re-aplicando sessão local (mesmo se vazia)...");
parsed.audioSnapshot?.tracks?.length > 0;
if (hasLocalAudio) {
console.log("Re-aplicando sessão local sobre o XML do servidor...");
await loadStateFromSession(); await loadStateFromSession();
}
} }
} }
renderAll(); renderAll();
@ -513,6 +509,44 @@ async function handleActionBroadcast(action) {
showToast(`${who} Play bases`, "info"); showToast(`${who} Play bases`, "info");
break; break;
} }
case "UPDATE_PATTERN_NOTES": {
const {
trackIndex: ti,
patternIndex: pi,
notes,
steps: incomingSteps,
} = action;
const t = appState.pattern.tracks[ti];
if (!t) break;
if (!t.patterns[pi]) t.patterns[pi] = { steps: [], notes: [] };
t.patterns[pi].notes = Array.isArray(notes) ? notes : [];
if (Array.isArray(incomingSteps)) {
t.patterns[pi].steps = incomingSteps;
}
try {
schedulePatternRerender();
} catch (e) {
console.warn("Erro no render UPDATE_PATTERN_NOTES", e);
}
const isFromSelf = action.__senderId === socket.id;
if (window.ROOM_NAME && isFromSelf) {
const xml = generateXmlFromStateExported();
sendAction({
type: "SYNC_PATTERN_STATE",
xml,
});
}
saveStateToSession();
break;
}
case "STOP_PLAYBACK": { case "STOP_PLAYBACK": {
setTimeout(stopPlayback, delayMs); setTimeout(stopPlayback, delayMs);
const who = actorOf(action); const who = actorOf(action);
@ -551,9 +585,6 @@ async function handleActionBroadcast(action) {
); );
break; break;
// =================================================================
// 👇 INÍCIO DA CORREÇÃO (Handlers Sincronia de Loop/Seek/SyncMode)
// =================================================================
case "SET_LOOP_STATE": { case "SET_LOOP_STATE": {
const changed = const changed =
appState.global.isLoopActive !== !!action.isLoopActive || appState.global.isLoopActive !== !!action.isLoopActive ||
@ -614,9 +645,6 @@ async function handleActionBroadcast(action) {
saveStateToSession(); saveStateToSession();
break; break;
} }
// =================================================================
// 👆 FIM DA CORREÇÃO
// =================================================================
// Estado Global // Estado Global
case "LOAD_PROJECT": // case "LOAD_PROJECT": //
@ -647,17 +675,22 @@ async function handleActionBroadcast(action) {
isLoadingProject = false; isLoadingProject = false;
break; break;
case "SYNC_PATTERN_STATE": // case "SYNC_PATTERN_STATE":
// Esta ação agora só será recebida de *outros* usuários, // 🔥 CORREÇÃO CRÍTICA:
// ou quando o servidor enviar, não de você mesmo. // Esta ação serve apenas para o SERVIDOR atualizar o arquivo .json no disco.
try { // Os clientes online NÃO devem recarregar o XML, pois eles já atualizaram
await parseMmpContent(action.xml); // // o estado via ações atômicas (TOGGLE_NOTE, ADD_TRACK, etc).
renderAll(); // Recarregar aqui causa o "resetProjectState" que mata o áudio.
saveStateToSession(); //
console.log("Socket: Pattern state sincronizado."); console.log("Socket: XML salvo no servidor (Sync silencioso).");
} catch (e) {
console.error("Erro SYNC_PATTERN_STATE:", e); // Se quiser garantir, salvamos apenas na sessão local do navegador
showToast("❌ Erro sync pattern", "error"); // sem mexer na engine de áudio ou na tela.
if (action.xml) {
// Atualiza apenas a string do XML na memória global para referência futura
const parser = new DOMParser();
appState.global.originalXmlDoc = parser.parseFromString(action.xml, "application/xml");
saveStateToSession();
} }
break; break;
@ -713,17 +746,55 @@ async function handleActionBroadcast(action) {
renderPatternEditor(); renderPatternEditor();
const who = actorOf(action); const who = actorOf(action);
showToast(`🥁 Faixa add por ${who}`, "info"); showToast(`🥁 Faixa add por ${who}`, "info");
// Salva o estado localmente também!
// 🔥 CORREÇÃO: Avisa o servidor que o XML mudou!
const isFromSelf = action.__senderId === socket.id;
if (window.ROOM_NAME && isFromSelf) {
const xml = generateXmlFromStateExported();
sendAction({ type: "SYNC_PATTERN_STATE", xml });
}
saveStateToSession(); saveStateToSession();
break; break;
} }
case "REMOVE_TRACK_BY_ID": {
const { trackId } = action;
const removed = removeTrackById(trackId); // Tenta remover pelo ID seguro
if (removed) {
renderPatternEditor();
const who = actorOf(action);
showToast(`❌ Faixa removida por ${who}`, "warning");
// Sincroniza o XML com o servidor para persistir a remoção
const isFromSelf = action.__senderId === socket.id;
if (window.ROOM_NAME && isFromSelf) {
// Importante: gerar o XML atualizado (sem a faixa)
const xml = generateXmlFromStateExported();
sendAction({ type: "SYNC_PATTERN_STATE", xml });
}
saveStateToSession();
} else {
console.warn(`Tentativa de remover track inexistente/fantasma: ${trackId}`);
// Não faz nada, protegendo as faixas locais!
}
break;
}
case "REMOVE_LAST_TRACK": { case "REMOVE_LAST_TRACK": {
removeLastTrackFromState(); removeLastTrackFromState();
renderPatternEditor(); renderPatternEditor();
const who = actorOf(action); const who = actorOf(action);
showToast(`❌ Faixa remov. por ${who}`, "warning"); showToast(`❌ Faixa remov. por ${who}`, "warning");
// Salva o estado localmente também!
// 🔥 CORREÇÃO: Avisa o servidor que o XML mudou (removeu o TripleOscillator, por exemplo)
const isFromSelf = action.__senderId === socket.id;
if (window.ROOM_NAME && isFromSelf) {
const xml = generateXmlFromStateExported();
sendAction({ type: "SYNC_PATTERN_STATE", xml });
}
saveStateToSession(); saveStateToSession();
break; break;
} }
@ -751,6 +822,31 @@ async function handleActionBroadcast(action) {
break; break;
} }
case "REMOVE_LAST_AUDIO_LANE": {
// 1. Verifica se tem tracks
if (appState.audio.tracks.length === 0) break;
// 2. Pega a última track
const lastTrack = appState.audio.tracks.pop();
// 3. Remove os clips associados a essa track (Limpeza)
if (lastTrack && lastTrack.id) {
// Filtra mantendo apenas os clips que NÃO pertencem à track removida
appState.audio.clips = appState.audio.clips.filter(clip => clip.trackId !== lastTrack.id);
}
// 4. Atualiza a tela
renderAll();
// 5. Notifica
const who = actorOf(action);
showToast(`🗑️ Pista de áudio removida por ${who}`, "error");
// 6. Salva sessão
saveStateToSession();
break;
}
case "REMOVE_AUDIO_CLIP": case "REMOVE_AUDIO_CLIP":
if (removeAudioClip(action.clipId)) { if (removeAudioClip(action.clipId)) {
appState.global.selectedClipId = null; appState.global.selectedClipId = null;
@ -887,47 +983,32 @@ async function handleActionBroadcast(action) {
// Snapshots // Snapshots
case "AUDIO_SNAPSHOT_REQUEST": { case "AUDIO_SNAPSHOT_REQUEST": {
const clips = appState.audio?.clips?.length || 0, // 🛑 BLOQUEADO: Agora o Servidor (backend) é quem manda o snapshot autoritativo.
tracks = appState.audio?.tracks?.length || 0; // Se deixarmos os clientes responderem, eles vão enviar estados antigos ("zumbis").
const iHave = clips > 0 || tracks > 0; console.log("Socket: Ignorando pedido de snapshot (Server Authoritative Mode).");
if (!iHave || isFromSelf) break;
if (!window.__lastSnapshotSentAt) window.__lastSnapshotSentAt = 0;
const now = Date.now();
if (now - window.__lastSnapshotSentAt < 1500) break;
window.__lastSnapshotSentAt = now;
try {
const snap = getAudioSnapshot();
sendAction({
type: "AUDIO_SNAPSHOT",
snapshot: snap,
__target: action.__senderId,
});
} catch (e) {
console.warn("Erro AUDIO_SNAPSHOT_REQUEST:", e);
}
// Salva o estado localmente também!
saveStateToSession();
break; break;
} }
case "AUDIO_SNAPSHOT": { case "AUDIO_SNAPSHOT": {
if (action.__target && action.__target !== socket.id) break; if (action.__target && action.__target !== socket.id) break;
if (appState.global.justReset) {
console.warn("Socket: Snapshot de áudio ignorado (justReset=true)."); // Removemos a verificação "if (hasClips) break" que impedia
break; // Ignora o snapshot // de carregar um estado vazio se o local já tivesse algo (como as faixas zumbis).
}
const hasClips = (appState.audio?.clips?.length || 0) > 0;
if (hasClips) break;
try { try {
console.log("Socket: Aplicando Snapshot de Áudio Autoritativo...");
await applyAudioSnapshot(action.snapshot); await applyAudioSnapshot(action.snapshot);
renderAll(); renderAll();
const who = actorOf(action); const who = actorOf(action);
showToast(`🔁 Sync áudio por ${who}`, "success"); // Só mostra toast se realmente tiver conteúdo, para não spammar na entrada
if ((action.snapshot?.tracks?.length || 0) > 0) {
showToast(`🔁 Sync áudio por ${who}`, "success");
}
} catch (e) { } catch (e) {
console.error("Erro AUDIO_SNAPSHOT:", e); console.error("Erro AUDIO_SNAPSHOT:", e);
showToast("❌ Erro sync áudio", "error");
} }
// Salva o estado localmente também! // Salva o estado localmente (mesmo que seja vazio!)
saveStateToSession(); saveStateToSession();
break; break;
} }
@ -1016,6 +1097,32 @@ async function handleActionBroadcast(action) {
break; break;
} }
case "REMOVE_AUDIO_LANE_BY_ID": { //
const { trackId } = action;
// 1. Encontra o índice da faixa com esse ID específico
const trackIndex = appState.audio.tracks.findIndex(t => t.id === trackId);
if (trackIndex !== -1) {
// 2. Remove a faixa exata do array local
appState.audio.tracks.splice(trackIndex, 1);
// 3. Remove os clips associados a este ID (Limpeza local)
appState.audio.clips = appState.audio.clips.filter(c => c.trackId !== trackId);
renderAll(); // Atualiza a tela
const who = actorOf(action);
// showToast(`🗑️ Pista removida por ${who}`, "warning");
// 4. Salva a sessão para garantir persistência no F5 local
saveStateToSession();
} else {
console.warn(`Tentativa de remover track inexistente: ${trackId}`);
}
break;
}
default: default:
console.warn("Ação desconhecida:", action.type); console.warn("Ação desconhecida:", action.type);
} }

View File

@ -2,6 +2,86 @@
import { appState } from './state.js'; import { appState } from './state.js';
import { PIXELS_PER_STEP, ZOOM_LEVELS } from './config.js'; import { PIXELS_PER_STEP, ZOOM_LEVELS } from './config.js';
export const DEFAULT_PROJECT_XML = `<?xml version="1.0"?>
<!DOCTYPE lmms-project>
<lmms-project version="1.0" type="song" creatorversion="1.2.2" creator="LMMS">
<head mastervol="100" masterpitch="0" timesig_denominator="4" timesig_numerator="4" bpm="140"/>
<song>
<trackcontainer visible="1" y="5" minimized="0" height="300" type="song" x="5" maximized="0" width="600">
<track muted="0" name="TripleOscillator" type="0" solo="0">
<instrumenttrack pitchrange="1" fxch="0" usemasterpitch="1" vol="100" pitch="0" pan="0" basenote="57">
<instrument name="tripleoscillator">
<tripleoscillator wavetype1="0" pan2="0" finer1="0" finel1="0" userwavefile0="" wavetype0="0" userwavefile1="" finer0="0" stphdetun1="0" vol0="33" wavetype2="0" coarse0="0" finel2="0" finer2="0" modalgo2="2" finel0="0" coarse1="-12" phoffset1="0" pan0="0" modalgo3="2" coarse2="-24" phoffset2="0" pan1="0" vol1="33" phoffset0="0" stphdetun0="0" modalgo1="2" vol2="33" stphdetun2="0" userwavefile2=""/>
</instrument>
<eldata fres="0.5" fwet="0" ftype="0" fcut="14000">
<elvol ctlenvamt="0" lspd_syncmode="0" x100="0" lpdel="0" latt="0" pdel="0" lspd="0.1" lshp="0" hold="0.5" lspd_numerator="4" userwavefile="" att="0" lamt="0" dec="0.5" lspd_denominator="4" sustain="0.5" rel="0.1" amt="0"/>
<elcut ctlenvamt="0" lspd_syncmode="0" x100="0" lpdel="0" latt="0" pdel="0" lspd="0.1" lshp="0" hold="0.5" lspd_numerator="4" userwavefile="" att="0" lamt="0" dec="0.5" lspd_denominator="4" sustain="0.5" rel="0.1" amt="0"/>
<elres ctlenvamt="0" lspd_syncmode="0" x100="0" lpdel="0" latt="0" pdel="0" lspd="0.1" lshp="0" hold="0.5" lspd_numerator="4" userwavefile="" att="0" lamt="0" dec="0.5" lspd_denominator="4" sustain="0.5" rel="0.1" amt="0"/>
</eldata>
<chordcreator chordrange="1" chord-enabled="0" chord="0"/>
<arpeggiator arpcycle="0" arptime_denominator="4" arpgate="100" arpdir="0" arpmode="0" arptime_syncmode="0" arp="0" arpmiss="0" arp-enabled="0" arptime="100" arprange="1" arpskip="0" arptime_numerator="4"/>
<midiport inputchannel="0" outputcontroller="0" fixedoutputvelocity="-1" outputchannel="1" fixedinputvelocity="-1" outputprogram="1" inputcontroller="0" readable="0" fixedoutputnote="-1" basevelocity="63" writable="0"/>
<fxchain enabled="0" numofeffects="0"/>
</instrumenttrack>
</track>
<track muted="0" name="Sample track" type="2" solo="0">
<sampletrack vol="100" pan="0">
<fxchain enabled="0" numofeffects="0"/>
</sampletrack>
</track>
<track muted="0" name="Beat/Bassline 0" type="1" solo="0">
<bbtrack>
<trackcontainer visible="0" y="5" minimized="0" height="400" type="bbtrackcontainer" x="610" maximized="0" width="700">
<track muted="0" name="Kicker" type="0" solo="0">
<instrumenttrack pitchrange="1" fxch="0" usemasterpitch="1" vol="100" pitch="0" pan="0" basenote="57">
<instrument name="kicker">
<kicker decay="440" version="1" noise="0" endfreq="40" decay_syncmode="0" decay_denominator="4" startfreq="150" env="0.163" startnote="1" dist="0.8" slope="0.06" decay_numerator="4" click="0.4" distend="0.8" gain="1" endnote="0"/>
</instrument>
<eldata fres="0.5" fwet="0" ftype="0" fcut="14000">
<elvol ctlenvamt="0" lspd_syncmode="0" x100="0" lpdel="0" latt="0" pdel="0" lspd="0.1" lshp="0" hold="0.5" lspd_numerator="4" userwavefile="" att="0" lamt="0" dec="0.5" lspd_denominator="4" sustain="0.5" rel="0.1" amt="0"/>
<elcut ctlenvamt="0" lspd_syncmode="0" x100="0" lpdel="0" latt="0" pdel="0" lspd="0.1" lshp="0" hold="0.5" lspd_numerator="4" userwavefile="" att="0" lamt="0" dec="0.5" lspd_denominator="4" sustain="0.5" rel="0.1" amt="0"/>
<elres ctlenvamt="0" lspd_syncmode="0" x100="0" lpdel="0" latt="0" pdel="0" lspd="0.1" lshp="0" hold="0.5" lspd_numerator="4" userwavefile="" att="0" lamt="0" dec="0.5" lspd_denominator="4" sustain="0.5" rel="0.1" amt="0"/>
</eldata>
<chordcreator chordrange="1" chord-enabled="0" chord="0"/>
<arpeggiator arpcycle="0" arptime_denominator="4" arpgate="100" arpdir="0" arpmode="0" arptime_syncmode="0" arp="0" arpmiss="0" arp-enabled="0" arptime="100" arprange="1" arpskip="0" arptime_numerator="4"/>
<midiport inputchannel="0" outputcontroller="0" fixedoutputvelocity="-1" outputchannel="1" fixedinputvelocity="-1" outputprogram="1" inputcontroller="0" readable="0" fixedoutputnote="-1" basevelocity="63" writable="0"/>
<fxchain enabled="0" numofeffects="0"/>
</instrumenttrack>
<pattern pos="0" muted="0" name="Kicker" steps="16" type="0"/>
</track>
</trackcontainer>
</bbtrack>
</track>
<track muted="0" name="Automation track" type="5" solo="0">
<automationtrack/>
</track>
</trackcontainer>
<track muted="0" name="Automation track" type="6" solo="0">
<automationtrack/>
<automationpattern pos="0" len="192" name="Numerator" prog="0" tens="1" mute="0"/>
<automationpattern pos="0" len="192" name="Denominator" prog="0" tens="1" mute="0"/>
<automationpattern pos="0" len="192" name="Tempo" prog="0" tens="1" mute="0"/>
<automationpattern pos="0" len="192" name="Master volume" prog="0" tens="1" mute="0"/>
<automationpattern pos="0" len="192" name="Master pitch" prog="0" tens="1" mute="0"/>
</track>
<fxmixer visible="1" y="310" minimized="0" height="333" x="5" maximized="0" width="543">
<fxchannel volume="1" muted="0" num="0" name="Master" soloed="0">
<fxchain enabled="0" numofeffects="0"/>
</fxchannel>
</fxmixer>
<ControllerRackView visible="1" y="310" minimized="0" height="200" x="680" maximized="0" width="350"/>
<pianoroll visible="0" y="5" minimized="0" height="480" x="5" maximized="0" width="860"/>
<automationeditor visible="0" y="1" minimized="0" height="400" x="1" maximized="0" width="860"/>
<projectnotes visible="0" y="10" minimized="0" height="400" x="700" maximized="0" width="679"><![CDATA[<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd">
<html><head><meta name="qrichtext" content="1" /><style type="text/css">
p, li { white-space: pre-wrap; }
</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:7.5pt; font-weight:400; font-style:normal;">
<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p></body></html>]]></projectnotes>
<timeline lp1pos="192" lpstate="0" lp0pos="0"/>
<controllers/>
</song>
</lmms-project>`;
/** /**
* Helper interna para ler o BPM do input. * Helper interna para ler o BPM do input.
* @returns {number} O BPM atual. * @returns {number} O BPM atual.

View File

@ -84,33 +84,31 @@
id="new-project-btn" id="new-project-btn"
title="Novo Projeto" title="Novo Projeto"
></i> ></i>
<span style="margin-left: 8px">Novo projeto</span>
<i <i
class="fa-solid fa-folder-open" class="fa-solid fa-folder-open"
id="open-mmp-btn" id="open-mmp-btn"
title="Abrir Projeto do Servidor" title="Abrir Projeto do Servidor"
></i> ></i>
<span style="margin-left: 8px">Abrir projetos</span>
<i <i
class="fa-solid fa-save" class="fa-solid fa-save"
id="save-mmp-btn" id="save-mmp-btn"
title="Salvar Projeto (.mmp)" title="Salvar Projeto (.mmp)"
></i> ></i>
<span style="margin-left: 8px">Salvar projeto</span>
<i <i
class="fa-solid fa-upload" class="fa-solid fa-upload"
id="upload-sample-btn" id="upload-sample-btn"
title="Carregar Sample do Computador" title="Carregar Sample do Computador"
></i> ></i>
<span style="margin-left: 8px">Enviar sample</span>
</div> </div>
<div class="divider"></div> <div class="divider"></div>
<div class="control-group"> <div class="control-group">
<i
class="fa-solid fa-backward-step"
id="rewind-btn"
title="Voltar ao Início"
></i>
<i class="fa-solid fa-play" title="Play/Pause Global (Futuro)"></i>
<i class="fa-solid fa-stop" title="Stop Global (Futuro)"></i>
<button id="record-btn" class="transport-btn" title="Gravar"> <button id="record-btn" class="transport-btn" title="Gravar">
<i class="fa-solid fa-circle-dot"></i> <i class="fa-solid fa-circle-dot"></i>
<span style="margin-left: 8px">Gravar</span>
</button> </button>
</div> </div>
<div class="divider"></div> <div class="divider"></div>
@ -228,6 +226,10 @@
<i class="fa-solid fa-users"></i> <i class="fa-solid fa-users"></i>
<span style="margin-left: 8px">Criar Sala</span> <span style="margin-left: 8px">Criar Sala</span>
</button> </button>
<button id="toggle-mixer-btn" class="control-btn" title="Abrir Mixer (Futuro)">
<i class="fa-solid fa-sliders"></i>
<span style="margin-left: 8px">Abrir Mixer</span>
</button>
</div> </div>
<div class="spacer"></div> <div class="spacer"></div>
<div class="control-group master-controls"> <div class="control-group master-controls">
@ -248,54 +250,50 @@
<main class="main-content"> <main class="main-content">
<div class="beat-editor"> <div class="beat-editor">
<div class="editor-header">
Mostrar/esconder Editor de Bases
<div class="window-controls">
<i class="fa-solid fa-minus"></i
><i class="fa-regular fa-square"></i
><i class="fa-solid fa-xmark"></i>
</div>
</div>
<div class="editor-toolbar"> <div class="editor-toolbar">
<div class="playback-controls"> <div class="editor-header">
<i class="fa-solid fa-play" id="play-btn" title="Play/Pause"></i> <div class="toolbar-group">
<i class="fa-solid fa-stop" id="stop-btn" title="Stop"></i> <button id="play-btn" class="control-btn" title="Play Patterns"><i class="fa-solid fa-play"></i></button>
</div> <button id="stop-btn" class="control-btn" title="Stop"><i class="fa-solid fa-stop"></i></button>
<div class="pattern-manager">
<h2 id="beat-bassline-title"></h2>
<select
id="global-pattern-selector"
class="pattern-selector"
disabled
>
<option>Selecione uma faixa</option>
</select>
<button id="add-pattern-btn" class="pattern-btn">+</button>
<button id="remove-pattern-btn" class="pattern-btn">-</button>
<button <div class="toolbar-divider"></div>
id="send-pattern-to-playlist-btn"
class="pattern-btn" <select id="global-pattern-selector" title="Selecionar Pattern Ativo">
title="Enviar Pattern para a Playlist" <option value="0">Pattern 1</option>
style="width: auto; padding: 0 8px; font-size: 0.9rem" <option value="1">Pattern 2</option>
> <option value="2">Pattern 3</option>
<i class="fa-solid fa-arrow-right-to-bracket"></i> Enviar <option value="3">Pattern 4</option>
</button> </select>
</div>
<button class="control-btn" id="add-pattern-btn" title="Novo Pattern (Não implementado no JS ainda)"><i class="fa-solid fa-plus"></i></button>
<button class="control-btn" id="remove-pattern-btn" title="Remover Pattern (Não implementado no JS ainda)"><i class="fa-solid fa-minus"></i></button>
<div class="toolbar-divider"></div>
<button id="send-pattern-to-playlist-btn" class="control-btn" title="Renderizar para Áudio">
<i class="fa-solid fa-arrow-right-to-bracket"></i> <span style="margin-left:5px;">Enviar p/ Playlist</span>
</button>
</div>
<div class="toolbar-group">
<div class="toolbar-divider"></div>
<button id="add-instrument-btn" class="control-btn" title="Adicionar Instrumento">
<i class="fa-solid fa-music"></i> <i class="fa-solid fa-plus" style="font-size: 0.7em; margin-left: 3px;"></i>
<span style="margin-left: 8px">Adicionar Instrumento</span>
</button>
<button id="remove-instrument-btn" class="control-btn" title="Remover Instrumento Selecionado">
<i class="fa-solid fa-trash"></i>
<span style="margin-left: 8px">Remover Instrumento</span>
</button>
</div>
</div>
<div id="sequencer-grid" class="sequencer-container">
</div>
<div class="tool-icons"> <div class="tool-icons">
<i class="fa-solid fa-table-cells"></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
class="fa-solid fa-wave-square"
id="bounce-pattern-btn"
title="Renderizar Pattern para Pista de Áudio"
></i>
<i
class="fa-solid fa-plus"
id="add-bar-btn"
title="Adicionar 1 Compasso"
></i>
</div> </div>
<div id="timeline-context-menu"> <div id="timeline-context-menu">
<div id="copy-clip">Copiar</div> <div id="copy-clip">Copiar</div>
@ -311,176 +309,110 @@
<div id="ruler-set-loop-start">Definir Início do Loop</div> <div id="ruler-set-loop-start">Definir Início do Loop</div>
<div id="ruler-set-loop-end">Definir Fim do Loop</div> <div id="ruler-set-loop-end">Definir Fim do Loop</div>
</div> </div>
<div class="zoom-controls">
<i class="fa-solid fa-minus" id="remove-instrument-btn"></i
><i class="fa-solid fa-plus" id="add-instrument-btn"></i>
</div>
</div> </div>
<div id="track-container"></div> <div id="track-container"></div>
</div> </div>
<div class="piano-roll-editor" id="piano-roll-editor" style="display: none;"> <div class="piano-roll-editor" id="piano-roll-editor" style="display: none;">
<div class="editor-header"> <div class="editor-header">
<span>Piano Roll - <span id="piano-roll-instrument-name">Instrumento 1</span></span> <span>Piano Roll - <span id="piano-roll-instrument-name">Instrumento 1</span></span>
<div class="window-controls"> <div class="window-controls">
<i class="fa-solid fa-xmark" id="close-piano-roll-btn"></i> <i class="fa-solid fa-xmark" id="close-piano-roll-btn"></i>
</div> </div>
</div> </div>
<div class="piano-roll-toolbar"> <div class="piano-roll-toolbar">
<div class="playback-controls"> <div class="playback-controls">
<i class="fa-solid fa-pencil active" title="Draw Tool"></i> <i class="fa-solid fa-pencil active" title="Draw Tool"></i>
<i class="fa-solid fa-eraser" title="Erase Tool"></i> <i class="fa-solid fa-eraser" title="Erase Tool"></i>
</div> </div>
<div class="snap-controls"> <div class="snap-controls">
<label>Snap:</label> <label>Snap:</label>
<select> <select>
<option>1/4</option> <option>1/4</option>
<option>1/8</option> <option>1/8</option>
<option selected>1/16</option> <option selected>1/16</option>
</select> </select>
</div> </div>
</div> </div>
<div class="piano-roll-workspace"> <div class="piano-roll-workspace">
<div class="piano-keys-container" id="piano-keys-container"> <div class="piano-keys-container" id="piano-keys-container">
<canvas id="piano-keys-canvas"></canvas> <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="piano-grid-container" id="piano-grid-container">
<canvas id="piano-grid-canvas"></canvas>
</div>
</div>
</div>
<div class="audio-editor"> <div class="audio-editor">
<div class="editor-header"> <div class="editor-header" id="audio-editor-header">
<span>Editor de Amostras de Áudio</span> <div class="toolbar-group">
<span class="panel-title" style="margin-right: 10px;"><i class="fa-solid fa-wave-square"></i> Playlist</span>
<div class="playback-controls"> <div class="toolbar-divider"></div>
<i
class="fa-solid fa-search-minus"
id="zoom-out-btn"
title="Zoom Out"
></i>
<i
class="fa-solid fa-search-plus"
id="zoom-in-btn"
title="Zoom In"
></i>
<i
class="fa-solid fa-scissors"
id="slice-tool-btn"
title="Ferramenta de Corte"
></i>
<i <button id="audio-editor-play-btn" class="control-btn" title="Play Playlist">
class="fa-solid fa-arrows-left-right-to-line" <i class="fa-solid fa-play"></i>
id="resize-tool-trim" </button>
title="Modo de Redimensionamento (Aparar/Trimming)" <button id="audio-editor-stop-btn" class="control-btn" title="Stop Playlist">
></i> <i class="fa-solid fa-stop"></i>
<i </button>
class="fa-solid fa-arrows-left-right" <button id="audio-editor-loop-btn" class="control-btn" title="Loop Mode">
id="resize-tool-stretch" <i class="fa-solid fa-repeat"></i>
title="Modo de Redimensionamento (Esticar/Time Stretch)" <span style="margin-left: 8px">Loop</span>
></i> </button>
<i </div>
class="fa-solid fa-play"
id="audio-editor-play-btn" <div class="toolbar-group">
title="Play/Pause" <button id="slice-tool-btn" class="control-btn" title="Ferramenta Corte (Slice)">
></i> <i class="fa-solid fa-scissors"></i>
<i <span style="margin-left: 8px">Cortar</span>
class="fa-solid fa-stop" </button>
id="audio-editor-stop-btn" <button id="resize-tool-trim" class="control-btn active" title="Redimensionar (Trim)">
title="Stop" <i class="fa-solid fa-arrows-left-right-to-line"></i>
></i> <span style="margin-left: 8px">Redimensionar</span>
<i </button>
class="fa-solid fa-repeat" <button id="resize-tool-stretch" class="control-btn" title="Esticar (Stretch)">
id="audio-editor-loop-btn" <i class="fa-solid fa-expand"></i>
title="Ativar/Desativar Loop" <span style="margin-left: 8px">Esticar</span>
></i> </button>
<button <button id="delete-clip" class="control-btn" title="Excluir Clip Selecionado">
id="sync-mode-btn" <i class="fa-solid fa-trash"></i>
class="control-btn active" <span style="margin-left: 8px">Rem. Instrumento</span>
title="Modo de Sincronia de Playback (Global/Local)" </button>
>
Global <div class="toolbar-divider"></div>
</button>
<i <button id="zoom-out-btn" class="control-btn" title="Zoom Out">
class="fa-solid fa-plus" <i class="fa-solid fa-magnifying-glass-minus"></i>
id="add-audio-track-btn" </button>
title="Adicionar Pista de Áudio" <button id="zoom-in-btn" class="control-btn" title="Zoom In">
></i> <i class="fa-solid fa-magnifying-glass-plus"></i>
</div> </button>
<div class="toolbar-divider"></div>
<button id="add-audio-track-btn" class="control-btn" title="Adicionar Pista">
<i class="fa-solid fa-plus"></i>
<span style="margin-left: 8px">Add Track</span>
</button>
<button id="remove-audio-track-btn" class="control-btn" title="Remover Última Pista">
<i class="fa-solid fa-minus"></i>
<span style="margin-left: 8px">Rem. Track</span>
</button>
</div>
</div> </div>
<div id="audio-track-container">
<div class="audio-track-lane">
<div class="track-info">
<div class="track-info-header">
<i class="fa-solid fa-gear"></i>
<span class="track-name">Pista de Áudio 1</span>
<div class="track-mute"></div>
</div>
<div class="track-controls">
<div class="knob-container">
<div class="knob" data-control="volume">
<div class="knob-indicator"></div>
</div>
<span>VOL</span>
</div>
<div class="knob-container">
<div class="knob" data-control="pan">
<div class="knob-indicator"></div>
</div>
<span>PAN</span>
</div>
</div>
</div>
<div class="timeline-container"> <div class="audio-tracks-wrapper">
<div class="spectrogram-view-grid" style="width: 4000px"> <div id="audio-timeline-ruler" class="timeline-ruler"></div>
<div <div id="loop-region" class="loop-region"></div>
class="timeline-clip" <div id="playhead" class="playhead"></div>
style="left: 100px; width: 400px" <div id="audio-track-container" class="track-container"></div>
></div>
<div class="playhead"></div>
</div>
</div>
</div>
<div class="audio-track-lane">
<div class="track-info">
<div class="track-info-header">
<i class="fa-solid fa-gear"></i>
<span class="track-name">Pista de Áudio 2</span>
<div class="track-mute"></div>
</div>
<div class="track-controls">
<div class="knob-container">
<div class="knob" data-control="volume">
<div class="knob-indicator"></div>
</div>
<span>VOL</span>
</div>
<div class="knob-container">
<div class="knob" data-control="pan">
<div class="knob-indicator"></div>
</div>
<span>PAN</span>
</div>
</div>
</div>
<div class="timeline-container">
<div id="loop-region" class="loop-region">
<div class="spectrogram-view-grid" style="width: 4000px">
<div class="timeline-clip" style="left: 50px; width: 600px">
<div class="clip-name">jungle01.ogg</div>
</div>
</div>
</div>
<div class="playhead"></div>
</div>
</div>
</div> </div>
</div> </div>
</main> </main>
</div> </div>
<input <input