MMPCreator

This commit is contained in:
JotaChina committed 2025-08-29 16:38:40 -03:00
1 parent 0dda2b1b0c
commit 8395783986
22 files changed
+2883 -460

No files matched your search

+134
View File
@@ -0,0 +1,134 @@
//TODO ver Tone.js
// js/audio.js
import { appState } from "./state.js";
import { highlightStep } from "./ui.js";
import { getTotalSteps } from "./utils.js";
let audioContext;
let mainGainNode;
export function getAudioContext() {
return audioContext;
}
export function getMainGainNode() {
return mainGainNode;
}
export function initializeAudioContext() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
mainGainNode = audioContext.createGain();
mainGainNode.connect(audioContext.destination);
}
if (audioContext.state === "suspended") {
audioContext.resume();
}
}
export function playMetronomeSound(isDownbeat) {
initializeAudioContext();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
const frequency = isDownbeat ? 1000 : 800;
oscillator.frequency.setValueAtTime(frequency, audioContext.currentTime);
oscillator.type = "sine";
gainNode.gain.setValueAtTime(1, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(
0.00001,
audioContext.currentTime + 0.05
);
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.05);
}
export function playSample(filePath, trackId) {
initializeAudioContext();
if (!filePath) return;
const track = trackId ? appState.tracks.find((t) => t.id == trackId) : null;
const audio = new Audio(filePath);
const source = audioContext.createMediaElementSource(audio);
if (track && track.gainNode) {
source.connect(track.gainNode);
} else {
source.connect(mainGainNode);
}
audio.play();
}
function tick() {
const totalSteps = getTotalSteps();
if (totalSteps === 0) {
stopPlayback();
return;
}
const lastStepIndex =
appState.currentStep === 0 ? totalSteps - 1 : appState.currentStep - 1;
highlightStep(lastStepIndex, false);
if (appState.metronomeEnabled) {
const noteValue =
parseInt(document.getElementById("compasso-b-input").value, 10) || 4;
const stepsPerBeat = 16 / noteValue;
if (appState.currentStep % stepsPerBeat === 0) {
playMetronomeSound(appState.currentStep === 0);
}
}
appState.tracks.forEach((track) => {
if (track.steps[appState.currentStep] && track.samplePath) {
playSample(track.samplePath, track.id);
}
});
highlightStep(appState.currentStep, true);
appState.currentStep = (appState.currentStep + 1) % totalSteps;
}
export function startPlayback() {
if (appState.isPlaying || appState.tracks.length === 0) return;
initializeAudioContext();
const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120;
const stepInterval = (60 * 1000) / (bpm * 4);
if (appState.playbackIntervalId) clearInterval(appState.playbackIntervalId);
appState.isPlaying = true;
document.getElementById("play-btn").classList.remove("fa-play");
document.getElementById("play-btn").classList.add("fa-pause");
tick();
appState.playbackIntervalId = setInterval(tick, stepInterval);
}
export function stopPlayback() {
clearInterval(appState.playbackIntervalId);
appState.playbackIntervalId = null;
appState.isPlaying = false;
highlightStep(appState.currentStep - 1, false);
appState.currentStep = 0;
document.getElementById("play-btn").classList.remove("fa-pause");
document.getElementById("play-btn").classList.add("fa-play");
}
export function rewindPlayback() {
appState.currentStep = 0;
if (!appState.isPlaying) {
document
.querySelectorAll(".step.playing")
.forEach((s) => s.classList.remove("playing"));
}
}
export function togglePlayback() {
if (appState.isPlaying) {
stopPlayback();
} else {
startPlayback();
}
}
+9
View File
@@ -0,0 +1,9 @@
// js/config.js
// Constantes usadas para o cálculo de posição de notas no arquivo .mmp
export const TICKS_PER_BAR = 192;
export const NOTE_LENGTH = 12;
// Constantes para os valores padrão dos knobs
export const DEFAULT_VOLUME = 0.8;
export const DEFAULT_PAN = 0.0;
+243
View File
@@ -0,0 +1,243 @@
// js/file.js
import { appState } from "./state.js";
import { getTotalSteps } from "./utils.js";
import { renderApp } from "./ui.js";
import { NOTE_LENGTH, TICKS_PER_BAR } from "./config.js";
import {
initializeAudioContext,
getAudioContext,
getMainGainNode,
} from "./audio.js";
export async function handleFileLoad(file) {
let xmlContent = "";
try {
if (file.name.toLowerCase().endsWith(".mmpz")) {
const jszip = new JSZip();
const zip = await jszip.loadAsync(file);
const projectFile = Object.keys(zip.files).find((name) =>
name.toLowerCase().endsWith(".mmp")
);
if (!projectFile)
throw new Error(
"Não foi possível encontrar um arquivo .mmp dentro do .mmpz"
);
xmlContent = await zip.files[projectFile].async("string");
} else {
xmlContent = await file.text();
}
parseMmpContent(xmlContent);
} catch (error) {
console.error("Erro ao carregar o projeto:", error);
alert(`Erro ao carregar projeto: ${error.message}`);
}
}
export function parseMmpContent(xmlString) {
initializeAudioContext();
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlString, "application/xml");
appState.originalXmlDoc = xmlDoc;
const newTracks = [];
const head = xmlDoc.querySelector("head");
if (head) {
document.getElementById("bpm-input").value =
head.getAttribute("bpm") || 140;
document.getElementById("compasso-a-input").value =
head.getAttribute("timesig_numerator") || 4;
document.getElementById("compasso-b-input").value =
head.getAttribute("timesig_denominator") || 4;
}
const sampleTrackElements = xmlDoc.querySelectorAll(
'instrument[name="audiofileprocessor"]'
);
sampleTrackElements.forEach((instrumentNode) => {
const afpNode = instrumentNode.querySelector("audiofileprocessor");
const instrumentTrackNode = instrumentNode.parentElement;
const trackNode = instrumentTrackNode.parentElement;
if (!afpNode || !instrumentTrackNode || !trackNode) return;
const audioContext = getAudioContext();
const mainGainNode = getMainGainNode();
const totalSteps = parseInt(
trackNode.querySelector("pattern")?.getAttribute("steps") ||
getTotalSteps(),
10
);
const newSteps = new Array(totalSteps).fill(false);
const ticksPerStep = totalSteps > 0 ? TICKS_PER_BAR / totalSteps : 0;
trackNode.querySelectorAll("pattern note").forEach((noteNode) => {
const pos = parseInt(noteNode.getAttribute("pos"), 10);
const stepIndex = Math.round(pos / ticksPerStep);
if (stepIndex < totalSteps) {
newSteps[stepIndex] = true;
}
});
const newTrack = {
id: Date.now() + Math.random(),
name:
afpNode.getAttribute("src").split("/").pop() ||
trackNode.getAttribute("name"),
samplePath: `samples/${afpNode.getAttribute("src")}`,
steps: newSteps,
volume: parseFloat(instrumentTrackNode.getAttribute("vol")) / 100,
pan: parseFloat(instrumentTrackNode.getAttribute("pan")) / 100,
gainNode: audioContext.createGain(),
pannerNode: audioContext.createStereoPanner(),
};
newTrack.gainNode.connect(newTrack.pannerNode);
newTrack.pannerNode.connect(mainGainNode);
newTrack.gainNode.gain.value = newTrack.volume;
newTrack.pannerNode.pan.value = newTrack.pan;
newTracks.push(newTrack);
});
appState.tracks = newTracks;
renderApp();
console.log("Projeto carregado com sucesso!", appState);
}
export function generateMmpFile() {
if (appState.originalXmlDoc) {
modifyAndSaveExistingMmp();
} else {
generateNewMmp();
}
}
function generateNewMmp() {
console.log("Gerando novo arquivo .mmp do zero...");
const bpm = document.getElementById("bpm-input").value;
const sig_num = document.getElementById("compasso-a-input").value;
const sig_den = document.getElementById("compasso-b-input").value;
const tracksXml = appState.tracks
.map((track) => createTrackXml(track))
.join("");
const mmpContent = `<?xml version="1.0"?>
<!DOCTYPE lmms-project>
<lmms-project version="1.0" type="song" creator="MMPCreator" creatorversion="1.0">
<head mastervol="100" timesig_denominator="${sig_den}" bpm="${bpm}" timesig_numerator="${sig_num}" masterpitch="0"/>
<song>
<trackcontainer type="song">
<track type="1" solo="0" muted="0" name="Beat/Bassline 0">
<bbtrack>
<trackcontainer type="bbtrackcontainer">
${tracksXml}
</trackcontainer>
</bbtrack>
<bbtco color="4286611584" len="192" usestyle="1" pos="0" muted="0" name="Pattern 1"/>
</track>
</trackcontainer>
<timeline lp1pos="192" lp0pos="0" lpstate="0"/>
<controllers/>
<projectnotes width="686" y="10" minimized="0" visible="0" x="700" maximized="0" height="400"><![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>
<p>Feito com MMPCreator no https://alice.ufsj.edu.br/MMPSearch/creator</p>
</body></html>]]></projectnotes>
</song>
</lmms-project>`;
downloadFile(mmpContent, "novo_projeto.mmp");
}
function modifyAndSaveExistingMmp() {
console.log("Modificando arquivo .mmp existente...");
const xmlDoc = appState.originalXmlDoc.cloneNode(true);
const head = xmlDoc.querySelector("head");
if (head) {
head.setAttribute("bpm", document.getElementById("bpm-input").value);
head.setAttribute(
"timesig_numerator",
document.getElementById("compasso-a-input").value
);
head.setAttribute(
"timesig_denominator",
document.getElementById("compasso-b-input").value
);
}
const bbTrackContainer = xmlDoc.querySelector("bbtrack > trackcontainer");
if (bbTrackContainer) {
const oldSampleTracks = bbTrackContainer.querySelectorAll(
'instrument[name="audiofileprocessor"]'
);
oldSampleTracks.forEach((node) => node.closest("track").remove());
const tracksXml = appState.tracks
.map((track) => createTrackXml(track))
.join("");
const tempDoc = new DOMParser().parseFromString(
`<root>${tracksXml}</root>`,
"application/xml"
);
Array.from(tempDoc.documentElement.children).forEach((newTrackNode) => {
bbTrackContainer.appendChild(newTrackNode);
});
}
const serializer = new XMLSerializer();
const mmpContent = serializer.serializeToString(xmlDoc);
downloadFile(mmpContent, "projeto_editado.mmp");
}
function createTrackXml(track) {
if (!track.samplePath) return "";
const totalSteps = track.steps.length || getTotalSteps();
const ticksPerStep = totalSteps > 0 ? TICKS_PER_BAR / totalSteps : 0;
const lmmsVolume = Math.round(track.volume * 100);
const lmmsPan = Math.round(track.pan * 100);
const sampleSrc = track.samplePath.replace("samples/", "");
const notesXml = track.steps
.map((isActive, index) => {
if (isActive) {
const notePos = Math.round(index * ticksPerStep);
return `<note vol="100" len="${NOTE_LENGTH}" pos="${notePos}" pan="0" key="57"/>`;
}
return "";
})
.join("\n ");
return `
<track type="0" solo="0" muted="0" name="${track.name}">
<instrumenttrack vol="${lmmsVolume}" pitch="0" fxch="0" pitchrange="1" basenote="57" usemasterpitch="1" pan="${lmmsPan}">
<instrument name="audiofileprocessor">
<audiofileprocessor eframe="1" stutter="0" looped="0" interp="1" reversed="0" lframe="0" src="${sampleSrc}" amp="100" sframe="0"/>
</instrument>
<fxchain enabled="0" numofeffects="0"/>
</instrumenttrack>
<pattern type="0" pos="0" muted="0" steps="${totalSteps}" name="${track.name}">
${notesXml}
</pattern>
</track>`;
}
function downloadFile(content, fileName) {
const blob = new Blob([content], { type: "application/xml;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// (ALTERADO) Adiciona export na frente da função
export async function loadProjectFromServer(fileName) {
try {
const response = await fetch(`mmp/${fileName}`);
if (!response.ok)
throw new Error(`Não foi possível carregar o arquivo ${fileName}`);
const xmlContent = await response.text();
parseMmpContent(xmlContent);
// Retorna true em caso de sucesso
return true;
} catch (error) {
console.error("Erro ao carregar projeto do servidor:", error);
alert(`Erro ao carregar projeto: ${error.message}`);
// Retorna false em caso de falha
return false;
}
}
+121
View File
@@ -0,0 +1,121 @@
// js/main.js
import {
appState,
addTrackToState,
removeLastTrackFromState,
} from "./state.js";
import {
togglePlayback,
stopPlayback,
rewindPlayback,
initializeAudioContext,
} from "./audio.js";
import { handleFileLoad, generateMmpFile } from "./file.js";
import {
renderApp,
redrawSequencer,
loadAndRenderSampleBrowser,
showOpenProjectModal,
closeOpenProjectModal,
} from "./ui.js";
import { adjustValue, enforceNumericInput } from "./utils.js";
// --- INICIALIZAÇÃO E EVENTOS FINAIS ---
document.addEventListener("DOMContentLoaded", () => {
const newProjectBtn = document.getElementById("new-project-btn");
const openMmpBtn = document.getElementById("open-mmp-btn");
const saveMmpBtn = document.getElementById("save-mmp-btn");
const addInstrumentBtn = document.getElementById("add-instrument-btn");
const removeInstrumentBtn = document.getElementById("remove-instrument-btn");
const playBtn = document.getElementById("play-btn");
const stopBtn = document.getElementById("stop-btn");
const rewindBtn = document.getElementById("rewind-btn");
const metronomeBtn = document.getElementById("metronome-btn");
const mmpFileInput = document.getElementById("mmp-file-input");
const openProjectModal = document.getElementById("open-project-modal");
const openModalCloseBtn = document.getElementById("open-modal-close-btn");
const loadFromComputerBtn = document.getElementById("load-from-computer-btn");
const sidebarToggle = document.getElementById("sidebar-toggle");
newProjectBtn.addEventListener("click", () => {
if (
appState.tracks.length > 0 &&
!confirm("Você tem certeza? Alterações não salvas serão perdidas.")
)
return;
Object.assign(appState, {
tracks: [],
isPlaying: false,
playbackIntervalId: null,
currentStep: 0,
metronomeEnabled: false,
originalXmlDoc: null,
});
renderApp();
});
openMmpBtn.addEventListener("click", showOpenProjectModal);
loadFromComputerBtn.addEventListener("click", () => mmpFileInput.click());
mmpFileInput.addEventListener("change", (event) => {
const file = event.target.files[0];
if (file) {
handleFileLoad(file);
closeOpenProjectModal();
}
});
saveMmpBtn.addEventListener("click", generateMmpFile);
addInstrumentBtn.addEventListener("click", addTrackToState);
removeInstrumentBtn.addEventListener("click", removeLastTrackFromState);
playBtn.addEventListener("click", togglePlayback);
stopBtn.addEventListener("click", stopPlayback);
rewindBtn.addEventListener("click", rewindPlayback);
metronomeBtn.addEventListener("click", () => {
initializeAudioContext();
appState.metronomeEnabled = !appState.metronomeEnabled;
metronomeBtn.classList.toggle("active", appState.metronomeEnabled);
});
openModalCloseBtn.addEventListener("click", closeOpenProjectModal);
openProjectModal.addEventListener("click", (e) => {
if (e.target === openProjectModal) closeOpenProjectModal();
});
sidebarToggle.addEventListener("click", () => {
document.body.classList.toggle("sidebar-hidden");
const icon = sidebarToggle.querySelector("i");
icon.className = document.body.classList.contains("sidebar-hidden")
? "fa-solid fa-caret-right"
: "fa-solid fa-caret-left";
});
const inputs = document.querySelectorAll(".value-input");
inputs.forEach((input) => {
input.addEventListener("input", (event) => {
enforceNumericInput(event);
if (appState.isPlaying && event.target.id.startsWith("compasso-")) {
stopPlayback();
}
if (event.target.id.startsWith("compasso-")) {
redrawSequencer();
}
});
input.addEventListener("wheel", (event) => {
event.preventDefault();
const step = event.deltaY < 0 ? 1 : -1;
adjustValue(event.target, step);
});
});
const buttons = document.querySelectorAll(".adjust-btn");
buttons.forEach((button) => {
button.addEventListener("click", () => {
const targetId = button.dataset.target + "-input";
const targetInput = document.getElementById(targetId);
if (targetInput) {
adjustValue(targetInput, step);
}
});
});
// Inicia a aplicação
loadAndRenderSampleBrowser();
renderApp();
});
+90
View File
@@ -0,0 +1,90 @@
// js/state.js
import { DEFAULT_VOLUME, DEFAULT_PAN } from "./config.js";
import {
initializeAudioContext,
getAudioContext,
getMainGainNode,
} from "./audio.js";
import { renderApp } from "./ui.js";
// O "cérebro" da aplicação
export let appState = {
tracks: [],
isPlaying: false,
playbackIntervalId: null,
currentStep: 0,
metronomeEnabled: false,
originalXmlDoc: null,
};
export function addTrackToState() {
initializeAudioContext();
const audioContext = getAudioContext();
const mainGainNode = getMainGainNode();
const newTrack = {
id: Date.now(),
name: "novo instrumento",
samplePath: null,
steps: [],
volume: DEFAULT_VOLUME,
pan: DEFAULT_PAN,
gainNode: audioContext.createGain(),
pannerNode: audioContext.createStereoPanner(),
};
newTrack.gainNode.connect(newTrack.pannerNode);
newTrack.pannerNode.connect(mainGainNode);
newTrack.gainNode.gain.value = newTrack.volume;
newTrack.pannerNode.pan.value = newTrack.pan;
appState.tracks.push(newTrack);
renderApp();
}
export function removeLastTrackFromState() {
appState.tracks.pop();
renderApp();
}
export function updateTrackSample(trackId, samplePath) {
const track = appState.tracks.find((t) => t.id == trackId);
if (track) {
track.samplePath = samplePath;
track.name = samplePath.split("/").pop();
}
renderApp();
}
export function toggleStepState(trackId, stepIndex) {
const track = appState.tracks.find((t) => t.id == trackId);
if (track) {
track.steps[stepIndex] = !track.steps[stepIndex];
}
}
export function updateTrackVolume(trackId, volume) {
const track = appState.tracks.find((t) => t.id == trackId);
const audioContext = getAudioContext();
if (track) {
const clampedVolume = Math.max(0, Math.min(1.5, volume));
track.volume = clampedVolume;
if (track.gainNode) {
track.gainNode.gain.setValueAtTime(
clampedVolume,
audioContext.currentTime
);
}
}
}
export function updateTrackPan(trackId, pan) {
const track = appState.tracks.find((t) => t.id == trackId);
const audioContext = getAudioContext();
if (track) {
const clampedPan = Math.max(-1, Math.min(1, pan));
track.pan = clampedPan;
if (track.pannerNode) {
track.pannerNode.pan.setValueAtTime(clampedPan, audioContext.currentTime);
}
}
}
+319
View File
@@ -0,0 +1,319 @@
// js/ui.js
import {
appState,
toggleStepState,
updateTrackSample,
updateTrackVolume,
updateTrackPan,
} from "./state.js";
import { playSample } from "./audio.js";
import { getTotalSteps } from "./utils.js";
import { loadProjectFromServer } from "./file.js"; // (CORREÇÃO) Importa a função que faltava
// RENDERIZAÇÃO PRINCIPAL
export function renderApp() {
const trackContainer = document.getElementById("track-container");
trackContainer.innerHTML = "";
appState.tracks.forEach((trackData) => {
const trackLane = document.createElement("div");
trackLane.className = "track-lane";
trackLane.dataset.trackId = trackData.id;
trackLane.innerHTML = `
<div class="track-info"><i class="fa-solid fa-gear"></i><div class="track-mute"></div><span class="track-name">${trackData.name}</span></div>
<div class="track-controls">
<div class="knob-container">
<div class="knob" data-control="volume" data-track-id="${trackData.id}">
<div class="knob-indicator"></div>
</div>
<span>VOL</span>
</div>
<div class="knob-container">
<div class="knob" data-control="pan" data-track-id="${trackData.id}">
<div class="knob-indicator"></div>
</div>
<span>PAN</span>
</div>
</div>
<div class="step-sequencer"></div>
`;
trackLane.addEventListener("dragover", (e) => {
e.preventDefault();
trackLane.classList.add("drag-over");
});
trackLane.addEventListener("dragleave", () =>
trackLane.classList.remove("drag-over")
);
trackLane.addEventListener("drop", (e) => {
e.preventDefault();
trackLane.classList.remove("drag-over");
const filePath = e.dataTransfer.getData("text/plain");
if (filePath) {
updateTrackSample(trackData.id, filePath);
}
});
trackContainer.appendChild(trackLane);
const volumeKnob = trackLane.querySelector(".knob[data-control='volume']");
addKnobInteraction(volumeKnob);
updateKnobVisual(volumeKnob, "volume");
const panKnob = trackLane.querySelector(".knob[data-control='pan']");
addKnobInteraction(panKnob);
updateKnobVisual(panKnob, "pan");
});
redrawSequencer();
}
export function redrawSequencer() {
const beatsPerBar =
parseInt(document.getElementById("compasso-a-input").value, 10) || 4;
const totalSteps = getTotalSteps();
document.querySelectorAll(".step-sequencer").forEach((container) => {
const parentTrackElement = container.closest(".track-lane");
const trackId = parentTrackElement.dataset.trackId;
const trackData = appState.tracks.find((t) => t.id == trackId);
if (trackData && trackData.steps.length !== totalSteps) {
const newStepsState = new Array(totalSteps).fill(false);
for (let i = 0; i < Math.min(trackData.steps.length, totalSteps); i++) {
newStepsState[i] = trackData.steps[i];
}
trackData.steps = newStepsState;
}
container.innerHTML = "";
for (let i = 0; i < totalSteps; i++) {
const stepWrapper = document.createElement("div");
stepWrapper.className = "step-wrapper";
const stepElement = document.createElement("div");
stepElement.className = "step";
if (trackData && trackData.steps[i] === true) {
stepElement.classList.add("active");
}
stepElement.addEventListener("click", () => {
toggleStepState(trackData.id, i);
stepElement.classList.toggle("active");
if (trackData && trackData.samplePath) {
playSample(trackData.samplePath, trackData.id);
}
});
const groupIndex = Math.floor(i / beatsPerBar);
if (groupIndex % 2 === 0) {
stepElement.classList.add("step-dark");
}
if (i > 0 && i % beatsPerBar === 0) {
const marker = document.createElement("div");
marker.className = "step-marker";
marker.textContent = i;
stepWrapper.appendChild(marker);
}
stepWrapper.appendChild(stepElement);
container.appendChild(stepWrapper);
}
});
}
// LÓGICA DE INTERAÇÃO DOS KNOBS
function addKnobInteraction(knobElement) {
const controlType = knobElement.dataset.control;
knobElement.addEventListener("mousedown", (e) => {
if (e.button === 1) {
e.preventDefault();
const trackId = knobElement.dataset.trackId;
const defaultValue = controlType === "volume" ? 0.8 : 0.0;
if (controlType === "volume") {
updateTrackVolume(trackId, defaultValue);
} else {
updateTrackPan(trackId, defaultValue);
}
}
});
knobElement.addEventListener("mousedown", (e) => {
if (e.button !== 0) return;
e.preventDefault();
const trackId = knobElement.dataset.trackId;
const track = appState.tracks.find((t) => t.id == trackId);
if (!track) return;
const startY = e.clientY;
const startValue = controlType === "volume" ? track.volume : track.pan;
document.body.classList.add("knob-dragging");
function onMouseMove(moveEvent) {
const deltaY = startY - moveEvent.clientY;
const sensitivity = controlType === "volume" ? 150 : 200;
const newValue = startValue + deltaY / sensitivity;
if (controlType === "volume") {
updateTrackVolume(trackId, newValue);
} else {
updateTrackPan(trackId, newValue);
}
}
function onMouseUp() {
document.body.classList.remove("knob-dragging");
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
}
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
});
knobElement.addEventListener("wheel", (e) => {
e.preventDefault();
const trackId = knobElement.dataset.trackId;
const track = appState.tracks.find((t) => t.id == trackId);
if (!track) return;
const step = 0.05;
const direction = e.deltaY < 0 ? 1 : -1;
if (controlType === "volume") {
const newValue = track.volume + direction * step;
updateTrackVolume(trackId, newValue);
} else {
const newValue = track.pan + direction * step;
updateTrackPan(trackId, newValue);
}
});
}
function updateKnobVisual(knobElement, controlType) {
const trackId = knobElement.dataset.trackId;
const track = appState.tracks.find((t) => t.id == trackId);
if (!track) return;
const indicator = knobElement.querySelector(".knob-indicator");
if (!indicator) return;
const minAngle = -135;
const maxAngle = 135;
let percentage = 0.5;
let title = "";
if (controlType === "volume") {
const value = track.volume;
const clampedValue = Math.max(0, Math.min(1.5, value));
percentage = clampedValue / 1.5;
title = `Volume: ${Math.round(clampedValue * 100)}%`;
} else {
const value = track.pan;
const clampedValue = Math.max(-1, Math.min(1, value));
percentage = (clampedValue + 1) / 2;
const panDisplay = Math.round(clampedValue * 100);
title = `Pan: ${
panDisplay === 0
? "Centro"
: panDisplay < 0
? `${-panDisplay} L`
: `${panDisplay} R`
}`;
}
const angle = minAngle + percentage * (maxAngle - minAngle);
indicator.style.transform = `translateX(-50%) rotate(${angle}deg)`;
knobElement.title = title;
}
export function highlightStep(stepIndex, isActive) {
if (stepIndex < 0) return;
document.querySelectorAll(".track-lane").forEach((track) => {
const stepWrapper = track.querySelector(
`.step-sequencer .step-wrapper:nth-child(${stepIndex + 1})`
);
if (stepWrapper) {
const stepElement = stepWrapper.querySelector(".step");
if (stepElement) {
stepElement.classList.toggle("playing", isActive);
}
}
});
}
// LÓGICA DA SIDEBAR E MODAL
export async function loadAndRenderSampleBrowser() {
const browserContent = document.getElementById("browser-content");
try {
const response = await fetch("metadata/samples-manifest.json");
if (!response.ok) {
throw new Error("Arquivo samples-manifest.json não encontrado.");
}
const fileTree = await response.json();
renderFileTree(fileTree, browserContent, "src/samples");
} catch (error) {
console.error("Erro ao carregar samples:", error);
browserContent.innerHTML = `<p style="color:var(--accent-red); padding: 10px;">${error.message}</p>`;
}
}
function renderFileTree(tree, parentElement, currentPath) {
parentElement.innerHTML = "";
const ul = document.createElement("ul");
const sortedKeys = Object.keys(tree).sort((a, b) => {
const aIsFile = tree[a]._isFile;
const bIsFile = tree[b]._isFile;
if (aIsFile === bIsFile) return a.localeCompare(b);
return aIsFile ? 1 : -1;
});
for (const key of sortedKeys) {
const node = tree[key];
const li = document.createElement("li");
const newPath = `${currentPath}/${key}`;
if (node._isFile) {
li.innerHTML = `<i class="fa-solid fa-file-audio"></i> ${key}`;
li.setAttribute("draggable", true);
li.addEventListener("click", (e) => {
e.stopPropagation();
playSample(newPath, null);
});
li.addEventListener("dragstart", (e) => {
e.dataTransfer.setData("text/plain", newPath);
e.dataTransfer.effectAllowed = "copy";
});
ul.appendChild(li);
} else {
li.className = "directory";
li.innerHTML = `<i class="fa-solid fa-folder"></i> ${key}`;
const nestedUl = document.createElement("ul");
renderFileTree(node, nestedUl, newPath);
li.appendChild(nestedUl);
li.addEventListener("click", (e) => {
e.stopPropagation();
li.classList.toggle("open");
});
ul.appendChild(li);
}
}
parentElement.appendChild(ul);
}
export async function showOpenProjectModal() {
const openProjectModal = document.getElementById("open-project-modal");
const serverProjectsList = document.getElementById("server-projects-list");
serverProjectsList.innerHTML = "<p>Carregando...</p>";
openProjectModal.classList.add("visible");
try {
const response = await fetch("metadata/mmp-manifest.json");
if (!response.ok)
throw new Error("Arquivo mmp-manifest.json não encontrado.");
const projects = await response.json();
serverProjectsList.innerHTML = "";
if (projects.length === 0) {
serverProjectsList.innerHTML =
'<p style="color:var(--text-dark);">Nenhum projeto encontrado no servidor.</p>';
}
projects.forEach((projectName) => {
const projectItem = document.createElement("div");
projectItem.className = "project-item";
projectItem.textContent = projectName;
projectItem.addEventListener("click", async () => {
const success = await loadProjectFromServer(projectName);
if (success) {
closeOpenProjectModal();
}
});
serverProjectsList.appendChild(projectItem);
});
} catch (error) {
console.error("Erro ao carregar lista de projetos:", error);
serverProjectsList.innerHTML = `<p style="color:var(--accent-red);">${error.message}</p>`;
}
}
export function closeOpenProjectModal() {
const openProjectModal = document.getElementById("open-project-modal");
openProjectModal.classList.remove("visible");
}
+27
View File
@@ -0,0 +1,27 @@
// js/utils.js
export function getTotalSteps() {
const compassoAInput = document.getElementById("compasso-a-input");
const compassoBInput = document.getElementById("compasso-b-input");
const beatsPerBar = parseInt(compassoAInput.value, 10) || 4;
const noteValue = parseInt(compassoBInput.value, 10) || 4;
const subdivisions = Math.round(16 / noteValue);
return beatsPerBar * subdivisions;
}
export function enforceNumericInput(event) {
event.target.value = event.target.value.replace(/[^0-9]/g, "");
}
export function adjustValue(inputElement, step) {
let currentValue = parseInt(inputElement.value, 10) || 0;
let min = parseInt(inputElement.dataset.min, 10);
let max = parseInt(inputElement.dataset.max, 10);
let newValue = currentValue + step;
if (!isNaN(min) && newValue < min) newValue = min;
if (!isNaN(max) && newValue > max) newValue = max;
inputElement.value = newValue;
// Dispara um evento 'input' para que outros listeners (como o que redesenha o sequenciador) sejam acionados.
inputElement.dispatchEvent(new Event("input", { bubbles: true }));
}