versão 2.0
Deploy / Deploy (push) Successful in 40s Details

This commit is contained in:
JotaChina 2025-10-20 21:53:09 -03:00
parent d77fe91df1
commit 531fb7b36a
16 changed files with 1735 additions and 1626 deletions

View File

@ -1,6 +1,4 @@
/* =============================================== */
/* VÁRIAVEIS GLOBAIS (ROOT)
/* =============================================== */
/* MMPCreator - Folha de Estilos Principal */
:root {
--bg-body: #2d3035;
--bg-toolbar: #3b3f45;
@ -17,90 +15,115 @@
}
/* =============================================== */
/* ESTILOS GLOBAIS E LAYOUT PRINCIPAL (CORRIGIDO)
/* LAYOUT E ESTRUTURA GLOBAL
/* =============================================== */
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-body);
color: var(--text-light);
/* Retornamos ao layout com padding para compatibilidade */
padding-left: 300px;
padding-top: 50px; /* Adiciona espaço para a toolbar fixa */
box-sizing: border-box;
transition: padding-left .3s ease;
height: 100vh;
display: flex; /* Usamos flex no body para o main-content crescer */
flex-direction: column;
overflow: hidden;
display: flex;
}
body.sidebar-hidden {
padding-left: 0;
}
body.knob-dragging { cursor: ns-resize; }
body.slice-tool-active .timeline-container { cursor: crosshair; }
body.knob-dragging {
cursor: ns-resize;
}
.main-content {
padding: 1rem;
flex-grow: 1; /* Faz o conteúdo principal ocupar o espaço restante */
.app-container {
display: flex;
flex-direction: column;
gap: 1rem;
overflow: hidden; /* Evita que o conteúdo transborde */
height: 100%; /* Garante que o flexbox interno funcione */
flex-grow: 1;
overflow: hidden;
position: relative;
}
/* =============================================== */
/* BARRA LATERAL (SAMPLE BROWSER)
/* =============================================== */
.sample-browser {
position: fixed;
top: 0;
left: 0;
width: 300px;
height: 100vh;
background-color: var(--bg-toolbar);
border-right: 2px solid var(--border-color);
z-index: 1500;
z-index: 10;
display: flex;
flex-direction: column;
transform: translateX(0);
transition: transform .3s ease;
transition: min-width 0.3s ease, width 0.3s ease;
flex-shrink: 0;
}
body.sidebar-hidden .sample-browser {
transform: translateX(-100%);
width: 0;
min-width: 0;
border-right: none;
overflow: hidden;
}
.browser-header {
padding: 15px;
background-color: #2a2c30;
border-bottom: 2px solid var(--border-color);
text-align: center;
font-weight: bold;
color: var(--text-light);
white-space: nowrap;
}
.browser-content {
flex-grow: 1;
overflow-y: auto;
padding: 10px;
}
.browser-content ul { list-style: none; padding-left: 15px; }
.browser-content li { padding: 5px; cursor: pointer; border-radius: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; user-select: none; }
.browser-content li:hover { background-color: var(--bg-editor); }
.browser-content li i { margin-right: 8px; width: 12px; color: var(--text-dark); transition: transform .2s; }
.browser-content li.directory > ul { display: none; }
.browser-content li.directory.open > ul { display: block; }
.browser-content li.directory.open > .fa-folder { transform: rotate(90deg); }
.browser-content ul {
padding-left: 15px;
list-style: none;
}
.folder-item > .file-list {
display: none;
padding-left: 20px;
}
.folder-item.open > .file-list {
display: block;
}
.folder-name {
cursor: pointer;
display: flex;
align-items: center;
padding: 5px;
border-radius: 3px;
user-select: none;
}
.folder-name:hover {
background-color: var(--bg-editor);
}
.folder-icon {
margin-right: 8px;
color: var(--text-dark);
transition: transform 0.2s ease-in-out;
}
.folder-name::before {
content: '\f0da';
font-family: "Font Awesome 6 Free";
font-weight: 900;
margin-right: 8px;
font-size: 0.9em;
transition: transform 0.2s ease-in-out;
}
.folder-item.open > .folder-name::before {
transform: rotate(90deg);
}
.folder-item.open > .folder-name > .folder-icon::before {
content: '\f07c';
}
.browser-content li.file-item {
padding: 5px;
cursor: pointer;
border-radius: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
}
#sidebar-toggle {
position: fixed;
position: absolute;
top: 60px;
left: 305px;
left: 5px;
z-index: 1400;
background-color: var(--bg-toolbar);
border: 1px solid var(--border-color);
@ -108,49 +131,19 @@ body.sidebar-hidden .sample-browser {
width: 25px;
height: 40px;
cursor: pointer;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
transition: left .3s ease;
border-radius: 0 4px 4px 0;
display: flex;
align-items: center;
justify-content: center;
}
body.sidebar-hidden #sidebar-toggle {
left: 5px;
}
/* =============================================== */
/* BARRA DE FERRAMENTAS GLOBAL
/* ÁREA DE CONTEÚDO E TOOLBARS
/* =============================================== */
.global-toolbar {
padding: 8px 15px;
position: fixed;
top: 0;
left: 300px;
right: 0;
z-index: 1000;
display: flex;
align-items: center;
gap: 20px;
background-color: var(--bg-toolbar);
border-bottom: 2px solid var(--border-color);
transition: left .3s ease;
height: 50px; /* Altura fixa para o cálculo do padding do body */
box-sizing: border-box;
}
body.sidebar-hidden .global-toolbar {
left: 0;
}
/* =============================================== */
/* NOVO: EDITOR DE AMOSTRAS DE ÁUDIO (AUDIO EDITOR)
/* =============================================== */
/* O container principal que substitui o .future-panel */
.audio-editor {
height: 50%;
.global-toolbar { display: flex; align-items: center; gap: 20px; padding: 8px 15px; background-color: var(--bg-toolbar); border-bottom: 2px solid var(--border-color); height: 50px; box-sizing: border-box; flex-shrink: 0; }
.main-content { flex-grow: 1; padding: 1rem; display: flex; flex-direction: column; gap: 1rem; overflow: hidden; }
.beat-editor {
flex: 1;
background-color: var(--bg-editor);
border: 1px solid var(--border-color);
border-radius: 8px;
@ -159,178 +152,43 @@ body.sidebar-hidden .global-toolbar {
display: flex;
flex-direction: column;
}
/* Container para as faixas de áudio, com scroll vertical */
#audio-track-container {
overflow-y: auto;
flex-grow: 1;
}
/* Estilo para cada linha de faixa de áudio */
.audio-track-lane {
display: flex;
align-items: center;
padding: 8px 10px;
background-color: var(--bg-editor);
border-bottom: 1px solid var(--bg-toolbar);
min-height: 40px; /* Altura mínima para cada faixa */
box-sizing: border-box;
}
.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; }
.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; }
/* =============================================== */
/* ESTILOS DO EDITOR DE ÁUDIO (MARCADORES)
/* =============================================== */
/* Wrapper para a visualização do espectrograma, permite scroll horizontal */
.spectrogram-view-wrapper {
flex-grow: 1;
overflow-x: auto;
overflow-y: hidden;
background-color: #2a2c30;
border-radius: 3px;
}
/* Garante que o grid possa conter elementos posicionados de forma absoluta */
.spectrogram-view-grid {
position: relative;
display: inline-block; /* Faz o contêiner se ajustar à largura do canvas */
height: 100%;
}
/* Estilo para os números de compasso */
.bar-marker {
position: absolute;
top: 0;
transform: translateX(-50%); /* Centraliza o número sobre a linha */
background-color: rgba(0, 0, 0, 0.5);
color: var(--text-dark);
padding: 1px 5px;
font-size: 0.7rem;
border-radius: 3px;
user-select: none; /* Impede que o texto seja selecionado */
z-index: 5; /* Garante que fique acima da forma de onda mas abaixo da agulha */
}
/* Mantém o canvas como block para evitar espaçamentos */
.waveform-canvas {
display: block;
}
/* =============================================== */
/* TOOLBAR DO EDITOR
/* =============================================== */
.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;
}
.window-controls i { margin-left: 12px; cursor: pointer; }
.editor-toolbar, .editor-header .playback-controls {
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;
}
.editor-toolbar i, .editor-header .playback-controls i {
cursor: pointer;
padding: 5px;
border-radius: 3px;
font-size: 1rem;
}
.editor-toolbar i.enabled { background-color: var(--bg-body); box-shadow: inset 0 0 2px #000; }
.pattern-manager { display: flex; align-items: center; gap: 10px; }
.pattern-selector { background-color: var(--bg-body); color: var(--text-light); padding: 5px 10px; border: 1px solid var(--border-color); font-size: .9rem; border-radius: 2px; }
.pattern-btn { background: var(--bg-body); border: 1px solid var(--border-color); color: var(--text-light); cursor: pointer; border-radius: 3px; width: 28px; height: 28px; font-size: 1.2rem; }
/* =============================================== */
/* FAIXAS (TRACK LANES) E SEQUENCIADOR
/* EDITOR DE BASES (BEAT EDITOR / STEP SEQUENCER)
/* =============================================== */
#track-container {
overflow-y: auto;
overflow-x: hidden;
flex-grow: 1;
}
.track-lane {
display: flex;
align-items: center;
padding: 8px 10px;
align-items: stretch;
background-color: var(--bg-editor);
border-bottom: 1px solid var(--bg-toolbar);
border-left: 2px solid transparent;
border-right: 2px solid transparent;
transition: border-color 0.2s, background-color 0.2s;
min-height: 72px;
box-sizing: border-box;
}
.track-lane.active-track {
background-color: #40454d;
.track-lane.active-track { background-color: #40454d; }
.track-lane.drag-over { border: 2px dashed var(--accent-green); }
.track-lane .track-info {
display: flex;
align-items: center;
gap: 8px;
width: 180px;
flex-shrink: 0;
padding: 0 10px;
border-right: 1px solid var(--bg-toolbar);
}
.track-lane.drag-over {
border-color: var(--accent-green);
}
/* Localize a regra .track-mute e substitua por esta */
.track-solo-btn {
width: 25px;
height: 12px;
background-color: var(--accent-red); /* Cor padrão: vermelho */
border-radius: 6px;
cursor: pointer;
border: 1px solid var(--border-color);
box-shadow: inset 0 0 2px #000;
transition: background-color 0.2s, opacity 0.2s;
}
.track-solo-btn:hover {
opacity: 0.8;
}
/* Quando solado (ativo), o botão fica verde */
.track-solo-btn.active {
background-color: var(--accent-green);
opacity: 1;
}
.track-info { display: flex; align-items: center; gap: 8px; width: 180px; flex-shrink: 0; }
.track-info .fa-gear { font-size: 1.2rem; cursor: pointer; }
.track-mute { width: 25px; height: 12px; background-color: var(--accent-green); border-radius: 6px; cursor: pointer; border: 1px solid var(--border-color); box-shadow: inset 0 0 2px #000; transition: background-color 0.2s, opacity 0.2s; }
.track-mute:hover {
opacity: 0.8;
}
.track-mute.active {
background-color: var(--text-dark);
opacity: 0.7;
}
.track-name { color: var(--accent-red); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.track-controls { display: flex; gap: 5px; margin: 0 10px; padding-left: 10px; border-left: 1px solid var(--bg-toolbar); flex-shrink: 0; }
.knob-container { text-align: center; font-size: .7rem; color: var(--text-dark); }
.knob { width: 28px; height: 28px; background-color: var(--bg-toolbar); border-radius: 50%; border: 1px solid var(--border-color); margin-bottom: 2px; cursor: grab; box-shadow: inset 0 0 4px #222; position: relative; }
.knob:active { cursor: grabbing; }
.knob-indicator { width: 2px; height: 8px; background-color: var(--text-light); position: absolute; top: 2px; left: 50%; transform-origin: bottom center; transform: translateX(-50%) rotate(0deg); border-radius: 1px; }
.step-sequencer-wrapper { flex-grow: 1; overflow-x: auto; overflow-y: hidden; padding-bottom: 8px; }
.track-mute:hover { opacity: 0.8; }
.track-mute.active { background-color: var(--text-dark); opacity: 0.7; }
.track-lane .track-controls { display: flex; gap: 5px; margin: 0 10px; padding-left: 10px; border-left: 1px solid var(--bg-toolbar); flex-shrink: 0; }
.step-sequencer-wrapper { flex-grow: 1; overflow-x: auto; overflow-y: hidden; padding-bottom: 8px; display: flex; align-items: center; }
.step-sequencer { display: flex; gap: 4px; }
.step-sequencer-wrapper::-webkit-scrollbar { height: 8px; }
.step-sequencer-wrapper::-webkit-scrollbar-track { background: var(--border-color); border-radius: 4px; }
.step-sequencer-wrapper::-webkit-scrollbar-thumb { background: var(--bg-toolbar); border-radius: 4px; }
.step-sequencer-wrapper::-webkit-scrollbar-thumb:hover { background: #555; }
.step-wrapper { position: relative; }
.step-marker { position: absolute; top: -16px; left: 1px; font-size: .6rem; color: var(--text-dark); user-select: none; }
.step { width: 28px; height: 28px; background-color: #2a2a2a; border: 1px solid #4a4a4a; border-radius: 2px; cursor: pointer; transition: background-color .1s, transform 0.1s; flex-shrink: 0; }
@ -339,9 +197,161 @@ body.sidebar-hidden .global-toolbar {
.step.active { background-color: var(--accent-green); border: 1px solid #fff; box-shadow: 0 0 8px var(--accent-green); }
.step.playing { transform: scale(1.1); box-shadow: inset 0 0 8px rgba(255, 255, 255, 0.8); }
/* =================================================================== */
/* EDITOR DE ÁUDIO - LAYOUT PRINCIPAL
/* =================================================================== */
.audio-editor {
flex: 1;
background-color: var(--bg-editor);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0, 0, 0, .3);
overflow: hidden;
display: flex;
flex-direction: column;
--track-info-width: 255px;
}
#audio-track-container {
flex-grow: 1; /* Ocupa o espaço restante */
overflow: auto; /* Habilita a rolagem horizontal e vertical */
}
.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 .track-info {
width: var(--track-info-width);
flex-shrink: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
background-color: #383c42;
border-right: 2px solid var(--border-color);
}
.track-info-header { display: flex; align-items: center; gap: 8px; width: 100%; }
.track-name { color: var(--accent-red); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.timeline-container {
flex-grow: 1;
position: relative;
overflow-x: hidden; /* A rolagem agora é controlada pelo #audio-track-container */
overflow-y: hidden;
}
.spectrogram-view-grid {
height: 100%;
position: relative;
display: block;
--step-width: 32px;
--beat-width: 128px;
--bar-width: 512px;
background-size: var(--bar-width) 100%, var(--beat-width) 100%, var(--step-width) 100%;
background-image:
repeating-linear-gradient(to right, #666 0, #666 1px, transparent 1px, transparent 100%),
repeating-linear-gradient(to right, #444 0, #444 1px, transparent 1px, transparent 100%),
repeating-linear-gradient(to right, #3a3e44 0, #3a3e44 1px, transparent 1px, transparent 100%);
}
/* =============================================== */
/* CONTROLES E INPUTS
/* EDITOR DE ÁUDIO - RÉGUA E LAYOUT CORRIGIDO
/* =============================================== */
.ruler-wrapper {
display: flex;
flex-shrink: 0;
background-color: #383c42;
border-bottom: 1px solid var(--border-color);
}
.ruler-spacer {
width: var(--track-info-width);
flex-shrink: 0;
border-right: 2px solid var(--border-color);
}
.timeline-ruler {
position: relative;
height: 25px;
flex-grow: 1;
overflow: hidden;
background-color: #2a2c30;
}
.ruler-marker {
position: absolute;
top: 0;
height: 100%;
color: var(--text-dark);
font-size: 0.75rem;
padding-left: 5px;
border-left: 1px solid #555;
user-select: none;
display: flex;
align-items: center;
box-sizing: border-box;
}
/* =============================================== */
/* EDITOR DE ÁUDIO - CLIPS E CONTROLES
/* =============================================== */
.timeline-clip {
position: absolute;
top: 50%;
transform: translateY(-50%);
height: 55px;
background: linear-gradient(to bottom, #5c626b, #4a4f57);
border: 1px solid var(--border-color-dark);
border-radius: 4px;
box-shadow: 0 3px 8px rgba(0,0,0,0.5);
display: flex;
align-items: center;
padding: 0 8px;
overflow: hidden;
cursor: grab;
user-select: none;
color: var(--text-light);
}
.timeline-clip:active, .timeline-clip.dragging { cursor: grabbing; z-index: 1000; border-color: var(--accent-blue); opacity: 0.9; }
.clip-name { position: absolute; top: 4px; left: 8px; font-size: 0.75rem; font-weight: bold; background-color: rgba(0,0,0,0.6); padding: 2px 6px; border-radius: 3px; pointer-events: none; }
.waveform-canvas-clip { width: 100%; height: 100%; display: block; }
.audio-track-lane .track-controls { display: flex; justify-content: flex-start; gap: 15px; border-left: none; padding-left: 0; margin: 0; }
.clip-resize-handle { position: absolute; top: 0; bottom: 0; width: 8px; cursor: ew-resize; z-index: 10; }
.clip-resize-handle.left { left: 0; }
.clip-resize-handle.right { right: 0; }
.playhead { position: absolute; top: 0; left: 0; width: 2px; height: 100%; background-color: var(--accent-red); z-index: 50; pointer-events: none; }
#loop-region {
display: none; /* Começa escondido por padrão */
position: absolute;
top: 0;
height: 100%;
background-color: rgba(52, 152, 219, 0.2);
border-left: 1px solid var(--accent-blue);
border-right: 1px solid var(--accent-blue);
z-index: 15;
min-width: 16px;
cursor: grab;
}
#loop-region.visible {
display: block; /* ou 'flex', 'absolute', etc. */
}
/* Esta regra está correta, mas também deve usar o ID */
#loop-region:active {
cursor: grabbing;
}
.loop-handle { position: absolute; top: 0; bottom: 0; width: 10px; cursor: ew-resize; }
.loop-handle.left { left: -5px; }
.loop-handle.right { right: -5px; }
#slice-tool-btn.active { color: var(--accent-blue); }
#audio-editor-loop-btn.active { color: var(--accent-green); }
/* =============================================== */
/* COMPONENTES GERAIS (KNOBS, BOTÕES, INPUTS)
/* =============================================== */
.knob-container { text-align: center; font-size: .7rem; color: var(--text-dark); }
.knob { width: 28px; height: 28px; background-color: var(--bg-toolbar); border-radius: 50%; border: 1px solid var(--border-color); margin-bottom: 2px; cursor: grab; box-shadow: inset 0 0 4px #222; position: relative; }
.knob:active { cursor: grabbing; }
.knob-indicator { width: 2px; height: 8px; background-color: var(--text-light); position: absolute; top: 2px; left: 50%; transform-origin: bottom center; transform: translateX(-50%) rotate(0deg); border-radius: 1px; }
.interactive-input-container { display: flex; align-items: center; justify-content: center; gap: 4px; }
.compasso-group { display: flex; align-items: center; gap: 4px; }
.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; }
@ -364,13 +374,15 @@ body.sidebar-hidden .global-toolbar {
#metronome-btn:hover { border-color: var(--text-light); background-color: var(--bg-editor); }
#metronome-btn.active { background-color: var(--accent-green); color: var(--bg-body); border-color: var(--accent-green); }
/* =============================================== */
/* MODAL E MENUS
/* =============================================== */
.modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.7); z-index: 2000; display: flex; justify-content: center; align-items: center; padding: 1rem; visibility: hidden; opacity: 0; transition: visibility 0s 0.3s, opacity 0.3s; }
.modal-overlay.visible { visibility: visible; opacity: 1; transition: visibility 0s, opacity 0.3s; }
.modal-content { background-color: var(--bg-body); padding: 1.5rem 2rem; border-radius: 6px; border: 1px solid var(--border-color); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); width: 100%; max-width: 500px; position: relative; display: flex; flex-direction: column; gap: 1.5rem; max-height: 90vh; }
.modal-close { position: absolute; top: 10px; right: 15px; font-size: 1.5rem; color: var(--text-dark); cursor: pointer; border: none; background: none; }
.modal-close:hover { color: var(--text-light); }
.modal-title { margin: 0; padding-bottom: 0.5rem; border-bottom: 1px solid var(--bg-toolbar); color: var(--text-light); text-align: center; flex-shrink: 0; }
.modal-section { margin: 0; }
.modal-section h3 { margin-top: 0; margin-bottom: 0.8rem; font-size: 1rem; color: var(--text-light); }
#server-projects-list { max-height: 250px; overflow-y: auto; background-color: var(--bg-toolbar); border: 1px solid var(--border-color); border-radius: 4px; padding: 0.5rem; min-height: 50px; }
#server-projects-list .project-item { background-color: var(--bg-editor); padding: 10px 15px; border-radius: 4px; margin-bottom: 8px; cursor: pointer; transition: background-color 0.2s, color 0.2s; border: 1px solid transparent; }
@ -378,124 +390,40 @@ body.sidebar-hidden .global-toolbar {
#server-projects-list .project-item:hover { background-color: var(--bg-body); color: #fff; border-color: var(--accent-green); }
.modal-button { background-color: var(--bg-toolbar); color: var(--text-light); border: 1px solid var(--border-color); padding: 0.8rem 1.5rem; border-radius: 4px; cursor: pointer; font-size: 1rem; transition: background-color 0.2s, border-color 0.2s; width: 100%; text-align: center; }
.modal-button:hover { background-color: #4a4f57; border-color: #333; }
.file-menu-container { position: relative; }
.toolbar-btn { background-color: var(--background-light); color: var(--text-light); border: 1px solid var(--border-color); border-radius: 3px; padding: 5px 10px; cursor: pointer; font-family: inherit; font-size: 0.8rem; }
.toolbar-btn:hover { background-color: var(--background-lighter); }
.file-menu-dropdown { position: absolute; top: 100%; left: 0; background-color: var(--background-lighter); border: 1px solid var(--border-color-dark); border-radius: 4px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); min-width: 200px; z-index: 1000; overflow: hidden; display: flex; flex-direction: column; }
.file-menu-dropdown.hidden { display: none; }
.file-menu-dropdown a { color: var(--text-light); padding: 8px 12px; text-decoration: none; display: block; font-size: 0.9rem; }
.file-menu-dropdown a:hover { background-color: var(--accent-blue); color: white; }
.menu-divider { height: 1px; background-color: var(--border-color); margin: 4px 0; }
#timeline-context-menu { position: fixed; display: none; background-color: var(--bg-toolbar); border: 1px solid var(--border-color-dark); border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.5); padding: 5px 0; z-index: 2000; font-size: 0.9rem; }
#timeline-context-menu div { padding: 8px 15px; cursor: pointer; white-space: nowrap; }
#timeline-context-menu div:hover { background-color: var(--accent-blue); color: white; }
/* =============================================== */
/* ESTILOS RESPONSIVOS (MELHORADO)
/* ESTILOS RESPONSIVOS
/* =============================================== */
@media (max-width: 1200px) {
.info-display-group {
gap: 2px;
}
.info-display {
padding: 4px 6px;
}
.value-input {
font-size: 1.2rem;
width: 45px;
}
.compasso-input {
width: 20px;
}
.info-display-group { gap: 2px; }
.info-display { padding: 4px 6px; }
.value-input { font-size: 1.2rem; width: 45px; }
.compasso-input { width: 20px; }
}
@media (max-width: 992px) {
.global-toolbar {
gap: 10px;
flex-wrap: wrap;
height: auto; /* Permite que a toolbar cresça se o conteúdo quebrar linha */
padding-bottom: 10px;
}
body {
padding-top: 80px; /* Aumenta o espaço para a toolbar maior */
}
.info-display-group {
order: 3; /* Move o grupo de informações para o final da toolbar */
width: 100%;
justify-content: space-around;
}
.spacer {
display: none;
}
.global-toolbar { gap: 10px; flex-wrap: wrap; height: auto; padding-bottom: 10px; }
.main-content { padding-top: 100px; }
.info-display-group { order: 3; width: 100%; justify-content: space-around; }
.spacer { display: none; }
}
@media (max-width: 768px) {
body {
padding-left: 0 !important;
}
.sample-browser {
transform: translateX(-100%);
position: fixed; /* Volta a ser fixo para deslizar por cima */
width: 280px;
}
body:not(.sidebar-hidden) .sample-browser {
transform: translateX(0);
}
#sidebar-toggle {
left: 5px;
transform: translateX(0);
position: fixed; /* Garante que o botão fique visível */
}
.global-toolbar {
left: 0;
padding-left: 45px;
}
.main-content {
padding: 10px;
padding-top: 85px; /* Ajusta o padding para a toolbar fixa */
}
.track-lane, .audio-track-lane {
flex-direction: column;
align-items: stretch;
gap: 15px;
padding: 15px;
}
.track-info,
.track-controls {
width: 100%;
}
.track-controls {
border-left: none;
padding-left: 0;
justify-content: space-around;
}
.step-sequencer-wrapper {
width: 100%;
}
.sample-browser { transform: translateX(-100%); position: fixed; width: 280px; }
body:not(.sidebar-hidden) .sample-browser { transform: translateX(0); }
#sidebar-toggle { left: 5px; transform: translateX(0); position: fixed; }
.global-toolbar { padding-left: 45px; }
.main-content { padding: 10px; }
.track-lane, .audio-track-lane { flex-direction: column; align-items: stretch; gap: 15px; padding: 15px; }
.track-lane .track-info, .audio-track-lane .track-info, .track-lane .track-controls, .audio-track-lane .track-controls { width: 100%; border: none; padding: 0; }
.track-lane .track-controls, .audio-track-lane .track-controls { justify-content: space-around; }
}
.spectrogram-view-wrapper {
position: relative; /* Essencial para o posicionamento absoluto do filho */
overflow: hidden; /* Garante que a agulha não saia dos limites */
}
.playhead {
position: absolute;
top: 0;
left: 0; /* A posição será atualizada via JavaScript */
width: 2px;
height: 100%;
background-color: var(--accent-red, #e74c3c); /* Use uma cor de destaque */
z-index: 10;
pointer-events: none; /* Impede que a agulha intercepte cliques do mouse */
transition: background-color 0.3s; /* Efeito suave ao parar */
}
/* Estilo para o botão de loop na barra de ferramentas do editor de áudio */
#audio-editor-loop-btn {
color: var(--text-dark); /* Cor padrão quando está desligado */
transition: color 0.2s;
}
#audio-editor-loop-btn.active {
color: var(--accent-green); /* Cor de destaque quando está ligado */
}
/* =============================================== */
/* SCROLLBARS
/* =============================================== */
::-webkit-scrollbar { height: 10px; width: 10px; }
::-webkit-scrollbar-track { background: var(--border-color); }
::-webkit-scrollbar-thumb { background: var(--bg-toolbar); border-radius: 5px; }
::-webkit-scrollbar-thumb:hover { background: #555; }

View File

@ -1,316 +1,34 @@
// js/audio.js
import { appState } from "./state.js";
import { highlightStep, updateAudioEditorUI, updatePlayheadVisual, resetPlayheadVisual } from "./ui.js";
import { getTotalSteps } from "./utils.js";
import { PIXELS_PER_STEP } from "./config.js";
let audioContext;
let mainGainNode;
let masterPannerNode;
const timerDisplay = document.getElementById('timer-display');
// O contexto de áudio agora será gerenciado principalmente pelo Tone.js.
// Esta função garante que ele seja iniciado por uma interação do usuário.
export function initializeAudioContext() {
if (Tone.context.state !== 'running') {
Tone.start();
console.log("AudioContext iniciado com Tone.js");
}
}
// Funções de acesso ao contexto global do Tone.js
export function getAudioContext() {
return audioContext;
return Tone.context;
}
export function getMainGainNode() {
return mainGainNode;
}
export function initializeAudioContext() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
mainGainNode = audioContext.createGain();
masterPannerNode = audioContext.createStereoPanner();
mainGainNode.connect(masterPannerNode);
masterPannerNode.connect(audioContext.destination);
}
if (audioContext.state === "suspended") {
audioContext.resume();
}
return Tone.Destination;
}
// Funções para controlar o volume e pan master
export function updateMasterVolume(volume) {
if (mainGainNode) {
mainGainNode.gain.setValueAtTime(volume, audioContext.currentTime);
// Tone.Destination.volume.value é em decibéis. Convertemos de linear (0-1.5) para dB.
if (volume === 0) {
Tone.Destination.volume.value = -Infinity;
} else {
Tone.Destination.volume.value = Tone.gainToDb(volume);
}
}
export function updateMasterPan(pan) {
if (masterPannerNode) {
masterPannerNode.pan.setValueAtTime(pan, audioContext.currentTime);
}
}
function formatTime(milliseconds) {
const totalSeconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0');
const seconds = (totalSeconds % 60).toString().padStart(2, '0');
const centiseconds = Math.floor((milliseconds % 1000) / 10).toString().padStart(2, '0');
return `${minutes}:${seconds}:${centiseconds}`;
}
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(mainGainNode);
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;
if (!track || !track.audioBuffer) {
const audio = new Audio(filePath);
audio.play();
return;
}
const source = audioContext.createBufferSource();
source.buffer = track.audioBuffer;
if (track.gainNode) {
source.connect(track.gainNode);
} else {
source.connect(mainGainNode);
}
source.start(0);
}
function tick() {
const totalSteps = getTotalSteps();
if (totalSteps === 0 || !appState.isPlaying) {
stopPlayback();
return;
}
const lastStepIndex = appState.currentStep === 0 ? totalSteps - 1 : appState.currentStep - 1;
highlightStep(lastStepIndex, false);
const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120;
const stepInterval = (60 * 1000) / (bpm * 4);
const currentTime = appState.currentStep * stepInterval;
if (timerDisplay) {
timerDisplay.textContent = formatTime(currentTime);
}
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 % (stepsPerBeat * 4) === 0);
}
}
appState.tracks.forEach((track) => {
if (!track.patterns || track.patterns.length === 0) return;
const activePattern = track.patterns[appState.activePatternIndex];
if (activePattern && activePattern.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();
if (appState.currentStep === 0) {
rewindPlayback();
}
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() {
if(appState.playbackIntervalId) {
clearInterval(appState.playbackIntervalId);
}
appState.playbackIntervalId = null;
appState.isPlaying = false;
document.querySelectorAll('.step.playing').forEach(s => s.classList.remove('playing'));
appState.currentStep = 0;
if (timerDisplay) timerDisplay.textContent = '00:00:00';
const playBtn = document.getElementById("play-btn");
if (playBtn) {
playBtn.classList.remove("fa-pause");
playBtn.classList.add("fa-play");
}
}
export function rewindPlayback() {
const lastStep = appState.currentStep > 0 ? appState.currentStep - 1 : getTotalSteps() - 1;
appState.currentStep = 0;
if (!appState.isPlaying) {
if (timerDisplay) timerDisplay.textContent = '00:00:00';
highlightStep(lastStep, false);
}
}
export function togglePlayback() {
initializeAudioContext();
if (appState.isPlaying) {
stopPlayback();
} else {
appState.currentStep = 0;
startPlayback();
}
}
function animationLoop() {
if (!appState.isAudioEditorPlaying || !audioContext) return;
const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120;
const stepsPerSecond = (bpm / 60) * 4;
const pixelsPerSecond = stepsPerSecond * PIXELS_PER_STEP;
let totalElapsedTime = (audioContext.currentTime - appState.audioEditorStartTime) + appState.audioEditorPlaybackTime;
const maxDuration = appState.audioTracks.reduce((max, track) =>
(track.audioBuffer && track.audioBuffer.duration > max) ? track.audioBuffer.duration : max, 0
);
if (appState.isAudioEditorLoopEnabled && maxDuration > 0) {
totalElapsedTime = totalElapsedTime % maxDuration;
} else {
if (totalElapsedTime >= maxDuration && maxDuration > 0) {
stopAudioEditorPlayback();
appState.audioEditorPlaybackTime = 0;
resetPlayheadVisual();
return;
}
}
const newPositionPx = totalElapsedTime * pixelsPerSecond;
updatePlayheadVisual(newPositionPx);
appState.audioEditorAnimationId = requestAnimationFrame(animationLoop);
}
export function startAudioEditorPlayback() {
if (appState.isAudioEditorPlaying || appState.audioTracks.length === 0) return;
initializeAudioContext();
appState.isAudioEditorPlaying = true;
appState.activeAudioSources = [];
updateAudioEditorUI();
const startTime = audioContext.currentTime;
appState.audioEditorStartTime = startTime;
appState.audioTracks.forEach(track => {
if (track.audioBuffer && !track.isMuted && track.isSoloed) {
if (appState.audioEditorPlaybackTime >= track.audioBuffer.duration) return;
const source = audioContext.createBufferSource();
source.buffer = track.audioBuffer;
source.loop = appState.isAudioEditorLoopEnabled;
source.connect(track.gainNode);
source.start(startTime, appState.audioEditorPlaybackTime);
appState.activeAudioSources.push(source);
}
});
if (appState.activeAudioSources.length > 0) {
if (appState.audioEditorAnimationId) {
cancelAnimationFrame(appState.audioEditorAnimationId);
}
animationLoop();
} else {
appState.isAudioEditorPlaying = false;
updateAudioEditorUI();
}
}
export function stopAudioEditorPlayback() {
if (!appState.isAudioEditorPlaying) return;
let totalElapsedTime = (audioContext.currentTime - appState.audioEditorStartTime) + appState.audioEditorPlaybackTime;
const maxDuration = appState.audioTracks.reduce((max, track) =>
(track.audioBuffer && track.audioBuffer.duration > max) ? track.audioBuffer.duration : max, 0
);
// --- CORREÇÃO FINAL E ROBUSTA ---
// Sempre aplica o módulo ao salvar o tempo.
// Se não estava em loop, totalElapsedTime < maxDuration, e o módulo não faz nada.
// Se estava em loop, ele corrige o valor para a posição visual correta.
if (maxDuration > 0) {
appState.audioEditorPlaybackTime = totalElapsedTime % maxDuration;
} else {
appState.audioEditorPlaybackTime = totalElapsedTime;
}
// --- FIM DA CORREÇÃO ---
if (appState.audioEditorAnimationId) {
cancelAnimationFrame(appState.audioEditorAnimationId);
appState.audioEditorAnimationId = null;
}
appState.activeAudioSources.forEach(source => {
try {
source.stop(0);
} catch (e) { /* Ignora erros */ }
});
appState.activeAudioSources = [];
appState.isAudioEditorPlaying = false;
updateAudioEditorUI();
}
export function seekAudioEditor(newTime) {
const wasPlaying = appState.isAudioEditorPlaying;
if (wasPlaying) {
stopAudioEditorPlayback();
}
appState.audioEditorPlaybackTime = newTime;
const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120;
const stepsPerSecond = (bpm / 60) * 4;
const pixelsPerSecond = stepsPerSecond * PIXELS_PER_STEP;
const newPositionPx = newTime * pixelsPerSecond;
updatePlayheadVisual(newPositionPx);
if (wasPlaying) {
startAudioEditorPlayback();
}
}
export function restartAudioEditorIfPlaying() {
if (appState.isAudioEditorPlaying) {
stopAudioEditorPlayback();
startAudioEditorPlayback();
}
// A panorimização master em Tone.js geralmente requer um nó Panner dedicado.
// Por enquanto, esta função servirá como um placeholder para futuras implementações.
console.log("Master Pan ainda não implementado com Tone.js");
}

View File

@ -0,0 +1,154 @@
// js/audio_audio.js
import { appState } from "../state.js";
import { updateAudioEditorUI, updatePlayheadVisual, resetPlayheadVisual } from "./audio_ui.js";
import { PIXELS_PER_STEP } from "../config.js";
import { initializeAudioContext } from "../audio.js";
import { getPixelsPerSecond } from "../utils.js";
function animationLoop() {
if (!appState.global.isAudioEditorPlaying) return;
const pixelsPerSecond = getPixelsPerSecond();
const totalElapsedTime = Tone.Transport.seconds;
let maxTime = 0;
appState.audio.clips.forEach(clip => {
const endTime = clip.startTime + clip.duration;
if (endTime > maxTime) maxTime = endTime;
});
if (!appState.global.isLoopActive && totalElapsedTime >= maxTime && maxTime > 0) {
stopAudioEditorPlayback();
resetPlayheadVisual();
return;
}
const newPositionPx = totalElapsedTime * pixelsPerSecond;
updatePlayheadVisual(newPositionPx);
// ##### CORREÇÃO 1 #####
// Salva o ID da animação para que o stop possa cancelá-lo
appState.audio.audioEditorAnimationId = requestAnimationFrame(animationLoop);
}
export function updateTransportLoop() {
Tone.Transport.loop = appState.global.isLoopActive;
Tone.Transport.loopStart = appState.global.loopStartTime;
Tone.Transport.loopEnd = appState.global.loopEndTime;
}
export function startAudioEditorPlayback() {
if (appState.global.isAudioEditorPlaying) return;
initializeAudioContext();
Tone.Transport.cancel(); // Limpa eventos agendados anteriormente
updateTransportLoop(); // Isso deve definir Tone.Transport.loop = true e Tone.Transport.loopEnd
// 1. Pegue a duração total do loop que a função acima definiu
const loopInterval = Tone.Transport.loopEnd;
// Se loopEnd não foi definido (ex: 0 ou undefined), o loop não funcionará.
if (!loopInterval || loopInterval === 0) {
console.error("LoopEnd não está definido no Tone.Transport! O áudio não repetirá.");
// Você pode querer definir um padrão aqui, mas o ideal é
// garantir que 'updateTransportLoop' esteja definindo 'loopEnd' corretamente.
// ex: const loopInterval = "1m"; (se for um compasso por padrão)
}
appState.audio.clips.forEach(clip => {
if (!clip.player || !clip.player.loaded) return;
// 2. CORREÇÃO: Use scheduleRepeat no lugar de scheduleOnce
Tone.Transport.scheduleRepeat((time) => {
// Sua lógica de parâmetros está correta
clip.gainNode.gain.value = Tone.gainToDb(clip.volume);
clip.pannerNode.pan.value = clip.pan;
clip.player.playbackRate = Math.pow(2, clip.pitch / 12);
// Inicia o player no tempo agendado
clip.player.start(time, clip.offset, clip.duration);
},
loopInterval, // <--- O intervalo de repetição (ex: "4m", "8m")
clip.startTime // <--- Onde o clip começa dentro da linha do tempo
);
});
// 3. ADIÇÃO CRÍTICA: Inicie o transporte e atualize o estado
Tone.Transport.start();
appState.global.isAudioEditorPlaying = true;
// 4. (CORRIGIDO) Atualize a UI do botão de play
const playBtn = document.getElementById("audio-editor-play-btn");
if (playBtn) {
playBtn.classList.add("active");
// Verifica se o ícone existe antes de tentar mudá-lo
const icon = playBtn.querySelector('i');
if (icon) {
icon.className = 'fa-solid fa-pause';
}
}
// ##### CORREÇÃO 2 #####
// Inicia o loop de animação da agulha
animationLoop();
}
export function stopAudioEditorPlayback() {
if (!appState.global.isAudioEditorPlaying) return;
Tone.Transport.stop();
appState.audio.clips.forEach(clip => {
if (clip.player && clip.player.state === 'started') {
clip.player.stop();
}
});
appState.audio.audioEditorPlaybackTime = Tone.Transport.seconds;
// Esta lógica agora funcionará corretamente graças à Correção 1
if (appState.audio.audioEditorAnimationId) {
cancelAnimationFrame(appState.audio.audioEditorAnimationId);
appState.audio.audioEditorAnimationId = null;
}
// (CORRIGIDO) Atualiza a UI do botão de play
const playBtn = document.getElementById("audio-editor-play-btn");
if (playBtn) {
playBtn.classList.remove("active");
// Verifica se o ícone existe antes de tentar mudá-lo
const icon = playBtn.querySelector('i');
if (icon) {
icon.className = 'fa-solid fa-play'; // Muda de volta para "play"
}
}
appState.global.isAudioEditorPlaying = false;
updateAudioEditorUI();
}
export function seekAudioEditor(newTime) {
const wasPlaying = appState.global.isAudioEditorPlaying;
if (wasPlaying) {
stopAudioEditorPlayback();
}
appState.audio.audioEditorPlaybackTime = newTime;
Tone.Transport.seconds = newTime;
const pixelsPerSecond = getPixelsPerSecond();
const newPositionPx = newTime * pixelsPerSecond;
updatePlayheadVisual(newPositionPx);
if (wasPlaying) {
startAudioEditorPlayback();
}
}
export function restartAudioEditorIfPlaying() {
if (appState.global.isAudioEditorPlaying) {
appState.audio.audioEditorPlaybackTime = Tone.Transport.seconds;
stopAudioEditorPlayback();
startAudioEditorPlayback();
}
}

View File

@ -0,0 +1,136 @@
// js/audio_state.js
import { DEFAULT_VOLUME, DEFAULT_PAN } from "../config.js";
import { renderAudioEditor } from "./audio_ui.js";
import { getMainGainNode } from "../audio.js";
const initialState = {
tracks: [],
clips: [],
audioEditorStartTime: 0,
audioEditorAnimationId: null,
audioEditorPlaybackTime: 0,
isAudioEditorLoopEnabled: false,
};
export let audioState = { ...initialState };
export function initializeAudioState() {
audioState.clips.forEach(clip => {
if (clip.player) clip.player.dispose();
if (clip.pannerNode) clip.pannerNode.dispose();
if (clip.gainNode) clip.gainNode.dispose();
});
Object.assign(audioState, initialState, { tracks: [], clips: [] });
}
export async function loadAudioForClip(clip) {
if (!clip.sourcePath) return clip;
try {
// Cria o player e o conecta à cadeia de áudio do clipe
clip.player = new Tone.Player();
clip.player.chain(clip.gainNode, clip.pannerNode, getMainGainNode());
// Carrega o áudio e espera a conclusão
await clip.player.load(clip.sourcePath);
if (clip.duration === 0) {
clip.duration = clip.player.buffer.duration;
}
} catch (error) {
console.error(`Falha ao carregar áudio para o clipe ${clip.name}:`, error);
clip.player = null;
}
return clip;
}
export function addAudioClipToTimeline(samplePath, trackId = 1, startTime = 0) {
const newClip = {
id: Date.now() + Math.random(),
trackId: trackId,
sourcePath: samplePath,
name: samplePath.split('/').pop(),
player: null,
startTime: startTime,
offset: 0,
duration: 0,
pitch: 0,
volume: DEFAULT_VOLUME,
pan: DEFAULT_PAN,
isSoloed: true,
// --- ADICIONADO: Nós de áudio para cada clipe ---
gainNode: new Tone.Gain(Tone.gainToDb(DEFAULT_VOLUME)),
pannerNode: new Tone.Panner(DEFAULT_PAN),
};
audioState.clips.push(newClip);
loadAudioForClip(newClip).then(() => {
renderAudioEditor();
});
}
export function updateAudioClipProperties(clipId, properties) {
const clip = audioState.clips.find(c => c.id == clipId);
if (clip) {
Object.assign(clip, properties);
}
}
export function sliceAudioClip(clipId, sliceTimeInTimeline) {
const originalClip = audioState.clips.find(c => c.id == clipId);
if (!originalClip || sliceTimeInTimeline <= originalClip.startTime || sliceTimeInTimeline >= originalClip.startTime + originalClip.duration) {
return;
}
const cutPointInClip = sliceTimeInTimeline - originalClip.startTime;
const newClip = {
id: Date.now() + Math.random(),
trackId: originalClip.trackId,
sourcePath: originalClip.sourcePath,
name: originalClip.name,
player: originalClip.player,
startTime: sliceTimeInTimeline,
offset: originalClip.offset + cutPointInClip,
duration: originalClip.duration - cutPointInClip,
pitch: originalClip.pitch,
volume: originalClip.volume,
pan: originalClip.pan,
isSoloed: false,
gainNode: new Tone.Gain(Tone.gainToDb(originalClip.volume)),
pannerNode: new Tone.Panner(originalClip.pan),
};
newClip.player.chain(newClip.gainNode, newClip.pannerNode, getMainGainNode());
originalClip.duration = cutPointInClip;
audioState.clips.push(newClip);
}
export function updateClipVolume(clipId, volume) {
const clip = audioState.clips.find((c) => c.id == clipId);
if (clip) {
const clampedVolume = Math.max(0, Math.min(1.5, volume));
clip.volume = clampedVolume;
if (clip.gainNode) {
clip.gainNode.gain.value = Tone.gainToDb(clampedVolume);
}
}
}
export function updateClipPan(clipId, pan) {
const clip = audioState.clips.find((c) => c.id == clipId);
if (clip) {
const clampedPan = Math.max(-1, Math.min(1, pan));
clip.pan = clampedPan;
if (clip.pannerNode) {
clip.pannerNode.pan.value = clampedPan;
}
}
}
export function addAudioTrackLane() {
const newTrackName = `Pista de Áudio ${audioState.tracks.length + 1}`;
audioState.tracks.push({ id: Date.now(), name: newTrackName });
// A UI será re-renderizada a partir do main.js
}

View File

@ -0,0 +1,367 @@
// js/audio/audio_ui.js
import { appState } from "../state.js";
import {
addAudioClipToTimeline,
updateAudioClipProperties,
sliceAudioClip,
} from "./audio_state.js";
import { seekAudioEditor, restartAudioEditorIfPlaying, updateTransportLoop } from "./audio_audio.js";
import { drawWaveform } from "../waveform.js";
import { PIXELS_PER_BAR, ZOOM_LEVELS } from "../config.js";
import { getPixelsPerSecond } from "../utils.js";
export function renderAudioEditor() {
const audioEditor = document.querySelector('.audio-editor');
const existingTrackContainer = document.getElementById('audio-track-container');
if (!audioEditor || !existingTrackContainer) return;
// --- CRIAÇÃO E RENDERIZAÇÃO DA RÉGUA (AGORA COM WRAPPER E SPACER) ---
let rulerWrapper = audioEditor.querySelector('.ruler-wrapper');
if (!rulerWrapper) {
rulerWrapper = document.createElement('div');
rulerWrapper.className = 'ruler-wrapper';
rulerWrapper.innerHTML = `
<div class="ruler-spacer"></div>
<div class="timeline-ruler"></div>
`;
audioEditor.insertBefore(rulerWrapper, existingTrackContainer);
}
const ruler = rulerWrapper.querySelector('.timeline-ruler');
ruler.innerHTML = ''; // Limpa a régua para redesenhar
const pixelsPerSecond = getPixelsPerSecond();
let maxTime = appState.global.loopEndTime;
appState.audio.clips.forEach(clip => {
const endTime = clip.startTime + clip.duration;
if (endTime > maxTime) maxTime = endTime;
});
const containerWidth = existingTrackContainer.offsetWidth;
const contentWidth = maxTime * pixelsPerSecond;
const totalWidth = Math.max(contentWidth, containerWidth, 2000); // Garante uma largura mínima
ruler.style.width = `${totalWidth}px`;
const zoomFactor = ZOOM_LEVELS[appState.global.zoomLevelIndex];
const scaledBarWidth = PIXELS_PER_BAR * zoomFactor;
if (scaledBarWidth > 0) {
const numberOfBars = Math.ceil(totalWidth / scaledBarWidth);
for (let i = 1; i <= numberOfBars; i++) {
const marker = document.createElement('div');
marker.className = 'ruler-marker';
marker.textContent = i;
marker.style.left = `${(i - 1) * scaledBarWidth}px`;
ruler.appendChild(marker);
}
}
const loopRegion = document.createElement('div');
loopRegion.id = 'loop-region';
loopRegion.style.left = `${appState.global.loopStartTime * pixelsPerSecond}px`;
loopRegion.style.width = `${(appState.global.loopEndTime - appState.global.loopStartTime) * pixelsPerSecond}px`;
loopRegion.innerHTML = `<div class="loop-handle left"></div><div class="loop-handle right"></div>`;
loopRegion.classList.toggle("visible", appState.global.isLoopActive);
ruler.appendChild(loopRegion);
// --- LISTENER DA RÉGUA PARA INTERAÇÕES (LOOP E SEEK) ---
ruler.addEventListener('mousedown', (e) => {
const currentPixelsPerSecond = getPixelsPerSecond();
const loopHandle = e.target.closest('.loop-handle');
const loopRegionBody = e.target.closest('#loop-region:not(.loop-handle)');
if (loopHandle) {
e.preventDefault(); e.stopPropagation();
const handleType = loopHandle.classList.contains('left') ? 'left' : 'right';
const initialMouseX = e.clientX;
const initialStart = appState.global.loopStartTime;
const initialEnd = appState.global.loopEndTime;
const onMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - initialMouseX;
const deltaTime = deltaX / currentPixelsPerSecond;
let newStart = appState.global.loopStartTime;
let newEnd = appState.global.loopEndTime;
if (handleType === 'left') {
newStart = Math.max(0, initialStart + deltaTime);
newStart = Math.min(newStart, appState.global.loopEndTime - 0.1); // Não deixa passar do fim
appState.global.loopStartTime = newStart;
} else {
newEnd = Math.max(appState.global.loopStartTime + 0.1, initialEnd + deltaTime); // Não deixa ser antes do início
appState.global.loopEndTime = newEnd;
}
updateTransportLoop();
// ### CORREÇÃO DE PERFORMANCE 1 ###
// Remove a chamada para renderAudioEditor()
// Em vez disso, atualiza o estilo do elemento 'loopRegion' diretamente
loopRegion.style.left = `${newStart * currentPixelsPerSecond}px`;
loopRegion.style.width = `${(newEnd - newStart) * currentPixelsPerSecond}px`;
// ### FIM DA CORREÇÃO 1 ###
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
// Opcional: chamar renderAudioEditor() UMA VEZ no final para garantir a sincronia
renderAudioEditor();
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
return;
}
if (loopRegionBody) {
e.preventDefault(); e.stopPropagation();
const initialMouseX = e.clientX;
const initialStart = appState.global.loopStartTime;
const initialEnd = appState.global.loopEndTime;
const initialDuration = initialEnd - initialStart;
const onMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - initialMouseX;
const deltaTime = deltaX / currentPixelsPerSecond;
let newStart = Math.max(0, initialStart + deltaTime);
let newEnd = newStart + initialDuration;
appState.global.loopStartTime = newStart;
appState.global.loopEndTime = newEnd;
updateTransportLoop();
// ### CORREÇÃO DE PERFORMANCE 2 ###
// Remove a chamada para renderAudioEditor()
// Atualiza apenas a posição 'left' do elemento
loopRegion.style.left = `${newStart * currentPixelsPerSecond}px`;
// ### FIM DA CORREÇÃO 2 ###
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
// Opcional: chamar renderAudioEditor() UMA VEZ no final
renderAudioEditor();
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
return;
}
// Se o clique não foi em um handle ou no corpo do loop, faz o "seek"
e.preventDefault();
const handleSeek = (event) => {
const rect = ruler.getBoundingClientRect();
const scrollLeft = ruler.scrollLeft;
const clickX = event.clientX - rect.left;
const absoluteX = clickX + scrollLeft;
const newTime = absoluteX / currentPixelsPerSecond;
seekAudioEditor(newTime);
};
handleSeek(e);
const onMouseMoveSeek = (moveEvent) => handleSeek(moveEvent);
const onMouseUpSeek = () => { document.removeEventListener('mousemove', onMouseMoveSeek); document.removeEventListener('mouseup', onMouseUpSeek); };
document.addEventListener('mousemove', onMouseMoveSeek);
document.addEventListener('mouseup', onMouseUpSeek);
});
// --- RECRIAÇÃO DO CONTAINER DE PISTAS PARA EVITAR LISTENERS DUPLICADOS ---
const newTrackContainer = existingTrackContainer.cloneNode(false);
audioEditor.replaceChild(newTrackContainer, existingTrackContainer);
if (appState.audio.tracks.length === 0) {
appState.audio.tracks.push({ id: Date.now(), name: "Pista de Áudio 1" });
}
// --- RENDERIZAÇÃO DAS PISTAS INDIVIDUAIS ---
appState.audio.tracks.forEach(trackData => {
const audioTrackLane = document.createElement('div');
audioTrackLane.className = 'audio-track-lane';
audioTrackLane.dataset.trackId = trackData.id;
audioTrackLane.innerHTML = `
<div class="track-info">
<div class="track-info-header">
<i class="fa-solid fa-gear"></i>
<span class="track-name">${trackData.name}</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="spectrogram-view-grid" style="width: ${totalWidth}px;"></div>
<div class="playhead"></div>
</div>
`;
newTrackContainer.appendChild(audioTrackLane);
const timelineContainer = audioTrackLane.querySelector('.timeline-container');
timelineContainer.addEventListener("dragover", (e) => { e.preventDefault(); audioTrackLane.classList.add('drag-over'); });
timelineContainer.addEventListener("dragleave", () => audioTrackLane.classList.remove('drag-over'));
timelineContainer.addEventListener("drop", (e) => {
e.preventDefault();
audioTrackLane.classList.remove('drag-over');
const filePath = e.dataTransfer.getData("text/plain");
if (!filePath) return;
const rect = timelineContainer.getBoundingClientRect();
const dropX = e.clientX - rect.left + timelineContainer.scrollLeft;
const startTimeInSeconds = dropX / pixelsPerSecond;
addAudioClipToTimeline(filePath, trackData.id, startTimeInSeconds);
});
const grid = timelineContainer.querySelector('.spectrogram-view-grid');
grid.style.setProperty('--bar-width', `${scaledBarWidth}px`);
grid.style.setProperty('--four-bar-width', `${scaledBarWidth * 4}px`);
});
// --- RENDERIZAÇÃO DOS CLIPS ---
appState.audio.clips.forEach(clip => {
const parentGrid = newTrackContainer.querySelector(`.audio-track-lane[data-track-id="${clip.trackId}"] .spectrogram-view-grid`);
if (!parentGrid) return;
const clipElement = document.createElement('div');
clipElement.className = 'timeline-clip';
clipElement.dataset.clipId = clip.id;
clipElement.style.left = `${clip.startTime * pixelsPerSecond}px`;
clipElement.style.width = `${clip.duration * pixelsPerSecond}px`;
let pitchStr = clip.pitch > 0 ? `+${clip.pitch}` : `${clip.pitch}`;
if (clip.pitch === 0) pitchStr = '';
clipElement.innerHTML = `
<div class="clip-resize-handle left"></div>
<span class="clip-name">${clip.name} ${pitchStr}</span>
<canvas class="waveform-canvas-clip"></canvas>
<div class="clip-resize-handle right"></div>
`;
parentGrid.appendChild(clipElement);
if (clip.player && clip.player.loaded) {
const canvas = clipElement.querySelector('.waveform-canvas-clip');
canvas.width = clip.duration * pixelsPerSecond;
canvas.height = 40;
const audioBuffer = clip.player.buffer.get();
drawWaveform(canvas, audioBuffer, 'var(--accent-green)', clip.offset, clip.duration);
}
clipElement.addEventListener('wheel', (e) => {
e.preventDefault();
const clipToUpdate = appState.audio.clips.find(c => c.id == clipElement.dataset.clipId);
if (!clipToUpdate) return;
const direction = e.deltaY < 0 ? 1 : -1;
let newPitch = clipToUpdate.pitch + direction;
newPitch = Math.max(-24, Math.min(24, newPitch));
updateAudioClipProperties(clipToUpdate.id, { pitch: newPitch });
renderAudioEditor();
restartAudioEditorIfPlaying();
});
});
// --- SINCRONIZAÇÃO DE SCROLL ENTRE A RÉGUA E AS PISTAS ---
newTrackContainer.addEventListener('scroll', () => {
const scrollPos = newTrackContainer.scrollLeft;
if (ruler.scrollLeft !== scrollPos) {
ruler.scrollLeft = scrollPos;
}
});
// --- EVENT LISTENER PRINCIPAL PARA INTERAÇÕES (MOVER, REDIMENSIONAR, ETC.) ---
newTrackContainer.addEventListener('mousedown', (e) => {
const currentPixelsPerSecond = getPixelsPerSecond();
const handle = e.target.closest('.clip-resize-handle');
const clipElement = e.target.closest('.timeline-clip');
if (appState.global.sliceToolActive && clipElement) { /* ... lógica de corte ... */ return; }
if (handle) { /* ... lógica de redimensionamento de clipe ... */ return; }
if (clipElement) {
e.preventDefault();
const clipId = clipElement.dataset.clipId;
const clickOffsetInClip = e.clientX - clipElement.getBoundingClientRect().left;
clipElement.classList.add('dragging');
let lastOverLane = clipElement.closest('.audio-track-lane');
const onMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - e.clientX;
clipElement.style.transform = `translateX(${deltaX}px)`;
const overElement = document.elementFromPoint(moveEvent.clientX, moveEvent.clientY);
const overLane = overElement ? overElement.closest('.audio-track-lane') : null;
if (overLane && overLane !== lastOverLane) {
if(lastOverLane) lastOverLane.classList.remove('drag-over');
overLane.classList.add('drag-over');
lastOverLane = overLane;
}
};
const onMouseUp = (upEvent) => {
clipElement.classList.remove('dragging');
if (lastOverLane) lastOverLane.classList.remove('drag-over');
clipElement.style.transform = '';
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
const finalLane = lastOverLane;
if (!finalLane) return;
const newTrackId = finalLane.dataset.trackId;
const timelineContainer = finalLane.querySelector('.timeline-container');
const wrapperRect = timelineContainer.getBoundingClientRect();
const newLeftPx = (upEvent.clientX - wrapperRect.left) - clickOffsetInClip + timelineContainer.scrollLeft;
const constrainedLeftPx = Math.max(0, newLeftPx);
const newStartTime = constrainedLeftPx / currentPixelsPerSecond;
updateAudioClipProperties(clipId, { trackId: Number(newTrackId), startTime: newStartTime });
renderAudioEditor();
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
return;
}
const timelineContainer = e.target.closest('.timeline-container');
if (timelineContainer) {
e.preventDefault();
const handleSeek = (event) => {
const rect = timelineContainer.getBoundingClientRect();
const scrollLeft = timelineContainer.scrollLeft;
const clickX = event.clientX - rect.left;
const absoluteX = clickX + scrollLeft;
const newTime = absoluteX / currentPixelsPerSecond;
seekAudioEditor(newTime);
};
handleSeek(e);
const onMouseMoveSeek = (moveEvent) => handleSeek(moveEvent);
const onMouseUpSeek = () => { document.removeEventListener('mousemove', onMouseMoveSeek); document.removeEventListener('mouseup', onMouseUpSeek); };
document.addEventListener('mousemove', onMouseMoveSeek);
document.addEventListener('mouseup', onMouseUpSeek);
}
});
}
export function updateAudioEditorUI() {
const playBtn = document.getElementById('audio-editor-play-btn');
if (!playBtn) return;
if (appState.global.isAudioEditorPlaying) {
playBtn.classList.remove('fa-play');
playBtn.classList.add('fa-pause');
} else {
playBtn.classList.remove('fa-pause');
playBtn.classList.add('fa-play');
}
}
export function updatePlayheadVisual(pixels) {
document.querySelectorAll('.audio-track-lane .playhead').forEach(ph => {
ph.style.left = `${pixels}px`;
});
}
export function resetPlayheadVisual() {
document.querySelectorAll('.audio-track-lane .playhead').forEach(ph => {
ph.style.left = '0px';
});
}

View File

@ -12,3 +12,6 @@ export const DEFAULT_PAN = 0.0;
// Constantes para o layout do editor de áudio
export const PIXELS_PER_STEP = 32; // Cada step (1/16) terá 32px de largura
export const PIXELS_PER_BAR = 512; // 16 steps * 32px/step = 512px por compasso (bar)
// Níveis de zoom pré-definidos (fator de multiplicação)
export const ZOOM_LEVELS = [0.25, 0.5, 1.0, 2.0, 4.0, 8.0];

View File

@ -1,13 +1,9 @@
// js/file.js
import { appState, loadAudioForTrack } from "./state.js";
import { getTotalSteps } from "./utils.js";
import { renderApp, getSamplePathMap } from "./ui.js";
import { DEFAULT_PAN, DEFAULT_VOLUME, NOTE_LENGTH, TICKS_PER_BAR } from "./config.js";
import {
initializeAudioContext,
getAudioContext,
getMainGainNode,
} from "./audio.js";
import { appState, resetProjectState } from "./state.js";
import { loadAudioForTrack } from "./pattern/pattern_state.js";
import { renderAll, getSamplePathMap } from "./ui.js";
import { DEFAULT_PAN, DEFAULT_VOLUME, NOTE_LENGTH } from "./config.js";
import { initializeAudioContext, getAudioContext, getMainGainNode } from "./audio.js";
export async function handleFileLoad(file) {
let xmlContent = "";
@ -31,12 +27,31 @@ export async function handleFileLoad(file) {
}
}
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();
await parseMmpContent(xmlContent);
return true;
} catch (error) {
console.error("Erro ao carregar projeto do servidor:", error);
console.error(error);
alert(`Erro ao carregar projeto: ${error.message}`);
return false;
}
}
export async function parseMmpContent(xmlString) {
resetProjectState();
initializeAudioContext();
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlString, "application/xml");
appState.originalXmlDoc = xmlDoc;
appState.global.originalXmlDoc = xmlDoc;
let newTracks = [];
const head = xmlDoc.querySelector("head");
@ -48,25 +63,27 @@ export async function parseMmpContent(xmlString) {
const allBBTrackNodes = Array.from(xmlDoc.querySelectorAll('song > trackcontainer[type="song"] > track[type="1"]'));
if (allBBTrackNodes.length === 0) {
appState.tracks = []; renderApp(); return;
appState.pattern.tracks = [];
renderAll();
return;
}
// --- INÍCIO DA CORREÇÃO FINAL DE ORDENAÇÃO ---
// A lista de NOMES é ordenada em ordem CRESCENTE (a ordem correta, cronológica).
const sortedBBTrackNameNodes = [...allBBTrackNodes].sort((a, b) => {
const bbtcoA = a.querySelector('bbtco');
const bbtcoB = b.querySelector('bbtco');
const posA = bbtcoA ? parseInt(bbtcoA.getAttribute('pos'), 10) : Infinity;
const posB = bbtcoB ? parseInt(bbtcoB.getAttribute('pos'), 10) : Infinity;
return posA - posB; // Ordem crescente
return posA - posB;
});
const dataSourceTrack = allBBTrackNodes[0];
appState.currentBeatBasslineName = dataSourceTrack.getAttribute("name") || "Beat/Bassline";
appState.global.currentBeatBasslineName = dataSourceTrack.getAttribute("name") || "Beat/Bassline";
const bbTrackContainer = dataSourceTrack.querySelector('bbtrack > trackcontainer');
if (!bbTrackContainer) {
appState.tracks = []; renderApp(); return;
appState.pattern.tracks = [];
renderAll();
return;
}
const instrumentTracks = bbTrackContainer.querySelectorAll('track[type="0"]');
@ -84,13 +101,11 @@ export async function parseMmpContent(xmlString) {
}
const allPatternsNodeList = trackNode.querySelectorAll("pattern");
// A lista de CONTEÚDO dos patterns é ordenada de forma DECRESCENTE para corresponder.
const allPatternsArray = Array.from(allPatternsNodeList).sort((a, b) => {
const posA = parseInt(a.getAttribute('pos'), 10) || 0;
const posB = parseInt(b.getAttribute('pos'), 10) || 0;
return posB - posA; // Ordem decrescente
return posB - posA;
});
// --- FIM DA CORREÇÃO FINAL DE ORDENAÇÃO ---
const patterns = sortedBBTrackNameNodes.map((bbTrack, index) => {
const patternNode = allPatternsArray[index];
@ -159,13 +174,15 @@ export async function parseMmpContent(xmlString) {
let isFirstTrackWithNotes = true;
newTracks.forEach(track => {
const audioContext = getAudioContext();
track.gainNode = audioContext.createGain();
track.pannerNode = audioContext.createStereoPanner();
// --- INÍCIO DA CORREÇÃO ---
// Cria os nós de áudio usando os construtores do Tone.js
track.gainNode = new Tone.Gain(Tone.gainToDb(track.volume));
track.pannerNode = new Tone.Panner(track.pan);
// Conecta a cadeia de áudio: Gain -> Panner -> Saída Principal (Destination)
track.gainNode.connect(track.pannerNode);
track.pannerNode.connect(getMainGainNode());
track.gainNode.gain.value = track.volume;
track.pannerNode.pan.value = track.pan;
// --- FIM DA CORREÇÃO ---
if (isFirstTrackWithNotes) {
const activeIdx = track.activePatternIndex || 0;
@ -187,13 +204,13 @@ export async function parseMmpContent(xmlString) {
console.error("Ocorreu um erro ao carregar os áudios do projeto:", error);
}
appState.tracks = newTracks;
appState.activeTrackId = appState.tracks[0]?.id || null;
renderApp();
appState.pattern.tracks = newTracks;
appState.pattern.activeTrackId = appState.pattern.tracks[0]?.id || null;
renderAll();
}
export function generateMmpFile() {
if (appState.originalXmlDoc) {
if (appState.global.originalXmlDoc) {
modifyAndSaveExistingMmp();
} else {
generateNewMmp();
@ -202,11 +219,9 @@ export function generateMmpFile() {
function createTrackXml(track) {
if (track.patterns.length === 0) return "";
const ticksPerStep = 12;
const lmmsVolume = Math.round(track.volume * 100);
const lmmsPan = Math.round(track.pan * 100);
const patternsXml = track.patterns.map(pattern => {
const patternNotes = pattern.steps.map((isActive, index) => {
if (isActive) {
@ -215,12 +230,10 @@ function createTrackXml(track) {
}
return "";
}).join("\n ");
return `<pattern type="0" pos="${pattern.pos}" muted="0" steps="${pattern.steps.length}" name="${pattern.name}">
${patternNotes}
</pattern>`;
}).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}">
@ -235,39 +248,23 @@ function createTrackXml(track) {
function modifyAndSaveExistingMmp() {
console.log("Modificando arquivo .mmp existente...");
const xmlDoc = appState.originalXmlDoc.cloneNode(true);
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("timesig_numerator", document.getElementById("compasso-a-input").value);
head.setAttribute("timesig_denominator", document.getElementById("compasso-b-input").value);
}
const bbTrackContainer = xmlDoc.querySelector('track[type="1"] > bbtrack > trackcontainer');
if (bbTrackContainer) {
bbTrackContainer.querySelectorAll('track[type="0"]').forEach(node => node.remove());
const tracksXml = appState.tracks
.map((track) => createTrackXml(track))
.join("");
const tempDoc = new DOMParser().parseFromString(
`<root>${tracksXml}</root>`,
"application/xml"
);
const tracksXml = appState.pattern.tracks.map((track) => createTrackXml(track)).join("");
const tempDoc = new DOMParser().parseFromString(`<root>${tracksXml}</root>`, "application/xml");
Array.from(tempDoc.documentElement.children).forEach((newTrackNode) => {
bbTrackContainer.appendChild(newTrackNode);
});
}
const serializer = new XMLSerializer();
const mmpContent = serializer.serializeToString(xmlDoc);
downloadFile(mmpContent, "projeto_editado.mmp");
@ -278,10 +275,7 @@ function generateNewMmp() {
const sig_num = document.getElementById("compasso-a-input").value;
const sig_den = document.getElementById("compasso-b-input").value;
const num_bars = document.getElementById("bars-input").value;
const tracksXml = appState.tracks
.map((track) => createTrackXml(track))
.join("");
const tracksXml = appState.pattern.tracks.map((track) => createTrackXml(track)).join("");
const mmpContent = `<?xml version="1.0"?>
<!DOCTYPE lmms-project>
<lmms-project version="1.0" type="song" creator="MMPCreator" creatorversion="1.0">
@ -321,18 +315,3 @@ function downloadFile(content, fileName) {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
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();
await parseMmpContent(xmlContent);
return true;
} catch (error) {
console.error("Erro ao carregar projeto do servidor:", error);
alert(`Erro ao carregar projeto: ${error.message}`);
return false;
}
}

View File

@ -1,31 +1,24 @@
// js/main.js
import {
appState,
addTrackToState,
removeLastTrackFromState,
} from "./state.js";
import { appState, resetProjectState } from "./state.js";
import { addTrackToState, removeLastTrackFromState } from "./pattern/pattern_state.js";
import { addAudioTrackLane } from "./audio/audio_state.js";
import { updateTransportLoop } from "./audio/audio_audio.js";
import {
togglePlayback,
stopPlayback,
rewindPlayback,
initializeAudioContext,
updateMasterVolume,
updateMasterPan,
} from "./pattern/pattern_audio.js";
import {
startAudioEditorPlayback,
stopAudioEditorPlayback,
restartAudioEditorIfPlaying,
} from "./audio.js";
} from "./audio/audio_audio.js";
import { initializeAudioContext } from "./audio.js";
import { handleFileLoad, generateMmpFile } from "./file.js";
import {
renderApp,
redrawSequencer,
loadAndRenderSampleBrowser,
showOpenProjectModal,
closeOpenProjectModal,
handleSampleUpload,
} from "./ui.js";
import { renderAll, loadAndRenderSampleBrowser, showOpenProjectModal, closeOpenProjectModal } from "./ui.js";
import { renderAudioEditor } from "./audio/audio_ui.js";
import { adjustValue, enforceNumericInput } from "./utils.js";
import { DEFAULT_PAN, DEFAULT_VOLUME } from "./config.js";
import { ZOOM_LEVELS } from "./config.js";
document.addEventListener("DOMContentLoaded", () => {
const newProjectBtn = document.getElementById("new-project-btn");
@ -39,8 +32,10 @@ document.addEventListener("DOMContentLoaded", () => {
const audioEditorPlayBtn = document.getElementById("audio-editor-play-btn");
const audioEditorStopBtn = document.getElementById("audio-editor-stop-btn");
const audioEditorLoopBtn = document.getElementById("audio-editor-loop-btn");
const addAudioTrackBtn = document.getElementById("add-audio-track-btn");
const rewindBtn = document.getElementById("rewind-btn");
const metronomeBtn = document.getElementById("metronome-btn");
const sliceToolBtn = document.getElementById("slice-tool-btn");
const mmpFileInput = document.getElementById("mmp-file-input");
const sampleFileInput = document.getElementById("sample-file-input");
const openProjectModal = document.getElementById("open-project-modal");
@ -48,36 +43,19 @@ document.addEventListener("DOMContentLoaded", () => {
const loadFromComputerBtn = document.getElementById("load-from-computer-btn");
const sidebarToggle = document.getElementById("sidebar-toggle");
const addBarBtn = document.getElementById("add-bar-btn");
const masterVolumeKnob = document.getElementById("master-volume-knob");
const masterPanKnob = document.getElementById("master-pan-knob");
const zoomInBtn = document.getElementById("zoom-in-btn");
const zoomOutBtn = document.getElementById("zoom-out-btn");
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: [],
audioTracks: [],
activeTrackId: null,
isPlaying: false,
playbackIntervalId: null,
currentStep: 0,
metronomeEnabled: false,
originalXmlDoc: null,
currentBeatBasslineName: 'Novo Projeto',
masterVolume: DEFAULT_VOLUME,
masterPan: DEFAULT_PAN
});
if ((appState.pattern.tracks.length > 0 || appState.audio.clips.length > 0) && !confirm("Você tem certeza? Alterações não salvas serão perdidas.")) return;
resetProjectState();
document.getElementById('bpm-input').value = 140;
document.getElementById('bars-input').value = 1;
document.getElementById('compasso-a-input').value = 4;
document.getElementById('compasso-b-input').value = 4;
const titleElement = document.getElementById('beat-bassline-title');
if(titleElement) titleElement.textContent = 'Novo Projeto';
renderApp();
setupMasterKnobs();
renderAll();
});
addBarBtn.addEventListener("click", () => {
@ -85,188 +63,114 @@ document.addEventListener("DOMContentLoaded", () => {
if (barsInput) adjustValue(barsInput, 1);
});
function setupMasterKnobs() {
function updateMasterKnobVisual(knobElement, controlType) {
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 = appState.masterVolume;
percentage = value / 1.5;
title = `Volume Master: ${Math.round(value * 100)}%`;
} else {
const value = appState.masterPan;
percentage = (value + 1) / 2;
const panDisplay = Math.round(value * 100);
title = `Pan Master: ${ 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;
}
function addMasterKnobInteraction(knobElement, controlType) {
knobElement.addEventListener("wheel", (e) => {
e.preventDefault();
const step = 0.05;
const direction = e.deltaY < 0 ? 1 : -1;
if (controlType === "volume") {
const newValue = appState.masterVolume + direction * step;
appState.masterVolume = Math.max(0, Math.min(1.5, newValue));
updateMasterVolume(appState.masterVolume);
} else {
const newValue = appState.masterPan + direction * step;
appState.masterPan = Math.max(-1, Math.min(1, newValue));
updateMasterPan(appState.masterPan);
}
updateMasterKnobVisual(knobElement, controlType);
});
knobElement.addEventListener("mousedown", (e) => {
if (e.button !== 0) return;
e.preventDefault();
const startY = e.clientY;
const startValue = controlType === "volume" ? appState.masterVolume : appState.masterPan;
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") {
appState.masterVolume = Math.max(0, Math.min(1.5, newValue));
updateMasterVolume(appState.masterVolume);
} else {
appState.masterPan = Math.max(-1, Math.min(1, newValue));
updateMasterPan(appState.masterPan);
}
updateMasterKnobVisual(knobElement, controlType);
}
function onMouseUp() {
document.body.classList.remove("knob-dragging");
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
}
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
});
}
addMasterKnobInteraction(masterVolumeKnob, "volume");
updateMasterKnobVisual(masterVolumeKnob, "volume");
addMasterKnobInteraction(masterPanKnob, "pan");
updateMasterKnobVisual(masterPanKnob, "pan");
}
openMmpBtn.addEventListener("click", showOpenProjectModal);
loadFromComputerBtn.addEventListener("click", () => mmpFileInput.click());
mmpFileInput.addEventListener("change", async (event) => {
const file = event.target.files[0];
if (file) {
await handleFileLoad(file);
closeOpenProjectModal();
}
});
mmpFileInput.addEventListener("change", (event) => { const file = event.target.files[0]; if (file) { handleFileLoad(file).then(() => closeOpenProjectModal()); } });
uploadSampleBtn.addEventListener("click", () => sampleFileInput.click());
sampleFileInput.addEventListener("change", async (event) => {
const file = event.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append("sampleFile", file);
try {
const response = await fetch('http://localhost:5000/upload-sample', {
method: 'POST',
body: formData,
});
const result = await response.json();
if (response.ok) {
alert("Sample enviado com sucesso!");
await loadAndRenderSampleBrowser();
} else {
throw new Error(result.error || "Erro desconhecido no servidor.");
}
} catch (error) {
console.error("Erro ao enviar o sample:", error);
alert(`Falha no upload: ${error.message}`);
}
event.target.value = null;
});
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);
});
metronomeBtn.addEventListener("click", () => { initializeAudioContext(); appState.global.metronomeEnabled = !appState.global.metronomeEnabled; metronomeBtn.classList.toggle("active", appState.global.metronomeEnabled); });
if(sliceToolBtn) { sliceToolBtn.addEventListener("click", () => { appState.global.sliceToolActive = !appState.global.sliceToolActive; sliceToolBtn.classList.toggle("active", appState.global.sliceToolActive); document.body.classList.toggle("slice-tool-active", appState.global.sliceToolActive); }); }
openModalCloseBtn.addEventListener("click", closeOpenProjectModal);
openProjectModal.addEventListener("click", (e) => {
if (e.target === openProjectModal) closeOpenProjectModal();
});
// ### CORREÇÃO 2: Adicionada verificação 'if (icon)' ###
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";
if (icon) {
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-") || event.target.id === 'bars-input')) {
stopPlayback();
}
if (event.target.id.startsWith("compasso-") || event.target.id === 'bars-input') {
redrawSequencer();
}
});
input.addEventListener("wheel", (event) => {
event.preventDefault();
const step = event.deltaY < 0 ? 1 : -1;
adjustValue(event.target, step);
if (appState.global.isPlaying && (event.target.id.startsWith("compasso-") || event.target.id === 'bars-input')) { stopPlayback(); }
if (event.target.id.startsWith("compasso-") || event.target.id === 'bars-input' || event.target.id === 'bpm-input') { renderAll(); }
});
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);
const step = parseInt(button.dataset.step, 10) || 1;
if (targetInput) {
adjustValue(targetInput, step);
}
buttons.forEach((button) => { button.addEventListener("click", () => { const targetId = button.dataset.target + "-input"; const targetInput = document.getElementById(targetId); const step = parseInt(button.dataset.step, 10) || 1; if (targetInput) { adjustValue(targetInput, step); } }); });
if (zoomInBtn) {
zoomInBtn.addEventListener("click", () => {
if (appState.global.zoomLevelIndex < ZOOM_LEVELS.length - 1) {
appState.global.zoomLevelIndex++;
renderAll();
}
});
});
audioEditorPlayBtn.addEventListener("click", () => {
if (appState.isAudioEditorPlaying) {
stopAudioEditorPlayback();
} else {
startAudioEditorPlayback();
}
});
}
if (zoomOutBtn) {
zoomOutBtn.addEventListener("click", () => {
if (appState.global.zoomLevelIndex > 0) {
appState.global.zoomLevelIndex--;
renderAll();
}
});
}
audioEditorPlayBtn.addEventListener("click", () => { if (appState.global.isAudioEditorPlaying) { stopAudioEditorPlayback(); } else { startAudioEditorPlayback(); } });
audioEditorStopBtn.addEventListener("click", stopAudioEditorPlayback);
// ### CORREÇÃO 1: Listeners duplicados combinados em um só ###
// No main.js
audioEditorLoopBtn.addEventListener("click", () => {
appState.isAudioEditorLoopEnabled = !appState.isAudioEditorLoopEnabled;
audioEditorLoopBtn.classList.toggle("active", appState.isAudioEditorLoopEnabled);
console.log("--- Botão de Loop Clicado ---"); // DEBUG 1
// 1. Altera o estado global de loop
appState.global.isLoopActive = !appState.global.isLoopActive;
console.log("Estado appState.global.isLoopActive:", appState.global.isLoopActive); // DEBUG 2
// 2. Sincroniza o estado do loop do editor
appState.audio.isAudioEditorLoopEnabled = appState.global.isLoopActive;
// 3. Atualiza a aparência do botão
audioEditorLoopBtn.classList.toggle("active", appState.global.isLoopActive);
// 4. Sincroniza o Tone.Transport
updateTransportLoop();
// 5. Mostra/esconde a área de loop
const loopArea = document.getElementById("loop-region");
// ESTE É O TESTE MAIS IMPORTANTE:
if (loopArea) {
console.log("Elemento #loop-region ENCONTRADO. Alterando classe 'visible'."); // DEBUG 3
loopArea.classList.toggle("visible", appState.global.isLoopActive);
} else {
console.error("ERRO GRAVE: Elemento #loop-region NÃO FOI ENCONTRADO!"); // DEBUG 4
}
// 6. Reinicia o playback se estiver tocando
restartAudioEditorIfPlaying();
});
if (addAudioTrackBtn) { addAudioTrackBtn.addEventListener("click", () => { addAudioTrackLane(); renderAudioEditor(); }); }
// ### CORREÇÃO 3: Ordem de execução corrigida ###
// 1. Carrega o conteúdo do navegador de samples
loadAndRenderSampleBrowser();
renderApp();
setupMasterKnobs();
// 2. Adiciona o listener DEPOIS que o conteúdo supostamente existe
const browserContent = document.getElementById('browser-content');
if (browserContent) {
browserContent.addEventListener('click', function(event) {
const folderName = event.target.closest('.folder-name');
if (folderName) {
const folderItem = folderName.parentElement;
folderItem.classList.toggle('open');
}
});
}
// 3. Renderiza o resto
renderAll();
});

View File

@ -0,0 +1,141 @@
// js/pattern_audio.js
import { appState } from "../state.js";
import { highlightStep } from "./pattern_ui.js";
import { getTotalSteps } from "../utils.js";
import { initializeAudioContext } from "../audio.js";
const timerDisplay = document.getElementById('timer-display');
function formatTime(milliseconds) {
const totalSeconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0');
const seconds = (totalSeconds % 60).toString().padStart(2, '0');
const centiseconds = Math.floor((milliseconds % 1000) / 10).toString().padStart(2, '0');
return `${minutes}:${seconds}:${centiseconds}`;
}
export function playMetronomeSound(isDownbeat) {
initializeAudioContext();
const synth = new Tone.Synth().toDestination();
const freq = isDownbeat ? 1000 : 800;
synth.triggerAttackRelease(freq, "8n", Tone.now());
}
// --- FUNÇÃO CORRIGIDA E EFICIENTE ---
export function playSample(filePath, trackId) {
initializeAudioContext();
const track = trackId ? appState.pattern.tracks.find((t) => t.id == trackId) : null;
// Se a faixa existe e tem um player pré-carregado, apenas o dispara.
if (track && track.player) {
// Atualiza o volume/pan caso tenham sido alterados
track.gainNode.gain.value = Tone.gainToDb(track.volume);
track.pannerNode.pan.value = track.pan;
// Dispara o som imediatamente. Esta operação é instantânea.
track.player.start(Tone.now());
}
// Fallback para preview de samples no navegador (sem trackId)
else if (!trackId && filePath) {
const previewPlayer = new Tone.Player(filePath).toDestination();
previewPlayer.autostart = true;
}
}
function tick() {
if (!appState.global.isPlaying) {
stopPlayback();
return;
}
const totalSteps = getTotalSteps();
const lastStepIndex = appState.global.currentStep === 0 ? totalSteps - 1 : appState.global.currentStep - 1;
highlightStep(lastStepIndex, false);
const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120;
const stepInterval = (60 * 1000) / (bpm * 4);
const currentTime = appState.global.currentStep * stepInterval;
if (timerDisplay) {
timerDisplay.textContent = formatTime(currentTime);
}
if (appState.global.metronomeEnabled) {
const noteValue = parseInt(document.getElementById("compasso-b-input").value, 10) || 4;
const stepsPerBeat = 16 / noteValue;
if (appState.global.currentStep % stepsPerBeat === 0) {
playMetronomeSound(appState.global.currentStep % (stepsPerBeat * 4) === 0);
}
}
appState.pattern.tracks.forEach((track) => {
if (!track.patterns || track.patterns.length === 0) return;
const activePattern = track.patterns[appState.pattern.activePatternIndex];
if (activePattern && activePattern.steps[appState.global.currentStep] && track.samplePath) {
playSample(track.samplePath, track.id);
}
});
highlightStep(appState.global.currentStep, true);
appState.global.currentStep = (appState.global.currentStep + 1) % totalSteps;
}
export function startPlayback() {
if (appState.global.isPlaying || appState.pattern.tracks.length === 0) return;
initializeAudioContext();
if (appState.global.currentStep === 0) {
rewindPlayback();
}
const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120;
Tone.Transport.bpm.value = bpm;
const stepInterval = (60 * 1000) / (bpm * 4);
if (appState.global.playbackIntervalId) clearInterval(appState.global.playbackIntervalId);
appState.global.isPlaying = true;
document.getElementById("play-btn").classList.remove("fa-play");
document.getElementById("play-btn").classList.add("fa-pause");
tick();
appState.global.playbackIntervalId = setInterval(tick, stepInterval);
}
export function stopPlayback() {
if(appState.global.playbackIntervalId) {
clearInterval(appState.global.playbackIntervalId);
}
appState.global.playbackIntervalId = null;
appState.global.isPlaying = false;
document.querySelectorAll('.step.playing').forEach(s => s.classList.remove('playing'));
appState.global.currentStep = 0;
if (timerDisplay) timerDisplay.textContent = '00:00:00';
const playBtn = document.getElementById("play-btn");
if (playBtn) {
playBtn.classList.remove("fa-pause");
playBtn.classList.add("fa-play");
}
}
export function rewindPlayback() {
const lastStep = appState.global.currentStep > 0 ? appState.global.currentStep - 1 : getTotalSteps() - 1;
appState.global.currentStep = 0;
if (!appState.global.isPlaying) {
if (timerDisplay) timerDisplay.textContent = '00:00:00';
highlightStep(lastStep, false);
}
}
export function togglePlayback() {
initializeAudioContext();
if (appState.global.isPlaying) {
stopPlayback();
} else {
appState.global.currentStep = 0;
startPlayback();
}
}

View File

@ -0,0 +1,97 @@
// js/pattern_state.js
import { DEFAULT_VOLUME, DEFAULT_PAN } from "../config.js";
import { getAudioContext, getMainGainNode } from "../audio.js";
import { renderPatternEditor } from "./pattern_ui.js";
import { getTotalSteps } from "../utils.js";
const initialState = {
tracks: [],
activeTrackId: null,
activePatternIndex: 0,
};
export let patternState = { ...initialState };
export function initializePatternState() {
Object.assign(patternState, initialState, { tracks: [] });
}
// --- FUNÇÃO CORRIGIDA ---
// Agora, esta função cria e pré-carrega um Tone.Player para a faixa.
export async function loadAudioForTrack(track) {
if (!track.samplePath) return track;
try {
// Se já existir um player antigo, o descartamos para liberar memória.
if (track.player) {
track.player.dispose();
}
// Cria um novo Tone.Player e o conecta à cadeia de áudio da faixa.
// O 'await' garante que o áudio seja totalmente carregado antes de prosseguirmos.
track.player = await new Tone.Player(track.samplePath).toDestination();
track.player.chain(track.gainNode, track.pannerNode, getMainGainNode());
} catch (error) {
console.error(`Falha ao carregar áudio para a trilha ${track.name}:`, error);
track.player = null;
}
return track;
}
export function addTrackToState() {
const mainGainNode = getMainGainNode();
const totalSteps = getTotalSteps();
const referenceTrack = patternState.tracks[0];
const newTrack = {
id: Date.now(),
name: "novo instrumento",
samplePath: null,
player: null, // <-- ADICIONADO: O player começará como nulo
patterns: referenceTrack
? referenceTrack.patterns.map(p => ({ name: p.name, steps: new Array(p.steps.length).fill(false), pos: p.pos }))
: [{ name: "Pattern 1", steps: new Array(totalSteps).fill(false), pos: 0 }],
activePatternIndex: 0,
volume: DEFAULT_VOLUME,
pan: DEFAULT_PAN,
gainNode: new Tone.Gain(Tone.gainToDb(DEFAULT_VOLUME)),
pannerNode: new Tone.Panner(DEFAULT_PAN),
};
newTrack.gainNode.chain(newTrack.pannerNode, mainGainNode);
patternState.tracks.push(newTrack);
renderPatternEditor();
}
export function removeLastTrackFromState() {
if (patternState.tracks.length > 0) {
const trackToRemove = patternState.tracks[patternState.tracks.length - 1];
if (trackToRemove.player) trackToRemove.player.dispose();
if (trackToRemove.pannerNode) trackToRemove.pannerNode.dispose();
if (trackToRemove.gainNode) trackToRemove.gainNode.dispose();
patternState.tracks.pop();
renderPatternEditor();
}
}
export async function updateTrackSample(trackId, samplePath) {
const track = patternState.tracks.find((t) => t.id == trackId);
if (track) {
track.samplePath = samplePath;
track.name = samplePath.split("/").pop();
await loadAudioForTrack(track); // Carrega o novo player
renderPatternEditor();
}
}
export function toggleStepState(trackId, stepIndex) {
const track = patternState.tracks.find((t) => t.id == trackId);
if (track && track.patterns && track.patterns.length > 0) {
const activePattern = track.patterns[track.activePatternIndex];
if (activePattern && activePattern.steps.length > stepIndex) {
activePattern.steps[stepIndex] = !activePattern.steps[stepIndex];
}
}
}

View File

@ -0,0 +1,171 @@
// js/pattern_ui.js
import { appState } from "../state.js";
import {
toggleStepState,
updateTrackSample
} from "./pattern_state.js";
import { playSample, stopPlayback } from "./pattern_audio.js"; // Será criado no próximo passo
import { getTotalSteps } from "../utils.js";
// Função principal de renderização para o editor de patterns
export function renderPatternEditor() {
const trackContainer = document.getElementById("track-container");
trackContainer.innerHTML = "";
appState.pattern.tracks.forEach((trackData) => {
const trackLane = document.createElement("div");
trackLane.className = "track-lane";
trackLane.dataset.trackId = trackData.id;
if (trackData.id === appState.pattern.activeTrackId) {
trackLane.classList.add('active-track');
}
trackLane.innerHTML = `
<div class="track-info">
<i class="fa-solid fa-gear"></i>
<div class="track-mute"></div>
<span class="track-name">${trackData.name}</span>
</div>
<div class="track-controls">
<div class="knob-container">
<div class="knob" data-control="volume" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
<span>VOL</span>
</div>
<div class="knob-container">
<div class="knob" data-control="pan" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
<span>PAN</span>
</div>
</div>
<div class="step-sequencer-wrapper"></div>
`;
trackLane.addEventListener('click', () => {
if (appState.pattern.activeTrackId === trackData.id) return;
stopPlayback();
appState.pattern.activeTrackId = trackData.id;
document.querySelectorAll('.track-lane').forEach(lane => lane.classList.remove('active-track'));
trackLane.classList.add('active-track');
updateGlobalPatternSelector();
redrawSequencer();
});
trackLane.addEventListener("dragover", (e) => { e.preventDefault(); trackLane.classList.add("drag-over"); });
trackLane.addEventListener("dragleave", () => trackLane.classList.remove("drag-over"));
trackLane.addEventListener("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);
// A lógica dos knobs precisará ser reimplementada ou movida para um arquivo de componentes
});
updateGlobalPatternSelector();
redrawSequencer();
}
export function redrawSequencer() {
const totalGridSteps = getTotalSteps();
document.querySelectorAll(".step-sequencer-wrapper").forEach((wrapper) => {
let sequencerContainer = wrapper.querySelector(".step-sequencer");
if (!sequencerContainer) {
sequencerContainer = document.createElement("div");
sequencerContainer.className = "step-sequencer";
wrapper.appendChild(sequencerContainer);
}
const parentTrackElement = wrapper.closest(".track-lane");
const trackId = parentTrackElement.dataset.trackId;
const trackData = appState.pattern.tracks.find((t) => t.id == trackId);
if (!trackData || !trackData.patterns || trackData.patterns.length === 0) {
sequencerContainer.innerHTML = ""; return;
}
const activePattern = trackData.patterns[appState.pattern.activePatternIndex];
if (!activePattern) {
sequencerContainer.innerHTML = ""; return;
}
const patternSteps = activePattern.steps;
sequencerContainer.innerHTML = "";
for (let i = 0; i < totalGridSteps; i++) {
const stepWrapper = document.createElement("div");
stepWrapper.className = "step-wrapper";
const stepElement = document.createElement("div");
stepElement.className = "step";
if (patternSteps[i] === true) {
stepElement.classList.add("active");
}
stepElement.addEventListener("click", () => {
toggleStepState(trackData.id, i);
stepElement.classList.toggle("active");
if (trackData && trackData.samplePath) {
playSample(trackData.samplePath, trackData.id);
}
});
const beatsPerBar = parseInt(document.getElementById("compasso-a-input").value, 10) || 4;
const groupIndex = Math.floor(i / beatsPerBar);
if (groupIndex % 2 === 0) {
stepElement.classList.add("step-dark");
}
const stepsPerBar = 16;
if (i > 0 && i % stepsPerBar === 0) {
const marker = document.createElement("div");
marker.className = "step-marker";
marker.textContent = Math.floor(i / stepsPerBar) + 1;
stepWrapper.appendChild(marker);
}
stepWrapper.appendChild(stepElement);
sequencerContainer.appendChild(stepWrapper);
}
});
}
export function updateGlobalPatternSelector() {
const globalPatternSelector = document.getElementById('global-pattern-selector');
if (!globalPatternSelector) return;
const referenceTrack = appState.pattern.tracks[0];
globalPatternSelector.innerHTML = '';
if (referenceTrack && referenceTrack.patterns.length > 0) {
referenceTrack.patterns.forEach((pattern, index) => {
const option = document.createElement('option');
option.value = index;
option.textContent = pattern.name;
globalPatternSelector.appendChild(option);
});
globalPatternSelector.selectedIndex = appState.pattern.activePatternIndex;
globalPatternSelector.disabled = false;
} else {
const option = document.createElement('option');
option.textContent = 'Sem patterns';
globalPatternSelector.appendChild(option);
globalPatternSelector.disabled = true;
}
}
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);
}
}
});
}

View File

@ -1,25 +1,13 @@
// js/state.js
import { patternState, initializePatternState } from './pattern/pattern_state.js';
import { audioState, initializeAudioState } from './audio/audio_state.js';
import { DEFAULT_VOLUME, DEFAULT_PAN } from "./config.js";
import {
initializeAudioContext,
getAudioContext,
getMainGainNode,
} from "./audio.js";
import { renderApp, renderAudioEditor } from "./ui.js";
import { getTotalSteps } from "./utils.js";
export let appState = {
tracks: [],
audioTracks: [],
activeTrackId: null,
activePatternIndex: 0,
// Estado global da aplicação
const globalState = {
sliceToolActive: false,
isPlaying: false,
isAudioEditorPlaying: false,
activeAudioSources: [],
audioEditorStartTime: 0,
audioEditorAnimationId: null,
audioEditorPlaybackTime: 0,
isAudioEditorLoopEnabled: false, // <-- ADICIONADO: Estado para controlar o loop
playbackIntervalId: null,
currentStep: 0,
metronomeEnabled: false,
@ -27,148 +15,40 @@ export let appState = {
currentBeatBasslineName: 'Novo Projeto',
masterVolume: DEFAULT_VOLUME,
masterPan: DEFAULT_PAN,
zoomLevelIndex: 2,
// --- ADICIONADO PARA A ÁREA DE LOOP ---
isLoopActive: false, // O botão de loop principal agora controla este estado
loopStartTime: 0, // Início do loop em segundos
loopEndTime: 8, // Fim do loop em segundos (padrão de 4 compassos a 120BPM)
};
export async function loadAudioForTrack(track) {
if (!track.samplePath) return track;
try {
const audioContext = getAudioContext();
if (!audioContext) initializeAudioContext();
const response = await fetch(track.samplePath);
if (!response.ok) throw new Error(`Erro ao buscar o sample: ${response.statusText}`);
const arrayBuffer = await response.arrayBuffer();
track.audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
} catch (error) {
console.error(`Falha ao carregar áudio para a trilha ${track.name}:`, error);
track.audioBuffer = null;
}
return track;
}
// Combina todos os estados em um único objeto namespaced
export let appState = {
global: globalState,
pattern: patternState,
audio: audioState,
};
export function addAudioTrack(samplePath) {
initializeAudioContext();
const audioContext = getAudioContext();
const mainGainNode = getMainGainNode();
// Função para resetar o projeto para o estado inicial
export function resetProjectState() {
initializePatternState();
initializeAudioState();
const newAudioTrack = {
id: Date.now() + Math.random(),
name: samplePath.split('/').pop(),
samplePath: samplePath,
audioBuffer: null,
volume: DEFAULT_VOLUME,
pan: DEFAULT_PAN,
isMuted: false,
isSoloed: false, // <-- ADICIONADO: Começa como não-solada
gainNode: audioContext.createGain(),
pannerNode: audioContext.createStereoPanner(),
};
newAudioTrack.gainNode.connect(newAudioTrack.pannerNode);
newAudioTrack.pannerNode.connect(mainGainNode);
newAudioTrack.gainNode.gain.value = newAudioTrack.volume;
newAudioTrack.pannerNode.pan.value = newAudioTrack.pan;
appState.audioTracks.push(newAudioTrack);
loadAudioForTrack(newAudioTrack).then(() => {
renderAudioEditor();
Object.assign(globalState, {
sliceToolActive: false,
isPlaying: false,
isAudioEditorPlaying: false,
playbackIntervalId: null,
currentStep: 0,
metronomeEnabled: false,
originalXmlDoc: null,
currentBeatBasslineName: 'Novo Projeto',
masterVolume: DEFAULT_VOLUME,
masterPan: DEFAULT_PAN,
zoomLevelIndex: 2,
isLoopActive: false,
loopStartTime: 0,
loopEndTime: 8,
});
}
// A função de mute agora será a de solo.
export function toggleAudioTrackSolo(trackId) {
const track = appState.audioTracks.find(t => t.id == trackId);
if (track) {
track.isSoloed = !track.isSoloed;
renderAudioEditor(); // Re-renderiza para mostrar a nova cor
}
}
// Mantemos a função de mute caso precise no futuro, mas ela não está conectada ao botão.
export function toggleAudioTrackMute(trackId) {
const track = appState.audioTracks.find(t => t.id == trackId);
if (track) {
track.isMuted = !track.isMuted;
renderAudioEditor();
}
}
export function addTrackToState() {
initializeAudioContext();
const audioContext = getAudioContext();
const mainGainNode = getMainGainNode();
const totalSteps = getTotalSteps();
const referenceTrack = appState.tracks[0];
const newTrack = {
id: Date.now(),
name: "novo instrumento",
samplePath: null,
audioBuffer: null,
patterns: referenceTrack
? referenceTrack.patterns.map(p => ({ name: p.name, steps: new Array(p.steps.length).fill(false), pos: p.pos }))
: [{ name: "Pattern 1", steps: new Array(totalSteps).fill(false), pos: 0 }],
activePatternIndex: 0,
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() {
if (appState.tracks.length > 0) {
appState.tracks.pop();
renderApp();
}
}
export async function updateTrackSample(trackId, samplePath) {
const track = appState.tracks.find((t) => t.id == trackId);
if (track) {
track.samplePath = samplePath;
track.name = samplePath.split("/").pop();
track.audioBuffer = null;
await loadAudioForTrack(track);
renderApp();
}
}
export function toggleStepState(trackId, stepIndex) {
const track = appState.tracks.find((t) => t.id == trackId);
if (track && track.patterns && track.patterns.length > 0) {
const activePattern = track.patterns[track.activePatternIndex];
if (activePattern && activePattern.steps.length > stepIndex) {
activePattern.steps[stepIndex] = !activePattern.steps[stepIndex];
}
}
}
export function updateTrackVolume(trackId, volume) {
const track = appState.tracks.find((t) => t.id == trackId) || appState.audioTracks.find((t) => t.id == trackId);
if (track) {
const clampedVolume = Math.max(0, Math.min(1.5, volume));
track.volume = clampedVolume;
if (track.gainNode) {
track.gainNode.gain.setValueAtTime(clampedVolume, getAudioContext().currentTime);
}
}
}
export function updateTrackPan(trackId, pan) {
const track = appState.tracks.find((t) => t.id == trackId) || appState.audioTracks.find((t) => t.id == trackId);
if (track) {
const clampedPan = Math.max(-1, Math.min(1, pan));
track.pan = clampedPan;
if (track.pannerNode) {
track.pannerNode.pan.setValueAtTime(clampedPan, getAudioContext().currentTime);
}
}
}

View File

@ -1,110 +1,22 @@
// js/ui.js
import {
appState,
toggleStepState,
updateTrackSample,
updateTrackVolume,
updateTrackPan,
addAudioTrack,
toggleAudioTrackSolo,
} from "./state.js";
import { playSample, stopPlayback, seekAudioEditor } from "./audio.js";
import { getTotalSteps } from "./utils.js";
import { playSample } from "./pattern/pattern_audio.js";
import { renderPatternEditor } from "./pattern/pattern_ui.js";
import { renderAudioEditor } from "./audio/audio_ui.js";
import { loadProjectFromServer } from "./file.js";
import { drawWaveform } from "./waveform.js";
import { PIXELS_PER_STEP, PIXELS_PER_BAR } from "./config.js";
export function updateAudioEditorUI() {
const playBtn = document.getElementById('audio-editor-play-btn');
if (playBtn) {
if (appState.isAudioEditorPlaying) {
playBtn.classList.remove('fa-play');
playBtn.classList.add('fa-pause');
} else {
playBtn.classList.remove('fa-pause');
playBtn.classList.add('fa-play');
}
}
}
let samplePathMap = {};
const globalPatternSelector = document.getElementById('global-pattern-selector');
if (globalPatternSelector) {
globalPatternSelector.addEventListener('change', () => {
stopPlayback();
appState.activePatternIndex = parseInt(globalPatternSelector.value, 10);
export function renderAll() {
renderPatternEditor();
renderAudioEditor();
const loopArea = document.getElementById("loop-region");
const firstTrack = appState.tracks[0];
if (firstTrack) {
const activePattern = firstTrack.patterns[appState.activePatternIndex];
if (activePattern) {
const stepsPerBar = 16;
const requiredBars = Math.ceil(activePattern.steps.length / stepsPerBar);
document.getElementById("bars-input").value = requiredBars > 0 ? requiredBars : 1;
}
}
redrawSequencer();
});
}
export function updateGlobalPatternSelector() {
if (!globalPatternSelector) return;
const referenceTrack = appState.tracks[0];
globalPatternSelector.innerHTML = '';
if (referenceTrack && referenceTrack.patterns.length > 0) {
referenceTrack.patterns.forEach((pattern, index) => {
const option = document.createElement('option');
option.value = index;
option.textContent = pattern.name;
globalPatternSelector.appendChild(option);
});
globalPatternSelector.selectedIndex = appState.activePatternIndex;
globalPatternSelector.disabled = false;
} else {
const option = document.createElement('option');
option.textContent = 'Sem patterns';
globalPatternSelector.appendChild(option);
globalPatternSelector.disabled = true;
if (loopArea) {
// Sincroniza a visibilidade da área de loop com o estado atual
loopArea.classList.toggle("visible", appState.global.isLoopActive);
}
}
export function handleSampleUpload(file) {
const validExtensions = ['.wav', '.flac', '.ogg', '.mp3'];
const fileExtension = '.' + file.name.split('.').pop().toLowerCase();
if (!validExtensions.includes(fileExtension)) {
alert("Formato de arquivo inválido. Por favor, envie .wav, .flac, .ogg, ou .mp3.");
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const dataURL = e.target.result;
const browserContent = document.getElementById("browser-content");
const list = browserContent.querySelector("ul");
if (list) {
const li = document.createElement("li");
li.innerHTML = `<i class="fa-solid fa-file-audio"></i> ${file.name}`;
li.setAttribute("draggable", true);
li.addEventListener("click", (event) => {
event.stopPropagation();
playSample(dataURL, null);
});
li.addEventListener("dragstart", (event) => {
event.dataTransfer.setData("text/plain", dataURL);
event.dataTransfer.effectAllowed = "copy";
});
list.prepend(li);
}
};
reader.readAsDataURL(file);
}
export function getSamplePathMap() {
return samplePathMap;
}
@ -122,383 +34,11 @@ function buildSamplePathMap(tree, currentPath) {
}
}
export function renderAudioEditor() {
const audioEditor = document.querySelector('.audio-editor');
const audioTrackContainer = document.getElementById('audio-track-container');
if (!audioEditor || !audioTrackContainer) return;
audioEditor.ondragover = (e) => {
e.preventDefault();
audioEditor.classList.add("drag-over");
};
audioEditor.ondragleave = () => {
audioEditor.classList.remove("drag-over");
};
audioEditor.ondrop = (e) => {
e.preventDefault();
audioEditor.classList.remove("drag-over");
const filePath = e.dataTransfer.getData("text/plain");
if (filePath) {
addAudioTrack(filePath);
}
};
audioTrackContainer.innerHTML = '';
appState.audioTracks.forEach(trackData => {
const audioTrackLane = document.createElement('div');
audioTrackLane.className = 'audio-track-lane';
audioTrackLane.dataset.trackId = trackData.id;
audioTrackLane.innerHTML = `
<div class="track-info">
<i class="fa-solid fa-gear"></i>
<div class="track-solo-btn"></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="spectrogram-view-wrapper">
<div class="spectrogram-view-grid">
<div class="playhead"></div>
</div>
</div>
`;
const grid = audioTrackLane.querySelector('.spectrogram-view-grid');
const canvas = document.createElement('canvas');
canvas.className = 'waveform-canvas';
canvas.height = 60;
grid.prepend(canvas);
audioTrackContainer.appendChild(audioTrackLane);
if (trackData.audioBuffer) {
const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120;
const sampleDuration = trackData.audioBuffer.duration;
const stepsPerSecond = (bpm / 60) * 4;
const totalSteps = sampleDuration * stepsPerSecond;
const canvasWidth = totalSteps * PIXELS_PER_STEP;
canvas.width = canvasWidth;
drawWaveform(canvas, trackData.audioBuffer, 'var(--accent-green)');
const numberOfBars = Math.ceil(canvasWidth / PIXELS_PER_BAR);
for (let i = 0; i < numberOfBars; i++) {
if (i === 0) continue;
const marker = document.createElement('div');
marker.className = 'bar-marker';
marker.textContent = i + 1;
marker.style.left = `${i * PIXELS_PER_BAR}px`;
grid.appendChild(marker);
}
}
const soloButton = audioTrackLane.querySelector('.track-solo-btn');
if (soloButton) {
if (trackData.isSoloed) {
soloButton.classList.add('active');
}
soloButton.addEventListener('click', (e) => {
e.stopPropagation();
toggleAudioTrackSolo(trackData.id);
});
}
const volumeKnob = audioTrackLane.querySelector('.knob[data-control="volume"]');
addKnobInteraction(volumeKnob);
updateKnobVisual(volumeKnob, "volume");
const panKnob = audioTrackLane.querySelector('.knob[data-control="pan"]');
addKnobInteraction(panKnob);
updateKnobVisual(panKnob, "pan");
const waveformWrapper = audioTrackLane.querySelector('.spectrogram-view-wrapper');
const handleSeek = (event) => {
const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120;
const stepsPerSecond = (bpm / 60) * 4;
const pixelsPerSecond = stepsPerSecond * PIXELS_PER_STEP;
const rect = waveformWrapper.getBoundingClientRect();
const clickX = event.clientX - rect.left;
const scrollLeft = waveformWrapper.scrollLeft;
const absoluteX = clickX + scrollLeft;
const newTime = absoluteX / pixelsPerSecond;
seekAudioEditor(newTime);
};
waveformWrapper.addEventListener('mousedown', (e) => {
e.preventDefault();
handleSeek(e);
const onMouseMove = (moveEvent) => handleSeek(moveEvent);
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
});
}
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;
if (trackData.id === appState.activeTrackId) {
trackLane.classList.add('active-track');
}
trackLane.innerHTML = `
<div class="track-info">
<i class="fa-solid fa-gear"></i>
<div class="track-mute"></div>
<span class="track-name">${trackData.name}</span>
</div>
<div class="track-controls">
<div class="knob-container">
<div class="knob" data-control="volume" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
<span>VOL</span>
</div>
<div class="knob-container">
<div class="knob" data-control="pan" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
<span>PAN</span>
</div>
</div>
<div class="step-sequencer-wrapper"></div>
`;
trackLane.addEventListener('click', () => {
if (appState.activeTrackId === trackData.id) return;
stopPlayback();
appState.activeTrackId = trackData.id;
document.querySelectorAll('.track-lane').forEach(lane => lane.classList.remove('active-track'));
trackLane.classList.add('active-track');
updateGlobalPatternSelector();
redrawSequencer();
});
trackLane.addEventListener("dragover", (e) => { e.preventDefault(); trackLane.classList.add("drag-over"); });
trackLane.addEventListener("dragleave", () => trackLane.classList.remove("drag-over"));
trackLane.addEventListener("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");
});
updateGlobalPatternSelector();
redrawSequencer();
renderAudioEditor();
}
export function redrawSequencer() {
const totalGridSteps = getTotalSteps();
document.querySelectorAll(".step-sequencer-wrapper").forEach((wrapper) => {
let sequencerContainer = wrapper.querySelector(".step-sequencer");
if (!sequencerContainer) {
sequencerContainer = document.createElement("div");
sequencerContainer.className = "step-sequencer";
wrapper.appendChild(sequencerContainer);
}
const parentTrackElement = wrapper.closest(".track-lane");
const trackId = parentTrackElement.dataset.trackId;
const trackData = appState.tracks.find((t) => t.id == trackId);
if (!trackData || !trackData.patterns || trackData.patterns.length === 0) {
sequencerContainer.innerHTML = ""; return;
}
const activePattern = trackData.patterns[appState.activePatternIndex];
if (!activePattern) {
sequencerContainer.innerHTML = ""; return;
}
const patternSteps = activePattern.steps;
sequencerContainer.innerHTML = "";
for (let i = 0; i < totalGridSteps; i++) {
const stepWrapper = document.createElement("div");
stepWrapper.className = "step-wrapper";
const stepElement = document.createElement("div");
stepElement.className = "step";
if (patternSteps[i] === true) {
stepElement.classList.add("active");
}
stepElement.addEventListener("click", () => {
toggleStepState(trackData.id, i);
stepElement.classList.toggle("active");
if (trackData && trackData.samplePath) {
playSample(trackData.samplePath, trackData.id);
}
});
const beatsPerBar = parseInt(document.getElementById("compasso-a-input").value, 10) || 4;
const groupIndex = Math.floor(i / beatsPerBar);
if (groupIndex % 2 === 0) {
stepElement.classList.add("step-dark");
}
const stepsPerBar = 16;
if (i > 0 && i % stepsPerBar === 0) {
const marker = document.createElement("div");
marker.className = "step-marker";
marker.textContent = Math.floor(i / stepsPerBar) + 1;
stepWrapper.appendChild(marker);
}
stepWrapper.appendChild(stepElement);
sequencerContainer.appendChild(stepWrapper);
}
});
}
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);
}
updateKnobVisual(knobElement, controlType);
}
});
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) || appState.audioTracks.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);
}
updateKnobVisual(knobElement, controlType);
}
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) || appState.audioTracks.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);
}
updateKnobVisual(knobElement, controlType);
});
}
function updateKnobVisual(knobElement, controlType) {
const trackId = knobElement.dataset.trackId;
const track = appState.tracks.find((t) => t.id == trackId) || appState.audioTracks.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);
}
}
});
}
export async function loadAndRenderSampleBrowser() {
const browserContent = document.getElementById("browser-content");
try {
const response = await fetch(`metadata/samples-manifest.json?v=${Date.now()}`);
if (!response.ok) {
throw new Error("Arquivo samples-manifest.json não encontrado.");
}
if (!response.ok) throw new Error("Arquivo samples-manifest.json não encontrado.");
const fileTree = await response.json();
samplePathMap = {};
@ -511,42 +51,65 @@ export async function loadAndRenderSampleBrowser() {
}
}
// Em ui.js, substitua a função antiga por esta:
function renderFileTree(tree, parentElement, currentPath) {
parentElement.innerHTML = "";
parentElement.innerHTML = ""; // Limpa o conteúdo anterior
const ul = document.createElement("ul");
// Ordena para que as pastas sempre apareçam antes dos arquivos
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;
if (aIsFile === bIsFile) return a.localeCompare(b); // Ordena alfabeticamente se ambos forem do mesmo tipo
return aIsFile ? 1 : -1; // Pastas (-1) vêm antes de arquivos (1)
});
for (const key of sortedKeys) {
if (key === '_isFile') continue;
if (key === '_isFile') continue; // Pula a propriedade de metadados
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}`;
// --- LÓGICA PARA ARQUIVOS ---
li.className = "file-item draggable-sample"; // CORREÇÃO: Adiciona classe para consistência
li.innerHTML = `<i class="fa-solid fa-volume-high"></i> ${key}`; // Ícone mais apropriado
li.setAttribute("draggable", true);
li.dataset.path = newPath; // Guarda o caminho para o drag-and-drop
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}`;
// --- LÓGICA CORRIGIDA PARA PASTAS ---
li.className = "folder-item"; // CORREÇÃO 1: Usa a classe CSS correta
// CORREÇÃO 2: Cria o <span> clicável para o nome da pasta, que o CSS e o main.js esperam
const folderNameSpan = document.createElement("span");
folderNameSpan.className = "folder-name";
folderNameSpan.innerHTML = `<i class="folder-icon fa-solid fa-folder"></i> ${key}`;
li.appendChild(folderNameSpan);
const nestedUl = document.createElement("ul");
nestedUl.className = "file-list"; // CORREÇÃO: Adiciona classe para o CSS
// Chama a função recursivamente para os conteúdos da pasta
renderFileTree(node, nestedUl, newPath);
li.appendChild(nestedUl);
li.addEventListener("click", (e) => {
e.stopPropagation();
li.classList.toggle("open");
});
// CORREÇÃO 3: Remove o addEventListener de clique daqui. O main.js já cuida disso.
ul.appendChild(li);
}
}
@ -560,14 +123,12 @@ export async function showOpenProjectModal() {
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.");
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>';
serverProjectsList.innerHTML = '<p style="color:var(--text-dark);">Nenhum projeto encontrado no servidor.</p>';
}
projects.forEach((projectName) => {
@ -592,15 +153,3 @@ export function closeOpenProjectModal() {
const openProjectModal = document.getElementById("open-project-modal");
openProjectModal.classList.remove("visible");
}
export function updatePlayheadVisual(pixels) {
document.querySelectorAll('.audio-track-lane .playhead').forEach(ph => {
ph.style.left = `${pixels}px`;
});
}
export function resetPlayheadVisual() {
document.querySelectorAll('.audio-track-lane .playhead').forEach(ph => {
ph.style.left = '0px';
});
}

View File

@ -1,5 +1,23 @@
// js/utils.js
import { appState } from './state.js';
import { PIXELS_PER_STEP, ZOOM_LEVELS } from './config.js';
/**
* Calcula a quantidade de pixels que representa um segundo na timeline,
* levando em conta o BPM e o nível de zoom atual.
* @returns {number} A quantidade de pixels por segundo.
*/
export function getPixelsPerSecond() {
const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120;
const stepsPerSecond = (bpm / 60) * 4;
const zoomFactor = ZOOM_LEVELS[appState.global.zoomLevelIndex];
return stepsPerSecond * PIXELS_PER_STEP * zoomFactor;
}
/**
* Calcula o número total de steps no sequenciador de patterns.
* @returns {number} O número total de steps.
*/
export function getTotalSteps() {
const barsInput = document.getElementById("bars-input");
const compassoAInput = document.getElementById("compasso-a-input");
@ -13,19 +31,29 @@ export function getTotalSteps() {
return numberOfBars * beatsPerBar * subdivisions;
}
/**
* Garante que apenas números sejam inseridos em um campo de input.
* @param {Event} event - O evento de input.
*/
export function enforceNumericInput(event) {
event.target.value = event.target.value.replace(/[^0-9]/g, "");
}
/**
* Ajusta o valor de um elemento de input com base em um passo (step),
* respeitando os limites de min/max definidos no elemento.
* @param {HTMLInputElement} inputElement - O elemento de input a ser ajustado.
* @param {number} step - O valor a ser adicionado (pode ser negativo).
*/
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.value = newValue;
inputElement.dispatchEvent(new Event("input", { bubbles: true }));
}

View File

@ -2,44 +2,61 @@
/**
* Desenha a forma de onda de um AudioBuffer em um elemento Canvas.
* Pode desenhar apenas um segmento específico do buffer.
* @param {HTMLCanvasElement} canvas - O elemento canvas onde o desenho será feito.
* @param {AudioBuffer} audioBuffer - O buffer de áudio decodificado da faixa.
* @param {string} color - A cor da forma de onda (ex: '#2ecc71').
* @param {number} [offset=0] - O tempo em segundos de onde começar a desenhar no AudioBuffer.
* @param {number} [duration] - A duração em segundos do segmento a ser desenhado.
*/
export function drawWaveform(canvas, audioBuffer, color) {
export function drawWaveform(canvas, audioBuffer, color, offset = 0, duration) {
if (!canvas || !audioBuffer) return;
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
const channelData = audioBuffer.getChannelData(0); // Pega os dados do primeiro canal
const step = Math.ceil(channelData.length / width);
const amp = height / 2; // Amplitude máxima do desenho
const channelData = audioBuffer.getChannelData(0);
const sampleRate = audioBuffer.sampleRate;
ctx.clearRect(0, 0, width, height); // Limpa o canvas
// Se a duração não for fornecida, usa a duração total a partir do offset
const finalDuration = duration || (audioBuffer.duration - offset);
// Calcula os índices de início e fim no array de dados do áudio
const startIndex = Math.floor(offset * sampleRate);
const endIndex = Math.floor((offset + finalDuration) * sampleRate);
const totalSamplesInSegment = endIndex - startIndex;
const step = Math.ceil(totalSamplesInSegment / width);
const amp = height / 2;
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.lineWidth = 1;
ctx.beginPath();
// Desenha a linha do meio (zero amplitude)
ctx.moveTo(0, amp);
ctx.lineTo(width, amp);
for (let i = 0; i < width; i++) {
let min = 1.0;
let max = -1.0;
// Encontra o valor mínimo e máximo para um bloco de amostras
for (let j = 0; j < step; j++) {
const datum = channelData[(i * step) + j];
if (datum < min) {
min = datum;
}
if (datum > max) {
max = datum;
// --- CORREÇÃO CRÍTICA AQUI ---
// Calcula o índice da amostra considerando o startIndex do segmento
const sampleIndex = startIndex + (i * step) + j;
if (sampleIndex < channelData.length) {
const datum = channelData[sampleIndex];
if (datum < min) min = datum;
if (datum > max) max = datum;
}
}
// Desenha a linha vertical para aquele ponto no tempo
const x = i;
const y_max = (1 + max) * amp;
const y_min = (1 + min) * amp;
// Ajusta o desenho para ser centrado verticalmente
const y_max = (1 - max) * amp;
const y_min = (1 - min) * amp;
ctx.moveTo(x, y_max);
ctx.lineTo(x, y_min);

View File

@ -123,42 +123,72 @@
<span>Editor de Amostras de Áudio</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>
<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>
<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">
<i class="fa-solid fa-gear"></i>
<div class="track-mute"></div>
<span class="track-name">bassslap02.ogg</span>
</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="spectrogram-view-wrapper">
<div class="spectrogram-view-grid"></div>
</div>
<div class="spectrogram-view-wrapper">
<canvas id="spectrogram-canvas-1" width="800" height="100"></canvas>
</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 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="spectrogram-view-grid" style="width: 4000px;"> <div class="timeline-clip" style="left: 100px; width: 400px;"></div>
<div class="playhead"></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>
</main>
</div>
<input type="file" id="mmp-file-input" accept=".mmp, .mmpz" style="display: none"/>
@ -181,8 +211,15 @@
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<div id="timeline-context-menu">
<div id="set-loop-start">Definir Início do Loop</div>
<div id="set-loop-end">Definir Fim do Loop</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.7.77/Tone.js"></script>
<script src="assets/js/creations/main.js" type="module"></script>
</body>
</html>