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 @@
/* =============================================== */ /* MMPCreator - Folha de Estilos Principal */
/* VÁRIAVEIS GLOBAIS (ROOT)
/* =============================================== */
:root { :root {
--bg-body: #2d3035; --bg-body: #2d3035;
--bg-toolbar: #3b3f45; --bg-toolbar: #3b3f45;
@ -17,90 +15,115 @@
} }
/* =============================================== */ /* =============================================== */
/* ESTILOS GLOBAIS E LAYOUT PRINCIPAL (CORRIGIDO) /* LAYOUT E ESTRUTURA GLOBAL
/* =============================================== */ /* =============================================== */
body { body {
margin: 0; 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); background-color: var(--bg-body);
color: var(--text-light); 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; height: 100vh;
display: flex; /* Usamos flex no body para o main-content crescer */ overflow: hidden;
flex-direction: column; display: flex;
} }
body.sidebar-hidden { body.knob-dragging { cursor: ns-resize; }
padding-left: 0; body.slice-tool-active .timeline-container { cursor: crosshair; }
}
body.knob-dragging { .app-container {
cursor: ns-resize;
}
.main-content {
padding: 1rem;
flex-grow: 1; /* Faz o conteúdo principal ocupar o espaço restante */
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; flex-grow: 1;
overflow: hidden; /* Evita que o conteúdo transborde */ overflow: hidden;
height: 100%; /* Garante que o flexbox interno funcione */ position: relative;
} }
/* =============================================== */ /* =============================================== */
/* BARRA LATERAL (SAMPLE BROWSER) /* BARRA LATERAL (SAMPLE BROWSER)
/* =============================================== */ /* =============================================== */
.sample-browser { .sample-browser {
position: fixed;
top: 0;
left: 0;
width: 300px; width: 300px;
height: 100vh;
background-color: var(--bg-toolbar); background-color: var(--bg-toolbar);
border-right: 2px solid var(--border-color); border-right: 2px solid var(--border-color);
z-index: 1500; z-index: 10;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
transform: translateX(0); transition: min-width 0.3s ease, width 0.3s ease;
transition: transform .3s ease; flex-shrink: 0;
} }
body.sidebar-hidden .sample-browser { body.sidebar-hidden .sample-browser {
transform: translateX(-100%); width: 0;
min-width: 0;
border-right: none;
overflow: hidden;
} }
.browser-header { .browser-header {
padding: 15px; padding: 15px;
background-color: #2a2c30; background-color: #2a2c30;
border-bottom: 2px solid var(--border-color); border-bottom: 2px solid var(--border-color);
text-align: center; text-align: center;
font-weight: bold; font-weight: bold;
color: var(--text-light); white-space: nowrap;
} }
.browser-content { .browser-content {
flex-grow: 1; flex-grow: 1;
overflow-y: auto; overflow-y: auto;
padding: 10px; padding: 10px;
} }
.browser-content ul {
.browser-content ul { list-style: none; padding-left: 15px; } padding-left: 15px;
.browser-content li { padding: 5px; cursor: pointer; border-radius: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; user-select: none; } list-style: 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; } .folder-item > .file-list {
.browser-content li.directory > ul { display: none; } display: none;
.browser-content li.directory.open > ul { display: block; } padding-left: 20px;
.browser-content li.directory.open > .fa-folder { transform: rotate(90deg); } }
.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 { #sidebar-toggle {
position: fixed; position: absolute;
top: 60px; top: 60px;
left: 305px; left: 5px;
z-index: 1400; z-index: 1400;
background-color: var(--bg-toolbar); background-color: var(--bg-toolbar);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@ -108,49 +131,19 @@ body.sidebar-hidden .sample-browser {
width: 25px; width: 25px;
height: 40px; height: 40px;
cursor: pointer; cursor: pointer;
border-top-right-radius: 4px; border-radius: 0 4px 4px 0;
border-bottom-right-radius: 4px;
transition: left .3s ease;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
body.sidebar-hidden #sidebar-toggle {
left: 5px;
}
/* =============================================== */ /* =============================================== */
/* BARRA DE FERRAMENTAS GLOBAL /* ÁREA DE CONTEÚDO E TOOLBARS
/* =============================================== */ /* =============================================== */
.global-toolbar { .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; }
padding: 8px 15px; .main-content { flex-grow: 1; padding: 1rem; display: flex; flex-direction: column; gap: 1rem; overflow: hidden; }
position: fixed; .beat-editor {
top: 0; flex: 1;
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%;
background-color: var(--bg-editor); background-color: var(--bg-editor);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
@ -159,178 +152,43 @@ body.sidebar-hidden .global-toolbar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.editor-header { background-color: var(--bg-toolbar); padding: 4px 10px; font-size: .8rem; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color); flex-shrink: 0; }
/* Container para as faixas de áudio, com scroll vertical */ .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; }
#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;
}
/* =============================================== */ /* =============================================== */
/* ESTILOS DO EDITOR DE ÁUDIO (MARCADORES) /* EDITOR DE BASES (BEAT EDITOR / STEP SEQUENCER)
/* =============================================== */
/* 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
/* =============================================== */ /* =============================================== */
#track-container { #track-container {
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
flex-grow: 1; flex-grow: 1;
} }
.track-lane { .track-lane {
display: flex; display: flex;
align-items: center; align-items: stretch;
padding: 8px 10px;
background-color: var(--bg-editor); background-color: var(--bg-editor);
border-bottom: 1px solid var(--bg-toolbar); border-bottom: 1px solid var(--bg-toolbar);
border-left: 2px solid transparent; min-height: 72px;
border-right: 2px solid transparent; box-sizing: border-box;
transition: border-color 0.2s, background-color 0.2s;
} }
.track-lane.active-track { background-color: #40454d; }
.track-lane.active-track { .track-lane.drag-over { border: 2px dashed var(--accent-green); }
background-color: #40454d; .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 { 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 { .track-mute:hover { opacity: 0.8; }
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; }
.track-mute.active { .step-sequencer-wrapper { flex-grow: 1; overflow-x: auto; overflow-y: hidden; padding-bottom: 8px; display: flex; align-items: center; }
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; }
.step-sequencer { display: flex; gap: 4px; } .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-wrapper { position: relative; }
.step-marker { position: absolute; top: -16px; left: 1px; font-size: .6rem; color: var(--text-dark); user-select: none; } .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; } .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.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); } .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; } .interactive-input-container { display: flex; align-items: center; justify-content: center; gap: 4px; }
.compasso-group { display: flex; align-items: 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; } .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: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); } #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 { 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-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-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 { 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-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-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); } .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 { 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; } #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); } #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 { 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; } .modal-button:hover { background-color: #4a4f57; border-color: #333; }
#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; }
.file-menu-container { position: relative; } #timeline-context-menu div { padding: 8px 15px; cursor: pointer; white-space: nowrap; }
.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; } #timeline-context-menu div:hover { background-color: var(--accent-blue); color: white; }
.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; }
/* =============================================== */ /* =============================================== */
/* ESTILOS RESPONSIVOS (MELHORADO) /* ESTILOS RESPONSIVOS
/* =============================================== */ /* =============================================== */
@media (max-width: 1200px) { @media (max-width: 1200px) {
.info-display-group { .info-display-group { gap: 2px; }
gap: 2px; .info-display { padding: 4px 6px; }
} .value-input { font-size: 1.2rem; width: 45px; }
.info-display { .compasso-input { width: 20px; }
padding: 4px 6px;
}
.value-input {
font-size: 1.2rem;
width: 45px;
}
.compasso-input {
width: 20px;
}
} }
@media (max-width: 992px) { @media (max-width: 992px) {
.global-toolbar { .global-toolbar { gap: 10px; flex-wrap: wrap; height: auto; padding-bottom: 10px; }
gap: 10px; .main-content { padding-top: 100px; }
flex-wrap: wrap; .info-display-group { order: 3; width: 100%; justify-content: space-around; }
height: auto; /* Permite que a toolbar cresça se o conteúdo quebrar linha */ .spacer { display: none; }
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;
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
body { .sample-browser { transform: translateX(-100%); position: fixed; width: 280px; }
padding-left: 0 !important; body:not(.sidebar-hidden) .sample-browser { transform: translateX(0); }
} #sidebar-toggle { left: 5px; transform: translateX(0); position: fixed; }
.sample-browser { .global-toolbar { padding-left: 45px; }
transform: translateX(-100%); .main-content { padding: 10px; }
position: fixed; /* Volta a ser fixo para deslizar por cima */ .track-lane, .audio-track-lane { flex-direction: column; align-items: stretch; gap: 15px; padding: 15px; }
width: 280px; .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; }
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%;
}
} }
.spectrogram-view-wrapper { /* =============================================== */
position: relative; /* Essencial para o posicionamento absoluto do filho */ /* SCROLLBARS
overflow: hidden; /* Garante que a agulha não saia dos limites */ /* =============================================== */
} ::-webkit-scrollbar { height: 10px; width: 10px; }
::-webkit-scrollbar-track { background: var(--border-color); }
.playhead { ::-webkit-scrollbar-thumb { background: var(--bg-toolbar); border-radius: 5px; }
position: absolute; ::-webkit-scrollbar-thumb:hover { background: #555; }
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 */
}

View File

@ -1,316 +1,34 @@
// js/audio.js // 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; // O contexto de áudio agora será gerenciado principalmente pelo Tone.js.
let mainGainNode; // Esta função garante que ele seja iniciado por uma interação do usuário.
let masterPannerNode; export function initializeAudioContext() {
if (Tone.context.state !== 'running') {
const timerDisplay = document.getElementById('timer-display'); Tone.start();
console.log("AudioContext iniciado com Tone.js");
}
}
// Funções de acesso ao contexto global do Tone.js
export function getAudioContext() { export function getAudioContext() {
return audioContext; return Tone.context;
} }
export function getMainGainNode() { export function getMainGainNode() {
return mainGainNode; return Tone.Destination;
}
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();
}
} }
// Funções para controlar o volume e pan master
export function updateMasterVolume(volume) { export function updateMasterVolume(volume) {
if (mainGainNode) { // Tone.Destination.volume.value é em decibéis. Convertemos de linear (0-1.5) para dB.
mainGainNode.gain.setValueAtTime(volume, audioContext.currentTime); if (volume === 0) {
Tone.Destination.volume.value = -Infinity;
} else {
Tone.Destination.volume.value = Tone.gainToDb(volume);
} }
} }
export function updateMasterPan(pan) { export function updateMasterPan(pan) {
if (masterPannerNode) { // A panorimização master em Tone.js geralmente requer um nó Panner dedicado.
masterPannerNode.pan.setValueAtTime(pan, audioContext.currentTime); // Por enquanto, esta função servirá como um placeholder para futuras implementações.
} console.log("Master Pan ainda não implementado com Tone.js");
}
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();
}
} }

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 // 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_STEP = 32; // Cada step (1/16) terá 32px de largura
export const PIXELS_PER_BAR = 512; // 16 steps * 32px/step = 512px por compasso (bar) 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 // js/file.js
import { appState, loadAudioForTrack } from "./state.js"; import { appState, resetProjectState } from "./state.js";
import { getTotalSteps } from "./utils.js"; import { loadAudioForTrack } from "./pattern/pattern_state.js";
import { renderApp, getSamplePathMap } from "./ui.js"; import { renderAll, getSamplePathMap } from "./ui.js";
import { DEFAULT_PAN, DEFAULT_VOLUME, NOTE_LENGTH, TICKS_PER_BAR } from "./config.js"; import { DEFAULT_PAN, DEFAULT_VOLUME, NOTE_LENGTH } from "./config.js";
import { import { initializeAudioContext, getAudioContext, getMainGainNode } from "./audio.js";
initializeAudioContext,
getAudioContext,
getMainGainNode,
} from "./audio.js";
export async function handleFileLoad(file) { export async function handleFileLoad(file) {
let xmlContent = ""; 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) { export async function parseMmpContent(xmlString) {
resetProjectState();
initializeAudioContext(); initializeAudioContext();
const parser = new DOMParser(); const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlString, "application/xml"); const xmlDoc = parser.parseFromString(xmlString, "application/xml");
appState.originalXmlDoc = xmlDoc; appState.global.originalXmlDoc = xmlDoc;
let newTracks = []; let newTracks = [];
const head = xmlDoc.querySelector("head"); 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"]')); const allBBTrackNodes = Array.from(xmlDoc.querySelectorAll('song > trackcontainer[type="song"] > track[type="1"]'));
if (allBBTrackNodes.length === 0) { 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 sortedBBTrackNameNodes = [...allBBTrackNodes].sort((a, b) => {
const bbtcoA = a.querySelector('bbtco'); const bbtcoA = a.querySelector('bbtco');
const bbtcoB = b.querySelector('bbtco'); const bbtcoB = b.querySelector('bbtco');
const posA = bbtcoA ? parseInt(bbtcoA.getAttribute('pos'), 10) : Infinity; const posA = bbtcoA ? parseInt(bbtcoA.getAttribute('pos'), 10) : Infinity;
const posB = bbtcoB ? parseInt(bbtcoB.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]; 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'); const bbTrackContainer = dataSourceTrack.querySelector('bbtrack > trackcontainer');
if (!bbTrackContainer) { if (!bbTrackContainer) {
appState.tracks = []; renderApp(); return; appState.pattern.tracks = [];
renderAll();
return;
} }
const instrumentTracks = bbTrackContainer.querySelectorAll('track[type="0"]'); const instrumentTracks = bbTrackContainer.querySelectorAll('track[type="0"]');
@ -84,13 +101,11 @@ export async function parseMmpContent(xmlString) {
} }
const allPatternsNodeList = trackNode.querySelectorAll("pattern"); 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 allPatternsArray = Array.from(allPatternsNodeList).sort((a, b) => {
const posA = parseInt(a.getAttribute('pos'), 10) || 0; const posA = parseInt(a.getAttribute('pos'), 10) || 0;
const posB = parseInt(b.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 patterns = sortedBBTrackNameNodes.map((bbTrack, index) => {
const patternNode = allPatternsArray[index]; const patternNode = allPatternsArray[index];
@ -159,13 +174,15 @@ export async function parseMmpContent(xmlString) {
let isFirstTrackWithNotes = true; let isFirstTrackWithNotes = true;
newTracks.forEach(track => { newTracks.forEach(track => {
const audioContext = getAudioContext(); // --- INÍCIO DA CORREÇÃO ---
track.gainNode = audioContext.createGain(); // Cria os nós de áudio usando os construtores do Tone.js
track.pannerNode = audioContext.createStereoPanner(); 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.gainNode.connect(track.pannerNode);
track.pannerNode.connect(getMainGainNode()); track.pannerNode.connect(getMainGainNode());
track.gainNode.gain.value = track.volume; // --- FIM DA CORREÇÃO ---
track.pannerNode.pan.value = track.pan;
if (isFirstTrackWithNotes) { if (isFirstTrackWithNotes) {
const activeIdx = track.activePatternIndex || 0; 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); console.error("Ocorreu um erro ao carregar os áudios do projeto:", error);
} }
appState.tracks = newTracks; appState.pattern.tracks = newTracks;
appState.activeTrackId = appState.tracks[0]?.id || null; appState.pattern.activeTrackId = appState.pattern.tracks[0]?.id || null;
renderApp(); renderAll();
} }
export function generateMmpFile() { export function generateMmpFile() {
if (appState.originalXmlDoc) { if (appState.global.originalXmlDoc) {
modifyAndSaveExistingMmp(); modifyAndSaveExistingMmp();
} else { } else {
generateNewMmp(); generateNewMmp();
@ -202,11 +219,9 @@ export function generateMmpFile() {
function createTrackXml(track) { function createTrackXml(track) {
if (track.patterns.length === 0) return ""; if (track.patterns.length === 0) return "";
const ticksPerStep = 12; const ticksPerStep = 12;
const lmmsVolume = Math.round(track.volume * 100); const lmmsVolume = Math.round(track.volume * 100);
const lmmsPan = Math.round(track.pan * 100); const lmmsPan = Math.round(track.pan * 100);
const patternsXml = track.patterns.map(pattern => { const patternsXml = track.patterns.map(pattern => {
const patternNotes = pattern.steps.map((isActive, index) => { const patternNotes = pattern.steps.map((isActive, index) => {
if (isActive) { if (isActive) {
@ -215,12 +230,10 @@ function createTrackXml(track) {
} }
return ""; return "";
}).join("\n "); }).join("\n ");
return `<pattern type="0" pos="${pattern.pos}" muted="0" steps="${pattern.steps.length}" name="${pattern.name}"> return `<pattern type="0" pos="${pattern.pos}" muted="0" steps="${pattern.steps.length}" name="${pattern.name}">
${patternNotes} ${patternNotes}
</pattern>`; </pattern>`;
}).join('\n '); }).join('\n ');
return ` return `
<track type="0" solo="0" muted="0" name="${track.name}"> <track type="0" solo="0" muted="0" name="${track.name}">
<instrumenttrack vol="${lmmsVolume}" pitch="0" fxch="0" pitchrange="1" basenote="57" usemasterpitch="1" pan="${lmmsPan}"> <instrumenttrack vol="${lmmsVolume}" pitch="0" fxch="0" pitchrange="1" basenote="57" usemasterpitch="1" pan="${lmmsPan}">
@ -235,39 +248,23 @@ function createTrackXml(track) {
function modifyAndSaveExistingMmp() { function modifyAndSaveExistingMmp() {
console.log("Modificando arquivo .mmp existente..."); console.log("Modificando arquivo .mmp existente...");
const xmlDoc = appState.originalXmlDoc.cloneNode(true); const xmlDoc = appState.global.originalXmlDoc.cloneNode(true);
const head = xmlDoc.querySelector("head"); const head = xmlDoc.querySelector("head");
if (head) { if (head) {
head.setAttribute("bpm", document.getElementById("bpm-input").value); head.setAttribute("bpm", document.getElementById("bpm-input").value);
head.setAttribute("num_bars", document.getElementById("bars-input").value); head.setAttribute("num_bars", document.getElementById("bars-input").value);
head.setAttribute( head.setAttribute("timesig_numerator", document.getElementById("compasso-a-input").value);
"timesig_numerator", head.setAttribute("timesig_denominator", document.getElementById("compasso-b-input").value);
document.getElementById("compasso-a-input").value
);
head.setAttribute(
"timesig_denominator",
document.getElementById("compasso-b-input").value
);
} }
const bbTrackContainer = xmlDoc.querySelector('track[type="1"] > bbtrack > trackcontainer'); const bbTrackContainer = xmlDoc.querySelector('track[type="1"] > bbtrack > trackcontainer');
if (bbTrackContainer) { if (bbTrackContainer) {
bbTrackContainer.querySelectorAll('track[type="0"]').forEach(node => node.remove()); bbTrackContainer.querySelectorAll('track[type="0"]').forEach(node => node.remove());
const tracksXml = appState.pattern.tracks.map((track) => createTrackXml(track)).join("");
const tracksXml = appState.tracks const tempDoc = new DOMParser().parseFromString(`<root>${tracksXml}</root>`, "application/xml");
.map((track) => createTrackXml(track))
.join("");
const tempDoc = new DOMParser().parseFromString(
`<root>${tracksXml}</root>`,
"application/xml"
);
Array.from(tempDoc.documentElement.children).forEach((newTrackNode) => { Array.from(tempDoc.documentElement.children).forEach((newTrackNode) => {
bbTrackContainer.appendChild(newTrackNode); bbTrackContainer.appendChild(newTrackNode);
}); });
} }
const serializer = new XMLSerializer(); const serializer = new XMLSerializer();
const mmpContent = serializer.serializeToString(xmlDoc); const mmpContent = serializer.serializeToString(xmlDoc);
downloadFile(mmpContent, "projeto_editado.mmp"); downloadFile(mmpContent, "projeto_editado.mmp");
@ -278,10 +275,7 @@ function generateNewMmp() {
const sig_num = document.getElementById("compasso-a-input").value; const sig_num = document.getElementById("compasso-a-input").value;
const sig_den = document.getElementById("compasso-b-input").value; const sig_den = document.getElementById("compasso-b-input").value;
const num_bars = document.getElementById("bars-input").value; const num_bars = document.getElementById("bars-input").value;
const tracksXml = appState.tracks const tracksXml = appState.pattern.tracks.map((track) => createTrackXml(track)).join("");
.map((track) => createTrackXml(track))
.join("");
const mmpContent = `<?xml version="1.0"?> const mmpContent = `<?xml version="1.0"?>
<!DOCTYPE lmms-project> <!DOCTYPE lmms-project>
<lmms-project version="1.0" type="song" creator="MMPCreator" creatorversion="1.0"> <lmms-project version="1.0" type="song" creator="MMPCreator" creatorversion="1.0">
@ -321,18 +315,3 @@ function downloadFile(content, fileName) {
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); 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 // js/main.js
import { import { appState, resetProjectState } from "./state.js";
appState, import { addTrackToState, removeLastTrackFromState } from "./pattern/pattern_state.js";
addTrackToState, import { addAudioTrackLane } from "./audio/audio_state.js";
removeLastTrackFromState, import { updateTransportLoop } from "./audio/audio_audio.js";
} from "./state.js";
import { import {
togglePlayback, togglePlayback,
stopPlayback, stopPlayback,
rewindPlayback, rewindPlayback,
initializeAudioContext, } from "./pattern/pattern_audio.js";
updateMasterVolume, import {
updateMasterPan,
startAudioEditorPlayback, startAudioEditorPlayback,
stopAudioEditorPlayback, stopAudioEditorPlayback,
restartAudioEditorIfPlaying, restartAudioEditorIfPlaying,
} from "./audio.js"; } from "./audio/audio_audio.js";
import { initializeAudioContext } from "./audio.js";
import { handleFileLoad, generateMmpFile } from "./file.js"; import { handleFileLoad, generateMmpFile } from "./file.js";
import { import { renderAll, loadAndRenderSampleBrowser, showOpenProjectModal, closeOpenProjectModal } from "./ui.js";
renderApp, import { renderAudioEditor } from "./audio/audio_ui.js";
redrawSequencer,
loadAndRenderSampleBrowser,
showOpenProjectModal,
closeOpenProjectModal,
handleSampleUpload,
} from "./ui.js";
import { adjustValue, enforceNumericInput } from "./utils.js"; import { adjustValue, enforceNumericInput } from "./utils.js";
import { DEFAULT_PAN, DEFAULT_VOLUME } from "./config.js"; import { ZOOM_LEVELS } from "./config.js";
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const newProjectBtn = document.getElementById("new-project-btn"); const newProjectBtn = document.getElementById("new-project-btn");
@ -39,8 +32,10 @@ document.addEventListener("DOMContentLoaded", () => {
const audioEditorPlayBtn = document.getElementById("audio-editor-play-btn"); const audioEditorPlayBtn = document.getElementById("audio-editor-play-btn");
const audioEditorStopBtn = document.getElementById("audio-editor-stop-btn"); const audioEditorStopBtn = document.getElementById("audio-editor-stop-btn");
const audioEditorLoopBtn = document.getElementById("audio-editor-loop-btn"); const audioEditorLoopBtn = document.getElementById("audio-editor-loop-btn");
const addAudioTrackBtn = document.getElementById("add-audio-track-btn");
const rewindBtn = document.getElementById("rewind-btn"); const rewindBtn = document.getElementById("rewind-btn");
const metronomeBtn = document.getElementById("metronome-btn"); const metronomeBtn = document.getElementById("metronome-btn");
const sliceToolBtn = document.getElementById("slice-tool-btn");
const mmpFileInput = document.getElementById("mmp-file-input"); const mmpFileInput = document.getElementById("mmp-file-input");
const sampleFileInput = document.getElementById("sample-file-input"); const sampleFileInput = document.getElementById("sample-file-input");
const openProjectModal = document.getElementById("open-project-modal"); const openProjectModal = document.getElementById("open-project-modal");
@ -48,36 +43,19 @@ document.addEventListener("DOMContentLoaded", () => {
const loadFromComputerBtn = document.getElementById("load-from-computer-btn"); const loadFromComputerBtn = document.getElementById("load-from-computer-btn");
const sidebarToggle = document.getElementById("sidebar-toggle"); const sidebarToggle = document.getElementById("sidebar-toggle");
const addBarBtn = document.getElementById("add-bar-btn"); const addBarBtn = document.getElementById("add-bar-btn");
const masterVolumeKnob = document.getElementById("master-volume-knob"); const zoomInBtn = document.getElementById("zoom-in-btn");
const masterPanKnob = document.getElementById("master-pan-knob"); const zoomOutBtn = document.getElementById("zoom-out-btn");
newProjectBtn.addEventListener("click", () => { newProjectBtn.addEventListener("click", () => {
if ( if ((appState.pattern.tracks.length > 0 || appState.audio.clips.length > 0) && !confirm("Você tem certeza? Alterações não salvas serão perdidas.")) return;
appState.tracks.length > 0 && resetProjectState();
!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
});
document.getElementById('bpm-input').value = 140; document.getElementById('bpm-input').value = 140;
document.getElementById('bars-input').value = 1; document.getElementById('bars-input').value = 1;
document.getElementById('compasso-a-input').value = 4; document.getElementById('compasso-a-input').value = 4;
document.getElementById('compasso-b-input').value = 4; document.getElementById('compasso-b-input').value = 4;
const titleElement = document.getElementById('beat-bassline-title'); const titleElement = document.getElementById('beat-bassline-title');
if(titleElement) titleElement.textContent = 'Novo Projeto'; if(titleElement) titleElement.textContent = 'Novo Projeto';
renderApp(); renderAll();
setupMasterKnobs();
}); });
addBarBtn.addEventListener("click", () => { addBarBtn.addEventListener("click", () => {
@ -85,188 +63,114 @@ document.addEventListener("DOMContentLoaded", () => {
if (barsInput) adjustValue(barsInput, 1); 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); openMmpBtn.addEventListener("click", showOpenProjectModal);
loadFromComputerBtn.addEventListener("click", () => mmpFileInput.click()); loadFromComputerBtn.addEventListener("click", () => mmpFileInput.click());
mmpFileInput.addEventListener("change", async (event) => { mmpFileInput.addEventListener("change", (event) => { const file = event.target.files[0]; if (file) { handleFileLoad(file).then(() => closeOpenProjectModal()); } });
const file = event.target.files[0];
if (file) {
await handleFileLoad(file);
closeOpenProjectModal();
}
});
uploadSampleBtn.addEventListener("click", () => sampleFileInput.click()); 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); saveMmpBtn.addEventListener("click", generateMmpFile);
addInstrumentBtn.addEventListener("click", addTrackToState); addInstrumentBtn.addEventListener("click", addTrackToState);
removeInstrumentBtn.addEventListener("click", removeLastTrackFromState); removeInstrumentBtn.addEventListener("click", removeLastTrackFromState);
playBtn.addEventListener("click", togglePlayback); playBtn.addEventListener("click", togglePlayback);
stopBtn.addEventListener("click", stopPlayback); stopBtn.addEventListener("click", stopPlayback);
rewindBtn.addEventListener("click", rewindPlayback); rewindBtn.addEventListener("click", rewindPlayback);
metronomeBtn.addEventListener("click", () => { metronomeBtn.addEventListener("click", () => { initializeAudioContext(); appState.global.metronomeEnabled = !appState.global.metronomeEnabled; metronomeBtn.classList.toggle("active", appState.global.metronomeEnabled); });
initializeAudioContext(); 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); }); }
appState.metronomeEnabled = !appState.metronomeEnabled;
metronomeBtn.classList.toggle("active", appState.metronomeEnabled);
});
openModalCloseBtn.addEventListener("click", closeOpenProjectModal); openModalCloseBtn.addEventListener("click", closeOpenProjectModal);
openProjectModal.addEventListener("click", (e) => {
if (e.target === openProjectModal) closeOpenProjectModal(); // ### CORREÇÃO 2: Adicionada verificação 'if (icon)' ###
});
sidebarToggle.addEventListener("click", () => { sidebarToggle.addEventListener("click", () => {
document.body.classList.toggle("sidebar-hidden"); document.body.classList.toggle("sidebar-hidden");
const icon = sidebarToggle.querySelector("i"); const icon = sidebarToggle.querySelector("i");
icon.className = document.body.classList.contains("sidebar-hidden") if (icon) {
? "fa-solid fa-caret-right" icon.className = document.body.classList.contains("sidebar-hidden") ? "fa-solid fa-caret-right" : "fa-solid fa-caret-left";
: "fa-solid fa-caret-left"; }
}); });
const inputs = document.querySelectorAll(".value-input"); const inputs = document.querySelectorAll(".value-input");
inputs.forEach((input) => { inputs.forEach((input) => {
input.addEventListener("input", (event) => { input.addEventListener("input", (event) => {
enforceNumericInput(event); enforceNumericInput(event);
if (appState.isPlaying && (event.target.id.startsWith("compasso-") || event.target.id === 'bars-input')) { if (appState.global.isPlaying && (event.target.id.startsWith("compasso-") || event.target.id === 'bars-input')) { stopPlayback(); }
stopPlayback(); if (event.target.id.startsWith("compasso-") || event.target.id === 'bars-input' || event.target.id === 'bpm-input') { renderAll(); }
}
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);
}); });
input.addEventListener("wheel", (event) => { event.preventDefault(); const step = event.deltaY < 0 ? 1 : -1; adjustValue(event.target, step); });
}); });
const buttons = document.querySelectorAll(".adjust-btn"); const buttons = document.querySelectorAll(".adjust-btn");
buttons.forEach((button) => { 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); } }); });
button.addEventListener("click", () => {
const targetId = button.dataset.target + "-input"; if (zoomInBtn) {
const targetInput = document.getElementById(targetId); zoomInBtn.addEventListener("click", () => {
const step = parseInt(button.dataset.step, 10) || 1; if (appState.global.zoomLevelIndex < ZOOM_LEVELS.length - 1) {
if (targetInput) { appState.global.zoomLevelIndex++;
adjustValue(targetInput, step); renderAll();
} }
}); });
}); }
if (zoomOutBtn) {
audioEditorPlayBtn.addEventListener("click", () => { zoomOutBtn.addEventListener("click", () => {
if (appState.isAudioEditorPlaying) { if (appState.global.zoomLevelIndex > 0) {
stopAudioEditorPlayback(); appState.global.zoomLevelIndex--;
} else { renderAll();
startAudioEditorPlayback(); }
} });
}); }
audioEditorPlayBtn.addEventListener("click", () => { if (appState.global.isAudioEditorPlaying) { stopAudioEditorPlayback(); } else { startAudioEditorPlayback(); } });
audioEditorStopBtn.addEventListener("click", stopAudioEditorPlayback); audioEditorStopBtn.addEventListener("click", stopAudioEditorPlayback);
// ### CORREÇÃO 1: Listeners duplicados combinados em um só ###
// No main.js
audioEditorLoopBtn.addEventListener("click", () => { audioEditorLoopBtn.addEventListener("click", () => {
appState.isAudioEditorLoopEnabled = !appState.isAudioEditorLoopEnabled; console.log("--- Botão de Loop Clicado ---"); // DEBUG 1
audioEditorLoopBtn.classList.toggle("active", appState.isAudioEditorLoopEnabled);
// 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(); 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(); 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 // 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 { 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 = { // Estado global da aplicação
tracks: [], const globalState = {
audioTracks: [], sliceToolActive: false,
activeTrackId: null,
activePatternIndex: 0,
isPlaying: false, isPlaying: false,
isAudioEditorPlaying: false, isAudioEditorPlaying: false,
activeAudioSources: [],
audioEditorStartTime: 0,
audioEditorAnimationId: null,
audioEditorPlaybackTime: 0,
isAudioEditorLoopEnabled: false, // <-- ADICIONADO: Estado para controlar o loop
playbackIntervalId: null, playbackIntervalId: null,
currentStep: 0, currentStep: 0,
metronomeEnabled: false, metronomeEnabled: false,
@ -27,148 +15,40 @@ export let appState = {
currentBeatBasslineName: 'Novo Projeto', currentBeatBasslineName: 'Novo Projeto',
masterVolume: DEFAULT_VOLUME, masterVolume: DEFAULT_VOLUME,
masterPan: DEFAULT_PAN, 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) { // Combina todos os estados em um único objeto namespaced
if (!track.samplePath) return track; export let appState = {
try { global: globalState,
const audioContext = getAudioContext(); pattern: patternState,
if (!audioContext) initializeAudioContext(); audio: audioState,
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;
}
export function addAudioTrack(samplePath) { // Função para resetar o projeto para o estado inicial
initializeAudioContext(); export function resetProjectState() {
const audioContext = getAudioContext(); initializePatternState();
const mainGainNode = getMainGainNode(); initializeAudioState();
const newAudioTrack = { Object.assign(globalState, {
id: Date.now() + Math.random(), sliceToolActive: false,
name: samplePath.split('/').pop(), isPlaying: false,
samplePath: samplePath, isAudioEditorPlaying: false,
audioBuffer: null, playbackIntervalId: null,
volume: DEFAULT_VOLUME, currentStep: 0,
pan: DEFAULT_PAN, metronomeEnabled: false,
isMuted: false, originalXmlDoc: null,
isSoloed: false, // <-- ADICIONADO: Começa como não-solada currentBeatBasslineName: 'Novo Projeto',
gainNode: audioContext.createGain(), masterVolume: DEFAULT_VOLUME,
pannerNode: audioContext.createStereoPanner(), masterPan: DEFAULT_PAN,
}; zoomLevelIndex: 2,
isLoopActive: false,
newAudioTrack.gainNode.connect(newAudioTrack.pannerNode); loopStartTime: 0,
newAudioTrack.pannerNode.connect(mainGainNode); loopEndTime: 8,
newAudioTrack.gainNode.gain.value = newAudioTrack.volume;
newAudioTrack.pannerNode.pan.value = newAudioTrack.pan;
appState.audioTracks.push(newAudioTrack);
loadAudioForTrack(newAudioTrack).then(() => {
renderAudioEditor();
}); });
} }
// 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 // js/ui.js
import { import { playSample } from "./pattern/pattern_audio.js";
appState, import { renderPatternEditor } from "./pattern/pattern_ui.js";
toggleStepState, import { renderAudioEditor } from "./audio/audio_ui.js";
updateTrackSample,
updateTrackVolume,
updateTrackPan,
addAudioTrack,
toggleAudioTrackSolo,
} from "./state.js";
import { playSample, stopPlayback, seekAudioEditor } from "./audio.js";
import { getTotalSteps } from "./utils.js";
import { loadProjectFromServer } from "./file.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 = {}; let samplePathMap = {};
const globalPatternSelector = document.getElementById('global-pattern-selector');
if (globalPatternSelector) { export function renderAll() {
globalPatternSelector.addEventListener('change', () => { renderPatternEditor();
stopPlayback(); renderAudioEditor();
appState.activePatternIndex = parseInt(globalPatternSelector.value, 10); const loopArea = document.getElementById("loop-region");
const firstTrack = appState.tracks[0]; if (loopArea) {
if (firstTrack) { // Sincroniza a visibilidade da área de loop com o estado atual
const activePattern = firstTrack.patterns[appState.activePatternIndex]; loopArea.classList.toggle("visible", appState.global.isLoopActive);
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;
} }
} }
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() { export function getSamplePathMap() {
return samplePathMap; 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() { export async function loadAndRenderSampleBrowser() {
const browserContent = document.getElementById("browser-content"); const browserContent = document.getElementById("browser-content");
try { try {
const response = await fetch(`metadata/samples-manifest.json?v=${Date.now()}`); const response = await fetch(`metadata/samples-manifest.json?v=${Date.now()}`);
if (!response.ok) { if (!response.ok) throw new Error("Arquivo samples-manifest.json não encontrado.");
throw new Error("Arquivo samples-manifest.json não encontrado.");
}
const fileTree = await response.json(); const fileTree = await response.json();
samplePathMap = {}; samplePathMap = {};
@ -511,42 +51,65 @@ export async function loadAndRenderSampleBrowser() {
} }
} }
// Em ui.js, substitua a função antiga por esta:
function renderFileTree(tree, parentElement, currentPath) { function renderFileTree(tree, parentElement, currentPath) {
parentElement.innerHTML = ""; parentElement.innerHTML = ""; // Limpa o conteúdo anterior
const ul = document.createElement("ul"); 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 sortedKeys = Object.keys(tree).sort((a, b) => {
const aIsFile = tree[a]._isFile; const aIsFile = tree[a]._isFile;
const bIsFile = tree[b]._isFile; const bIsFile = tree[b]._isFile;
if (aIsFile === bIsFile) return a.localeCompare(b); if (aIsFile === bIsFile) return a.localeCompare(b); // Ordena alfabeticamente se ambos forem do mesmo tipo
return aIsFile ? 1 : -1; return aIsFile ? 1 : -1; // Pastas (-1) vêm antes de arquivos (1)
}); });
for (const key of sortedKeys) { for (const key of sortedKeys) {
if (key === '_isFile') continue; if (key === '_isFile') continue; // Pula a propriedade de metadados
const node = tree[key]; const node = tree[key];
const li = document.createElement("li"); const li = document.createElement("li");
const newPath = `${currentPath}/${key}`; const newPath = `${currentPath}/${key}`;
if (node._isFile) { 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.setAttribute("draggable", true);
li.dataset.path = newPath; // Guarda o caminho para o drag-and-drop
li.addEventListener("click", (e) => { li.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
playSample(newPath, null); playSample(newPath, null);
}); });
li.addEventListener("dragstart", (e) => { li.addEventListener("dragstart", (e) => {
e.dataTransfer.setData("text/plain", newPath); e.dataTransfer.setData("text/plain", newPath);
e.dataTransfer.effectAllowed = "copy"; e.dataTransfer.effectAllowed = "copy";
}); });
ul.appendChild(li); ul.appendChild(li);
} else { } else {
li.className = "directory"; // --- LÓGICA CORRIGIDA PARA PASTAS ---
li.innerHTML = `<i class="fa-solid fa-folder"></i> ${key}`; 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"); 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); renderFileTree(node, nestedUl, newPath);
li.appendChild(nestedUl); li.appendChild(nestedUl);
li.addEventListener("click", (e) => {
e.stopPropagation(); // CORREÇÃO 3: Remove o addEventListener de clique daqui. O main.js já cuida disso.
li.classList.toggle("open");
});
ul.appendChild(li); ul.appendChild(li);
} }
} }
@ -560,14 +123,12 @@ export async function showOpenProjectModal() {
openProjectModal.classList.add("visible"); openProjectModal.classList.add("visible");
try { try {
const response = await fetch("metadata/mmp-manifest.json"); const response = await fetch("metadata/mmp-manifest.json");
if (!response.ok) if (!response.ok) throw new Error("Arquivo mmp-manifest.json não encontrado.");
throw new Error("Arquivo mmp-manifest.json não encontrado.");
const projects = await response.json(); const projects = await response.json();
serverProjectsList.innerHTML = ""; serverProjectsList.innerHTML = "";
if (projects.length === 0) { if (projects.length === 0) {
serverProjectsList.innerHTML = serverProjectsList.innerHTML = '<p style="color:var(--text-dark);">Nenhum projeto encontrado no servidor.</p>';
'<p style="color:var(--text-dark);">Nenhum projeto encontrado no servidor.</p>';
} }
projects.forEach((projectName) => { projects.forEach((projectName) => {
@ -592,15 +153,3 @@ export function closeOpenProjectModal() {
const openProjectModal = document.getElementById("open-project-modal"); const openProjectModal = document.getElementById("open-project-modal");
openProjectModal.classList.remove("visible"); 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 // 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() { export function getTotalSteps() {
const barsInput = document.getElementById("bars-input"); const barsInput = document.getElementById("bars-input");
const compassoAInput = document.getElementById("compasso-a-input"); const compassoAInput = document.getElementById("compasso-a-input");
@ -13,19 +31,29 @@ export function getTotalSteps() {
return numberOfBars * beatsPerBar * subdivisions; 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) { export function enforceNumericInput(event) {
event.target.value = event.target.value.replace(/[^0-9]/g, ""); 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) { export function adjustValue(inputElement, step) {
let currentValue = parseInt(inputElement.value, 10) || 0; let currentValue = parseInt(inputElement.value, 10) || 0;
let min = parseInt(inputElement.dataset.min, 10); let min = parseInt(inputElement.dataset.min, 10);
let max = parseInt(inputElement.dataset.max, 10); let max = parseInt(inputElement.dataset.max, 10);
let newValue = currentValue + step; let newValue = currentValue + step;
if (!isNaN(min) && newValue < min) newValue = min; if (!isNaN(min) && newValue < min) newValue = min;
if (!isNaN(max) && newValue > max) newValue = max; 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 })); 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. * 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 {HTMLCanvasElement} canvas - O elemento canvas onde o desenho será feito.
* @param {AudioBuffer} audioBuffer - O buffer de áudio decodificado da faixa. * @param {AudioBuffer} audioBuffer - O buffer de áudio decodificado da faixa.
* @param {string} color - A cor da forma de onda (ex: '#2ecc71'). * @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; if (!canvas || !audioBuffer) return;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
const width = canvas.width; const width = canvas.width;
const height = canvas.height; const height = canvas.height;
const channelData = audioBuffer.getChannelData(0); // Pega os dados do primeiro canal const channelData = audioBuffer.getChannelData(0);
const step = Math.ceil(channelData.length / width); const sampleRate = audioBuffer.sampleRate;
const amp = height / 2; // Amplitude máxima do desenho
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.strokeStyle = color;
ctx.lineWidth = 2; ctx.lineWidth = 1;
ctx.beginPath(); ctx.beginPath();
// Desenha a linha do meio (zero amplitude)
ctx.moveTo(0, amp);
ctx.lineTo(width, amp);
for (let i = 0; i < width; i++) { for (let i = 0; i < width; i++) {
let min = 1.0; let min = 1.0;
let max = -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++) { for (let j = 0; j < step; j++) {
const datum = channelData[(i * step) + j]; // --- CORREÇÃO CRÍTICA AQUI ---
if (datum < min) { // Calcula o índice da amostra considerando o startIndex do segmento
min = datum; const sampleIndex = startIndex + (i * step) + j;
} if (sampleIndex < channelData.length) {
if (datum > max) { const datum = channelData[sampleIndex];
max = datum; if (datum < min) min = datum;
if (datum > max) max = datum;
} }
} }
// Desenha a linha vertical para aquele ponto no tempo
const x = i; const x = i;
const y_max = (1 + max) * amp; // Ajusta o desenho para ser centrado verticalmente
const y_min = (1 + min) * amp; const y_max = (1 - max) * amp;
const y_min = (1 - min) * amp;
ctx.moveTo(x, y_max); ctx.moveTo(x, y_max);
ctx.lineTo(x, y_min); ctx.lineTo(x, y_min);

View File

@ -123,42 +123,72 @@
<span>Editor de Amostras de Áudio</span> <span>Editor de Amostras de Áudio</span>
<div class="playback-controls"> <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-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-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-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> </div>
<div id="audio-track-container"> <div id="audio-track-container">
<div class="audio-track-lane"> <div class="audio-track-lane">
<div class="track-info"> <div class="track-info">
<i class="fa-solid fa-gear"></i> <div class="track-info-header">
<div class="track-mute"></div> <i class="fa-solid fa-gear"></i>
<span class="track-name">bassslap02.ogg</span> <span class="track-name">Pista de Áudio 1</span>
</div> <div class="track-mute"></div>
<div class="track-controls"> </div>
<div class="knob-container"> <div class="track-controls">
<div class="knob" data-control="volume"> <div class="knob-container">
<div class="knob-indicator"></div> <div class="knob" data-control="volume"><div class="knob-indicator"></div></div>
</div> <span>VOL</span>
<span>VOL</span> </div>
</div> <div class="knob-container">
<div class="knob-container"> <div class="knob" data-control="pan"><div class="knob-indicator"></div></div>
<div class="knob" data-control="pan"> <span>PAN</span>
<div class="knob-indicator"></div> </div>
</div> </div>
<span>PAN</span> </div>
</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="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>
<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> </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> </main>
</div> </div>
<input type="file" id="mmp-file-input" accept=".mmp, .mmpz" style="display: none"/> <input type="file" id="mmp-file-input" accept=".mmp, .mmpz" style="display: none"/>
@ -181,8 +211,15 @@
</div> </div>
</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> <script src="assets/js/creations/main.js" type="module"></script>
</body> </body>
</html> </html>