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
.bundle
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;
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
/* =============================================== */
@ -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 .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; }
.compasso-input { width: 25px; }
.compasso-separator { color: var(--accent-green); font-weight: 700; font-size: 1.4rem; font-family: Courier New, Courier, monospace; margin: 0 2px; }
/* --- CORREÇÃO DO COMPASSO --- */
/* 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[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; }
@ -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;
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; }
#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;
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-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; }

View File

@ -23,23 +23,43 @@ import { sendAction } from "../socket.js";
export function renderAudioEditor() {
const audioEditor = document.querySelector(".audio-editor");
const existingTrackContainer = document.getElementById(
"audio-track-container"
);
const existingTrackContainer = document.getElementById("audio-track-container");
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 ---
let rulerWrapper = audioEditor.querySelector(".ruler-wrapper");
// Buscamos a régua dentro desse pai correto
let rulerWrapper = tracksParent.querySelector(".ruler-wrapper");
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.className = "ruler-wrapper";
rulerWrapper.innerHTML = `
<div class="ruler-spacer"></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");
ruler.innerHTML = "";
@ -243,7 +263,9 @@ export function renderAudioEditor() {
// Recriação Container Pistas (sem alterações)
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)
appState.audio.tracks.forEach((trackData) => {
@ -725,7 +747,6 @@ export function renderAudioEditor() {
const constrainedLeftPx = Math.max(0, newLeftPx);
let newStartTime = constrainedLeftPx / currentPixelsPerSecond;
newStartTime = quantizeTime(newStartTime);
// (Correção Bug 4 - remove Number())
updateAudioClipProperties(clipId, {
trackId: newTrackId,
startTimeInSeconds: newStartTime,
@ -756,13 +777,8 @@ export function renderAudioEditor() {
const clickX = event.clientX - rect.left;
const absoluteX = clickX + scrollLeft;
const newTime = absoluteX / currentPixelsPerSecond;
// =================================================================
// 👇 INÍCIO DA CORREÇÃO (Sincronia de Seek na Pista)
// =================================================================
// Sincronia de Seek na Pista)
sendAction({ type: "SET_SEEK_TIME", seekTime: newTime });
// seekAudioEditor(newTime); // 👈 Substituído
// =================================================================
// 👆 FIM DA CORREÇÃO
};
handleSeek(e); // Aplica no mousedown
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)
/**
@ -908,4 +924,3 @@ function createPatternViewElement(patternData) {
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 { loadAudioForTrack } from "./pattern/pattern_state.js";
import { renderAll, getSamplePathMap } from "./ui.js";
@ -7,21 +9,13 @@ import {
initializeAudioContext,
getMainGainNode,
} from "./audio.js";
import { DEFAULT_PROJECT_XML } from "./utils.js"
import * as Tone from "https://esm.sh/tone";
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() {
console.log("Recebido comando de reset. Limpando estado local...");
@ -89,7 +83,7 @@ export async function loadProjectFromServer(fileName) {
export async function parseMmpContent(xmlString) {
resetProjectState();
initializeAudioContext();
appState.global.justReset = xmlString === BLANK_PROJECT_XML;
appState.global.justReset = xmlString === DEFAULT_PROJECT_XML;
const audioContainer = document.getElementById("audio-track-container");
if (audioContainer) {
@ -305,10 +299,7 @@ export async function parseMmpContent(xmlString) {
appState.pattern.activeTrackId = appState.pattern.tracks[0]?.id || null;
appState.pattern.activePatternIndex = 0;
// --- SUBSTUIÇÃO DO BLOCO DE RESTAURAÇÃO ---
// Em vez daquele bloco try/catch gigante, apenas chamamos a função:
loadStateFromSession();
// ------------------------------------------
await Promise.resolve();
renderAll();
@ -324,17 +315,19 @@ export function generateMmpFile() {
function generateXmlFromState() {
if (!appState.global.originalXmlDoc) {
console.warn("Não há XML original. Retornando vazio.");
return "";
console.log("Gerando XML a partir do template em branco...");
const parser = new DOMParser();
appState.global.originalXmlDoc = parser.parseFromString(DEFAULT_PROJECT_XML, "application/xml");
}
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);
head.setAttribute("bpm", document.getElementById("bpm-input").value || 140);
head.setAttribute("num_bars", document.getElementById("bars-input").value || 1);
head.setAttribute("timesig_numerator", document.getElementById("compasso-a-input").value || 4);
head.setAttribute("timesig_denominator", document.getElementById("compasso-b-input").value || 4);
}
const bbTrackContainer = xmlDoc.querySelector('track[type="1"] > bbtrack > trackcontainer');
@ -344,7 +337,6 @@ function generateXmlFromState() {
.map((track) => createTrackXml(track))
.join("");
// 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);
@ -369,21 +361,22 @@ function createTrackXml(track) {
const lmmsVolume = Math.round(track.volume * 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) => {
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 "";
@ -398,8 +391,8 @@ function createTrackXml(track) {
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}">
<instrument name="${track.instrumentName}">
${track.instrumentXml}
<instrument name="${instrName}">
${instrXml}
</instrument>
<fxchain enabled="0" numofeffects="0"/>
</instrumenttrack>
@ -462,4 +455,3 @@ function downloadFile(content, fileName) {
}
export { generateXmlFromState as generateXmlFromStateExported };
export { BLANK_PROJECT_XML }; // Mantenha o que já estava

View File

@ -5,7 +5,7 @@ import {
restartAudioEditorIfPlaying,
} from "./audio/audio_audio.js";
import { initializeAudioContext } from "./audio.js";
import { handleFileLoad, generateMmpFile, BLANK_PROJECT_XML } from "./file.js";
import { handleFileLoad, generateMmpFile } from "./file.js";
import {
renderAll,
loadAndRenderSampleBrowser,
@ -13,7 +13,7 @@ import {
closeOpenProjectModal,
} from "./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 { loadProjectFromServer } from "./file.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}.mmp`;
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
@ -313,7 +318,7 @@ document.addEventListener("DOMContentLoaded", () => {
)
)
return;
sendAction({ type: "LOAD_PROJECT", xml: BLANK_PROJECT_XML });
sendAction({ type: "LOAD_PROJECT", xml: DEFAULT_PROJECT_XML });
});
addBarBtn?.addEventListener("click", () => {
@ -342,6 +347,29 @@ document.addEventListener("DOMContentLoaded", () => {
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", () => {
initializeAudioContext();
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)
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 { TripleOscillator } from "../../audio/plugins/TripleOscillator.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 { 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() {
appState.pattern.tracks.forEach(track => {
try { track.player?.dispose(); } catch {}
@ -55,20 +60,17 @@ export async function loadAudioForTrack(track) {
// --- 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
// Se não for áudio padrão, assumimos Plugin (ou Kicker padrão)
if (!track.samplePath || !isStandardAudio) {
try {
if (track.instrument) { try { track.instrument.dispose(); } catch {} }
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 = {};
// SELETOR DE PLUGINS
@ -87,64 +89,53 @@ export async function loadAudioForTrack(track) {
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
case "freeboy":
case "papu":
case "sid":
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":
case "zynaddsubfx":
case "watsyn":
case "monstro":
case "vibedstrings":
case "supersaw":
synth = new SuperSaw(Tone.getContext(), pluginData);
break;
case "organic": // Fallback simples para Organic (Additive)
case "organic":
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" } });
console.warn(`Plugin ${name} desconhecido, usando fallback (Kicker).`);
// Fallback seguro: Kicker
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) {
// 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';
// Atualiza o nome se ele estava vazio
if (!track.instrumentName) track.instrumentName = name;
console.log(`[Audio] Plugin carregado: ${name}`);
} catch (e) {
console.error("Erro ao carregar plugin:", name, e);
console.error("Erro ao carregar plugin:", track.instrumentName, e);
}
return track;
}
// 3. Lógica para SAMPLERS (Arquivos de Áudio Reais)
// 3. Lógica para SAMPLERS
try {
try { track.player?.dispose(); } catch {}
track.player = null;
@ -166,7 +157,7 @@ export async function loadAudioForTrack(track) {
track.player = player;
track.buffer = buffer;
track.type = 'sampler'; // Garante o tipo correto
track.type = 'sampler';
} catch (error) {
console.error('Erro ao carregar sample:', track.samplePath);
@ -181,21 +172,29 @@ export async function loadAudioForTrack(track) {
export function addTrackToState() {
const totalSteps = getTotalSteps();
const referenceTrack = appState.pattern.tracks[0];
const newId = Date.now() + Math.random();
const newTrack = {
id: Date.now() + Math.random(),
name: "novo instrumento",
id: newId,
name: `Novo Instrumento ${appState.pattern.tracks.length + 1}`,
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,
buffer: null,
patterns: referenceTrack
? referenceTrack.patterns.map(p => ({
name: p.name,
steps: new Array(p.steps.length).fill(false),
notes: [],
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,
volume: DEFAULT_VOLUME,
pan: DEFAULT_PAN,
@ -206,10 +205,39 @@ export function addTrackToState() {
newTrack.volumeNode.connect(newTrack.pannerNode);
newTrack.pannerNode.connect(getMainGainNode());
// Carrega o áudio (vai cair no case "kicker" do loadAudioForTrack)
loadAudioForTrack(newTrack);
appState.pattern.tracks.push(newTrack);
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() {

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 path = require("path");
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)
@ -111,7 +191,8 @@ function ensureRoom(roomName) {
if (fs.existsSync(p)) {
const j = JSON.parse(fs.readFileSync(p, "utf8"));
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: [] },
seq: j.seq || 0,
tokensSeen: new Set(),
@ -122,8 +203,10 @@ function ensureRoom(roomName) {
console.warn(`[persist] falha ao carregar estado da sala ${roomName}:`, e);
}
// Cria sala NOVA na memória
roomStates[roomName] = {
projectXml: null,
// 🔥 CORREÇÃO: Inicia com o XML do LMMS completo, não mais null
projectXml: DEFAULT_PROJECT_XML,
audio: { tracks: [], clips: [] },
seq: 0,
tokensSeen: new Set(),
@ -245,9 +328,6 @@ function applyAuthoritativeAction(roomName, action) {
}
case "UPDATE_AUDIO_CLIP": {
// =================================================================
// 👇 INÍCIO DA CORREÇÃO (Blindagem do Servidor para Bug 4)
// =================================================================
if (!action.clipId || !action.props) {
console.warn(
`[Server] Ação UPDATE_AUDIO_CLIP rejeitada (dados base inválidos):`,
@ -277,9 +357,6 @@ function applyAuthoritativeAction(roomName, action) {
);
return null;
}
// =================================================================
// 👆 FIM DA CORREÇÃO
// =================================================================
const c = state.clips.find((x) => String(x.id) === String(action.clipId));
if (c && action.props && typeof action.props === "object") {
@ -300,6 +377,29 @@ function applyAuthoritativeAction(roomName, action) {
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:
// outras ações não são da persistência do editor de áudio
mutated = false;
@ -414,13 +514,19 @@ io.on("connection", (socket) => {
// Carrega estado da sala
const room = ensureRoom(roomName);
// Persiste estado se for LOAD_PROJECT (compat)
if (action.type === "LOAD_PROJECT" && action.xml) {
// Persiste estado do Pattern (Notas/Sequenciador)
// Aceita tanto carregamento total quanto atualização de notas
if ((action.type === "LOAD_PROJECT" || action.type === "SYNC_PATTERN_STATE") && action.xml) {
// Proteção: Não salva se o XML for vazio (evita corromper a sala com o bug antigo)
if (action.xml.trim().length > 0) {
room.projectXml = action.xml;
saveRoom(roomName);
console.log(
`[broadcast_action] Estado da sala ${roomName} atualizado (LOAD_PROJECT).`
`[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

View File

@ -8,6 +8,7 @@ import {
addTrackToState,
removeLastTrackFromState,
updateTrackSample,
removeTrackById,
} from "./pattern/pattern_state.js";
import {
addAudioTrackLane,
@ -37,13 +38,13 @@ import {
parseMmpContent,
handleLocalProjectReset,
syncPatternStateToServer,
BLANK_PROJECT_XML,
generateXmlFromStateExported,
} from "./file.js";
import { renderAll, showToast } from "./ui.js"; // showToast()
import { updateStepUI, renderPatternEditor } from "./pattern/pattern_ui.js";
import { PORT_SOCK } from "./config.js";
import { DEFAULT_PROJECT_XML } from "./utils.js"
// -----------------------------------------------------------------------------
// Gera um ID único otimista (ex: "track_1678886401000_abc123")
@ -202,17 +203,12 @@ socket.on("load_project_state", async (projectXml) => {
if (window.ROOM_NAME) {
const raw = sessionStorage.getItem(`temp_state_${window.ROOM_NAME}`);
if (raw) {
const parsed = JSON.parse(raw);
const hasLocalAudio = parsed.audioSnapshot?.clips?.length > 0 ||
parsed.audioSnapshot?.tracks?.length > 0;
if (hasLocalAudio) {
console.log("Re-aplicando sessão local sobre o XML do servidor...");
// Se existe 'raw', confiamos que é o estado mais recente do usuário.
console.log("Re-aplicando sessão local (mesmo se vazia)...");
await loadStateFromSession();
}
}
}
renderAll();
showToast("🎵 Projeto carregado com sucesso", "success");
@ -513,6 +509,44 @@ async function handleActionBroadcast(action) {
showToast(`${who} Play bases`, "info");
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": {
setTimeout(stopPlayback, delayMs);
const who = actorOf(action);
@ -551,9 +585,6 @@ async function handleActionBroadcast(action) {
);
break;
// =================================================================
// 👇 INÍCIO DA CORREÇÃO (Handlers Sincronia de Loop/Seek/SyncMode)
// =================================================================
case "SET_LOOP_STATE": {
const changed =
appState.global.isLoopActive !== !!action.isLoopActive ||
@ -614,9 +645,6 @@ async function handleActionBroadcast(action) {
saveStateToSession();
break;
}
// =================================================================
// 👆 FIM DA CORREÇÃO
// =================================================================
// Estado Global
case "LOAD_PROJECT": //
@ -647,17 +675,22 @@ async function handleActionBroadcast(action) {
isLoadingProject = false;
break;
case "SYNC_PATTERN_STATE": //
// Esta ação agora só será recebida de *outros* usuários,
// ou quando o servidor enviar, não de você mesmo.
try {
await parseMmpContent(action.xml); //
renderAll();
saveStateToSession(); //
console.log("Socket: Pattern state sincronizado.");
} catch (e) {
console.error("Erro SYNC_PATTERN_STATE:", e);
showToast("❌ Erro sync pattern", "error");
case "SYNC_PATTERN_STATE":
// 🔥 CORREÇÃO CRÍTICA:
// Esta ação serve apenas para o SERVIDOR atualizar o arquivo .json no disco.
// Os clientes online NÃO devem recarregar o XML, pois eles já atualizaram
// o estado via ações atômicas (TOGGLE_NOTE, ADD_TRACK, etc).
// Recarregar aqui causa o "resetProjectState" que mata o áudio.
console.log("Socket: XML salvo no servidor (Sync silencioso).");
// Se quiser garantir, salvamos apenas na sessão local do navegador
// 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;
@ -713,17 +746,55 @@ async function handleActionBroadcast(action) {
renderPatternEditor();
const who = actorOf(action);
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();
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": {
removeLastTrackFromState();
renderPatternEditor();
const who = actorOf(action);
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();
break;
}
@ -751,6 +822,31 @@ async function handleActionBroadcast(action) {
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":
if (removeAudioClip(action.clipId)) {
appState.global.selectedClipId = null;
@ -887,47 +983,32 @@ async function handleActionBroadcast(action) {
// Snapshots
case "AUDIO_SNAPSHOT_REQUEST": {
const clips = appState.audio?.clips?.length || 0,
tracks = appState.audio?.tracks?.length || 0;
const iHave = clips > 0 || tracks > 0;
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();
// 🛑 BLOQUEADO: Agora o Servidor (backend) é quem manda o snapshot autoritativo.
// Se deixarmos os clientes responderem, eles vão enviar estados antigos ("zumbis").
console.log("Socket: Ignorando pedido de snapshot (Server Authoritative Mode).");
break;
}
case "AUDIO_SNAPSHOT": {
if (action.__target && action.__target !== socket.id) break;
if (appState.global.justReset) {
console.warn("Socket: Snapshot de áudio ignorado (justReset=true).");
break; // Ignora o snapshot
}
const hasClips = (appState.audio?.clips?.length || 0) > 0;
if (hasClips) break;
// Removemos a verificação "if (hasClips) break" que impedia
// de carregar um estado vazio se o local já tivesse algo (como as faixas zumbis).
try {
console.log("Socket: Aplicando Snapshot de Áudio Autoritativo...");
await applyAudioSnapshot(action.snapshot);
renderAll();
const who = actorOf(action);
// 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) {
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();
break;
}
@ -1016,6 +1097,32 @@ async function handleActionBroadcast(action) {
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:
console.warn("Ação desconhecida:", action.type);
}

View File

@ -2,6 +2,86 @@
import { appState } from './state.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.
* @returns {number} O BPM atual.

View File

@ -84,33 +84,31 @@
id="new-project-btn"
title="Novo Projeto"
></i>
<span style="margin-left: 8px">Novo projeto</span>
<i
class="fa-solid fa-folder-open"
id="open-mmp-btn"
title="Abrir Projeto do Servidor"
></i>
<span style="margin-left: 8px">Abrir projetos</span>
<i
class="fa-solid fa-save"
id="save-mmp-btn"
title="Salvar Projeto (.mmp)"
></i>
<span style="margin-left: 8px">Salvar projeto</span>
<i
class="fa-solid fa-upload"
id="upload-sample-btn"
title="Carregar Sample do Computador"
></i>
<span style="margin-left: 8px">Enviar sample</span>
</div>
<div class="divider"></div>
<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">
<i class="fa-solid fa-circle-dot"></i>
<span style="margin-left: 8px">Gravar</span>
</button>
</div>
<div class="divider"></div>
@ -228,6 +226,10 @@
<i class="fa-solid fa-users"></i>
<span style="margin-left: 8px">Criar Sala</span>
</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 class="spacer"></div>
<div class="control-group master-controls">
@ -248,54 +250,50 @@
<main class="main-content">
<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="playback-controls">
<i class="fa-solid fa-play" id="play-btn" title="Play/Pause"></i>
<i class="fa-solid fa-stop" id="stop-btn" title="Stop"></i>
</div>
<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>
<div class="editor-header">
<div class="toolbar-group">
<button id="play-btn" class="control-btn" title="Play Patterns"><i class="fa-solid fa-play"></i></button>
<button id="stop-btn" class="control-btn" title="Stop"><i class="fa-solid fa-stop"></i></button>
<button
id="send-pattern-to-playlist-btn"
class="pattern-btn"
title="Enviar Pattern para a Playlist"
style="width: auto; padding: 0 8px; font-size: 0.9rem"
>
<i class="fa-solid fa-arrow-right-to-bracket"></i> Enviar
<div class="toolbar-divider"></div>
<select id="global-pattern-selector" title="Selecionar Pattern Ativo">
<option value="0">Pattern 1</option>
<option value="1">Pattern 2</option>
<option value="2">Pattern 3</option>
<option value="3">Pattern 4</option>
</select>
<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">
<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 id="timeline-context-menu">
<div id="copy-clip">Copiar</div>
@ -311,13 +309,10 @@
<div id="ruler-set-loop-start">Definir Início do Loop</div>
<div id="ruler-set-loop-end">Definir Fim do Loop</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 id="track-container"></div>
</div>
<div class="piano-roll-editor" id="piano-roll-editor" style="display: none;">
<div class="editor-header">
<span>Piano Roll - <span id="piano-roll-instrument-name">Instrumento 1</span></span>
@ -351,134 +346,71 @@
</div>
</div>
</div>
<div class="audio-editor">
<div class="editor-header">
<span>Editor de Amostras de Áudio</span>
<div class="editor-header" id="audio-editor-header">
<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">
<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>
<div class="toolbar-divider"></div>
<i
class="fa-solid fa-arrows-left-right-to-line"
id="resize-tool-trim"
title="Modo de Redimensionamento (Aparar/Trimming)"
></i>
<i
class="fa-solid fa-arrows-left-right"
id="resize-tool-stretch"
title="Modo de Redimensionamento (Esticar/Time Stretch)"
></i>
<i
class="fa-solid fa-play"
id="audio-editor-play-btn"
title="Play/Pause"
></i>
<i
class="fa-solid fa-stop"
id="audio-editor-stop-btn"
title="Stop"
></i>
<i
class="fa-solid fa-repeat"
id="audio-editor-loop-btn"
title="Ativar/Desativar Loop"
></i>
<button
id="sync-mode-btn"
class="control-btn active"
title="Modo de Sincronia de Playback (Global/Local)"
>
Global
<button id="audio-editor-play-btn" class="control-btn" title="Play Playlist">
<i class="fa-solid fa-play"></i>
</button>
<button id="audio-editor-stop-btn" class="control-btn" title="Stop Playlist">
<i class="fa-solid fa-stop"></i>
</button>
<button id="audio-editor-loop-btn" class="control-btn" title="Loop Mode">
<i class="fa-solid fa-repeat"></i>
<span style="margin-left: 8px">Loop</span>
</button>
<i
class="fa-solid fa-plus"
id="add-audio-track-btn"
title="Adicionar Pista de Áudio"
></i>
</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 class="toolbar-group">
<button id="slice-tool-btn" class="control-btn" title="Ferramenta Corte (Slice)">
<i class="fa-solid fa-scissors"></i>
<span style="margin-left: 8px">Cortar</span>
</button>
<button id="resize-tool-trim" class="control-btn active" title="Redimensionar (Trim)">
<i class="fa-solid fa-arrows-left-right-to-line"></i>
<span style="margin-left: 8px">Redimensionar</span>
</button>
<button id="resize-tool-stretch" class="control-btn" title="Esticar (Stretch)">
<i class="fa-solid fa-expand"></i>
<span style="margin-left: 8px">Esticar</span>
</button>
<button id="delete-clip" class="control-btn" title="Excluir Clip Selecionado">
<i class="fa-solid fa-trash"></i>
<span style="margin-left: 8px">Rem. Instrumento</span>
</button>
<div class="toolbar-divider"></div>
<button id="zoom-out-btn" class="control-btn" title="Zoom Out">
<i class="fa-solid fa-magnifying-glass-minus"></i>
</button>
<button id="zoom-in-btn" class="control-btn" title="Zoom In">
<i class="fa-solid fa-magnifying-glass-plus"></i>
</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 class="timeline-container">
<div class="spectrogram-view-grid" style="width: 4000px">
<div
class="timeline-clip"
style="left: 100px; width: 400px"
></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 class="audio-tracks-wrapper">
<div id="audio-timeline-ruler" class="timeline-ruler"></div>
<div id="loop-region" class="loop-region"></div>
<div id="playhead" class="playhead"></div>
<div id="audio-track-container" class="track-container"></div>
</div>
</div>
</main>