versão 2.0
Deploy / Deploy (push) Successful in 40s
Details
Deploy / Deploy (push) Successful in 40s
Details
This commit is contained in:
parent
d77fe91df1
commit
531fb7b36a
|
|
@ -1,6 +1,4 @@
|
|||
/* =============================================== */
|
||||
/* VÁRIAVEIS GLOBAIS (ROOT)
|
||||
/* =============================================== */
|
||||
/* MMPCreator - Folha de Estilos Principal */
|
||||
:root {
|
||||
--bg-body: #2d3035;
|
||||
--bg-toolbar: #3b3f45;
|
||||
|
|
@ -17,90 +15,115 @@
|
|||
}
|
||||
|
||||
/* =============================================== */
|
||||
/* ESTILOS GLOBAIS E LAYOUT PRINCIPAL (CORRIGIDO)
|
||||
/* LAYOUT E ESTRUTURA GLOBAL
|
||||
/* =============================================== */
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background-color: var(--bg-body);
|
||||
color: var(--text-light);
|
||||
/* Retornamos ao layout com padding para compatibilidade */
|
||||
padding-left: 300px;
|
||||
padding-top: 50px; /* Adiciona espaço para a toolbar fixa */
|
||||
box-sizing: border-box;
|
||||
transition: padding-left .3s ease;
|
||||
height: 100vh;
|
||||
display: flex; /* Usamos flex no body para o main-content crescer */
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
body.sidebar-hidden {
|
||||
padding-left: 0;
|
||||
}
|
||||
body.knob-dragging { cursor: ns-resize; }
|
||||
body.slice-tool-active .timeline-container { cursor: crosshair; }
|
||||
|
||||
body.knob-dragging {
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 1rem;
|
||||
flex-grow: 1; /* Faz o conteúdo principal ocupar o espaço restante */
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
overflow: hidden; /* Evita que o conteúdo transborde */
|
||||
height: 100%; /* Garante que o flexbox interno funcione */
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* =============================================== */
|
||||
/* BARRA LATERAL (SAMPLE BROWSER)
|
||||
/* =============================================== */
|
||||
.sample-browser {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 300px;
|
||||
height: 100vh;
|
||||
background-color: var(--bg-toolbar);
|
||||
border-right: 2px solid var(--border-color);
|
||||
z-index: 1500;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: translateX(0);
|
||||
transition: transform .3s ease;
|
||||
transition: min-width 0.3s ease, width 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
body.sidebar-hidden .sample-browser {
|
||||
transform: translateX(-100%);
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
border-right: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.browser-header {
|
||||
padding: 15px;
|
||||
background-color: #2a2c30;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
color: var(--text-light);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.browser-content {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.browser-content ul { list-style: none; padding-left: 15px; }
|
||||
.browser-content li { padding: 5px; cursor: pointer; border-radius: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; user-select: none; }
|
||||
.browser-content li:hover { background-color: var(--bg-editor); }
|
||||
.browser-content li i { margin-right: 8px; width: 12px; color: var(--text-dark); transition: transform .2s; }
|
||||
.browser-content li.directory > ul { display: none; }
|
||||
.browser-content li.directory.open > ul { display: block; }
|
||||
.browser-content li.directory.open > .fa-folder { transform: rotate(90deg); }
|
||||
|
||||
.browser-content ul {
|
||||
padding-left: 15px;
|
||||
list-style: none;
|
||||
}
|
||||
.folder-item > .file-list {
|
||||
display: none;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.folder-item.open > .file-list {
|
||||
display: block;
|
||||
}
|
||||
.folder-name {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
border-radius: 3px;
|
||||
user-select: none;
|
||||
}
|
||||
.folder-name:hover {
|
||||
background-color: var(--bg-editor);
|
||||
}
|
||||
.folder-icon {
|
||||
margin-right: 8px;
|
||||
color: var(--text-dark);
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
.folder-name::before {
|
||||
content: '\f0da';
|
||||
font-family: "Font Awesome 6 Free";
|
||||
font-weight: 900;
|
||||
margin-right: 8px;
|
||||
font-size: 0.9em;
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
.folder-item.open > .folder-name::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.folder-item.open > .folder-name > .folder-icon::before {
|
||||
content: '\f07c';
|
||||
}
|
||||
.browser-content li.file-item {
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
user-select: none;
|
||||
}
|
||||
#sidebar-toggle {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
left: 305px;
|
||||
left: 5px;
|
||||
z-index: 1400;
|
||||
background-color: var(--bg-toolbar);
|
||||
border: 1px solid var(--border-color);
|
||||
|
|
@ -108,49 +131,19 @@ body.sidebar-hidden .sample-browser {
|
|||
width: 25px;
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
border-top-right-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
transition: left .3s ease;
|
||||
border-radius: 0 4px 4px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
body.sidebar-hidden #sidebar-toggle {
|
||||
left: 5px;
|
||||
}
|
||||
|
||||
/* =============================================== */
|
||||
/* BARRA DE FERRAMENTAS GLOBAL
|
||||
/* ÁREA DE CONTEÚDO E TOOLBARS
|
||||
/* =============================================== */
|
||||
.global-toolbar {
|
||||
padding: 8px 15px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 300px;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
background-color: var(--bg-toolbar);
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
transition: left .3s ease;
|
||||
height: 50px; /* Altura fixa para o cálculo do padding do body */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body.sidebar-hidden .global-toolbar {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
/* =============================================== */
|
||||
/* NOVO: EDITOR DE AMOSTRAS DE ÁUDIO (AUDIO EDITOR)
|
||||
/* =============================================== */
|
||||
|
||||
/* O container principal que substitui o .future-panel */
|
||||
.audio-editor {
|
||||
height: 50%;
|
||||
.global-toolbar { display: flex; align-items: center; gap: 20px; padding: 8px 15px; background-color: var(--bg-toolbar); border-bottom: 2px solid var(--border-color); height: 50px; box-sizing: border-box; flex-shrink: 0; }
|
||||
.main-content { flex-grow: 1; padding: 1rem; display: flex; flex-direction: column; gap: 1rem; overflow: hidden; }
|
||||
.beat-editor {
|
||||
flex: 1;
|
||||
background-color: var(--bg-editor);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
|
|
@ -159,178 +152,43 @@ body.sidebar-hidden .global-toolbar {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Container para as faixas de áudio, com scroll vertical */
|
||||
#audio-track-container {
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/* Estilo para cada linha de faixa de áudio */
|
||||
.audio-track-lane {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
background-color: var(--bg-editor);
|
||||
border-bottom: 1px solid var(--bg-toolbar);
|
||||
min-height: 40px; /* Altura mínima para cada faixa */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.editor-header { background-color: var(--bg-toolbar); padding: 4px 10px; font-size: .8rem; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color); flex-shrink: 0; }
|
||||
.editor-toolbar { background-color: var(--bg-toolbar); padding: 5px 10px; border-bottom: 2px solid var(--border-color); flex-shrink: 0; display: flex; align-items: center; gap: 15px; }
|
||||
|
||||
|
||||
/* =============================================== */
|
||||
/* ESTILOS DO EDITOR DE ÁUDIO (MARCADORES)
|
||||
/* =============================================== */
|
||||
|
||||
/* Wrapper para a visualização do espectrograma, permite scroll horizontal */
|
||||
.spectrogram-view-wrapper {
|
||||
flex-grow: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
background-color: #2a2c30;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Garante que o grid possa conter elementos posicionados de forma absoluta */
|
||||
.spectrogram-view-grid {
|
||||
position: relative;
|
||||
display: inline-block; /* Faz o contêiner se ajustar à largura do canvas */
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Estilo para os números de compasso */
|
||||
.bar-marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateX(-50%); /* Centraliza o número sobre a linha */
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
color: var(--text-dark);
|
||||
padding: 1px 5px;
|
||||
font-size: 0.7rem;
|
||||
border-radius: 3px;
|
||||
user-select: none; /* Impede que o texto seja selecionado */
|
||||
z-index: 5; /* Garante que fique acima da forma de onda mas abaixo da agulha */
|
||||
}
|
||||
|
||||
/* Mantém o canvas como block para evitar espaçamentos */
|
||||
.waveform-canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* =============================================== */
|
||||
/* TOOLBAR DO EDITOR
|
||||
/* =============================================== */
|
||||
.editor-header {
|
||||
background-color: var(--bg-toolbar);
|
||||
padding: 4px 10px;
|
||||
font-size: .8rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.window-controls i { margin-left: 12px; cursor: pointer; }
|
||||
|
||||
.editor-toolbar, .editor-header .playback-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
background-color: var(--bg-toolbar);
|
||||
padding: 5px 10px;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editor-toolbar i, .editor-header .playback-controls i {
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.editor-toolbar i.enabled { background-color: var(--bg-body); box-shadow: inset 0 0 2px #000; }
|
||||
|
||||
.pattern-manager { display: flex; align-items: center; gap: 10px; }
|
||||
.pattern-selector { background-color: var(--bg-body); color: var(--text-light); padding: 5px 10px; border: 1px solid var(--border-color); font-size: .9rem; border-radius: 2px; }
|
||||
.pattern-btn { background: var(--bg-body); border: 1px solid var(--border-color); color: var(--text-light); cursor: pointer; border-radius: 3px; width: 28px; height: 28px; font-size: 1.2rem; }
|
||||
|
||||
/* =============================================== */
|
||||
/* FAIXAS (TRACK LANES) E SEQUENCIADOR
|
||||
/* EDITOR DE BASES (BEAT EDITOR / STEP SEQUENCER)
|
||||
/* =============================================== */
|
||||
#track-container {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.track-lane {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
align-items: stretch;
|
||||
background-color: var(--bg-editor);
|
||||
border-bottom: 1px solid var(--bg-toolbar);
|
||||
border-left: 2px solid transparent;
|
||||
border-right: 2px solid transparent;
|
||||
transition: border-color 0.2s, background-color 0.2s;
|
||||
min-height: 72px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.track-lane.active-track {
|
||||
background-color: #40454d;
|
||||
.track-lane.active-track { background-color: #40454d; }
|
||||
.track-lane.drag-over { border: 2px dashed var(--accent-green); }
|
||||
.track-lane .track-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 180px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 10px;
|
||||
border-right: 1px solid var(--bg-toolbar);
|
||||
}
|
||||
|
||||
.track-lane.drag-over {
|
||||
border-color: var(--accent-green);
|
||||
}
|
||||
|
||||
/* Localize a regra .track-mute e substitua por esta */
|
||||
.track-solo-btn {
|
||||
width: 25px;
|
||||
height: 12px;
|
||||
background-color: var(--accent-red); /* Cor padrão: vermelho */
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: inset 0 0 2px #000;
|
||||
transition: background-color 0.2s, opacity 0.2s;
|
||||
}
|
||||
|
||||
.track-solo-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Quando solado (ativo), o botão fica verde */
|
||||
.track-solo-btn.active {
|
||||
background-color: var(--accent-green);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.track-info { display: flex; align-items: center; gap: 8px; width: 180px; flex-shrink: 0; }
|
||||
.track-info .fa-gear { font-size: 1.2rem; cursor: pointer; }
|
||||
.track-mute { width: 25px; height: 12px; background-color: var(--accent-green); border-radius: 6px; cursor: pointer; border: 1px solid var(--border-color); box-shadow: inset 0 0 2px #000; transition: background-color 0.2s, opacity 0.2s; }
|
||||
.track-mute:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.track-mute.active {
|
||||
background-color: var(--text-dark);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.track-name { color: var(--accent-red); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.track-controls { display: flex; gap: 5px; margin: 0 10px; padding-left: 10px; border-left: 1px solid var(--bg-toolbar); flex-shrink: 0; }
|
||||
.knob-container { text-align: center; font-size: .7rem; color: var(--text-dark); }
|
||||
.knob { width: 28px; height: 28px; background-color: var(--bg-toolbar); border-radius: 50%; border: 1px solid var(--border-color); margin-bottom: 2px; cursor: grab; box-shadow: inset 0 0 4px #222; position: relative; }
|
||||
.knob:active { cursor: grabbing; }
|
||||
.knob-indicator { width: 2px; height: 8px; background-color: var(--text-light); position: absolute; top: 2px; left: 50%; transform-origin: bottom center; transform: translateX(-50%) rotate(0deg); border-radius: 1px; }
|
||||
|
||||
.step-sequencer-wrapper { flex-grow: 1; overflow-x: auto; overflow-y: hidden; padding-bottom: 8px; }
|
||||
.track-mute:hover { opacity: 0.8; }
|
||||
.track-mute.active { background-color: var(--text-dark); opacity: 0.7; }
|
||||
.track-lane .track-controls { display: flex; gap: 5px; margin: 0 10px; padding-left: 10px; border-left: 1px solid var(--bg-toolbar); flex-shrink: 0; }
|
||||
.step-sequencer-wrapper { flex-grow: 1; overflow-x: auto; overflow-y: hidden; padding-bottom: 8px; display: flex; align-items: center; }
|
||||
.step-sequencer { display: flex; gap: 4px; }
|
||||
.step-sequencer-wrapper::-webkit-scrollbar { height: 8px; }
|
||||
.step-sequencer-wrapper::-webkit-scrollbar-track { background: var(--border-color); border-radius: 4px; }
|
||||
.step-sequencer-wrapper::-webkit-scrollbar-thumb { background: var(--bg-toolbar); border-radius: 4px; }
|
||||
.step-sequencer-wrapper::-webkit-scrollbar-thumb:hover { background: #555; }
|
||||
.step-wrapper { position: relative; }
|
||||
.step-marker { position: absolute; top: -16px; left: 1px; font-size: .6rem; color: var(--text-dark); user-select: none; }
|
||||
.step { width: 28px; height: 28px; background-color: #2a2a2a; border: 1px solid #4a4a4a; border-radius: 2px; cursor: pointer; transition: background-color .1s, transform 0.1s; flex-shrink: 0; }
|
||||
|
|
@ -339,9 +197,161 @@ body.sidebar-hidden .global-toolbar {
|
|||
.step.active { background-color: var(--accent-green); border: 1px solid #fff; box-shadow: 0 0 8px var(--accent-green); }
|
||||
.step.playing { transform: scale(1.1); box-shadow: inset 0 0 8px rgba(255, 255, 255, 0.8); }
|
||||
|
||||
/* =================================================================== */
|
||||
/* EDITOR DE ÁUDIO - LAYOUT PRINCIPAL
|
||||
/* =================================================================== */
|
||||
.audio-editor {
|
||||
flex: 1;
|
||||
background-color: var(--bg-editor);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, .3);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
--track-info-width: 255px;
|
||||
}
|
||||
#audio-track-container {
|
||||
flex-grow: 1; /* Ocupa o espaço restante */
|
||||
overflow: auto; /* Habilita a rolagem horizontal e vertical */
|
||||
}
|
||||
.audio-track-lane {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
background-color: var(--bg-editor);
|
||||
border-bottom: 1px solid var(--bg-toolbar);
|
||||
min-height: 90px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.audio-track-lane.drag-over { background-color: #40454d; }
|
||||
.audio-track-lane .track-info {
|
||||
width: var(--track-info-width);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
background-color: #383c42;
|
||||
border-right: 2px solid var(--border-color);
|
||||
}
|
||||
.track-info-header { display: flex; align-items: center; gap: 8px; width: 100%; }
|
||||
.track-name { color: var(--accent-red); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.timeline-container {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
overflow-x: hidden; /* A rolagem agora é controlada pelo #audio-track-container */
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.spectrogram-view-grid {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
display: block;
|
||||
--step-width: 32px;
|
||||
--beat-width: 128px;
|
||||
--bar-width: 512px;
|
||||
background-size: var(--bar-width) 100%, var(--beat-width) 100%, var(--step-width) 100%;
|
||||
background-image:
|
||||
repeating-linear-gradient(to right, #666 0, #666 1px, transparent 1px, transparent 100%),
|
||||
repeating-linear-gradient(to right, #444 0, #444 1px, transparent 1px, transparent 100%),
|
||||
repeating-linear-gradient(to right, #3a3e44 0, #3a3e44 1px, transparent 1px, transparent 100%);
|
||||
}
|
||||
|
||||
/* =============================================== */
|
||||
/* CONTROLES E INPUTS
|
||||
/* EDITOR DE ÁUDIO - RÉGUA E LAYOUT CORRIGIDO
|
||||
/* =============================================== */
|
||||
.ruler-wrapper {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
background-color: #383c42;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.ruler-spacer {
|
||||
width: var(--track-info-width);
|
||||
flex-shrink: 0;
|
||||
border-right: 2px solid var(--border-color);
|
||||
}
|
||||
.timeline-ruler {
|
||||
position: relative;
|
||||
height: 25px;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
background-color: #2a2c30;
|
||||
}
|
||||
.ruler-marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
color: var(--text-dark);
|
||||
font-size: 0.75rem;
|
||||
padding-left: 5px;
|
||||
border-left: 1px solid #555;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* =============================================== */
|
||||
/* EDITOR DE ÁUDIO - CLIPS E CONTROLES
|
||||
/* =============================================== */
|
||||
.timeline-clip {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 55px;
|
||||
background: linear-gradient(to bottom, #5c626b, #4a4f57);
|
||||
border: 1px solid var(--border-color-dark);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 3px 8px rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
color: var(--text-light);
|
||||
}
|
||||
.timeline-clip:active, .timeline-clip.dragging { cursor: grabbing; z-index: 1000; border-color: var(--accent-blue); opacity: 0.9; }
|
||||
.clip-name { position: absolute; top: 4px; left: 8px; font-size: 0.75rem; font-weight: bold; background-color: rgba(0,0,0,0.6); padding: 2px 6px; border-radius: 3px; pointer-events: none; }
|
||||
.waveform-canvas-clip { width: 100%; height: 100%; display: block; }
|
||||
.audio-track-lane .track-controls { display: flex; justify-content: flex-start; gap: 15px; border-left: none; padding-left: 0; margin: 0; }
|
||||
.clip-resize-handle { position: absolute; top: 0; bottom: 0; width: 8px; cursor: ew-resize; z-index: 10; }
|
||||
.clip-resize-handle.left { left: 0; }
|
||||
.clip-resize-handle.right { right: 0; }
|
||||
.playhead { position: absolute; top: 0; left: 0; width: 2px; height: 100%; background-color: var(--accent-red); z-index: 50; pointer-events: none; }
|
||||
#loop-region {
|
||||
display: none; /* Começa escondido por padrão */
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background-color: rgba(52, 152, 219, 0.2);
|
||||
border-left: 1px solid var(--accent-blue);
|
||||
border-right: 1px solid var(--accent-blue);
|
||||
z-index: 15;
|
||||
min-width: 16px;
|
||||
cursor: grab;
|
||||
}
|
||||
#loop-region.visible {
|
||||
display: block; /* ou 'flex', 'absolute', etc. */
|
||||
}
|
||||
|
||||
/* Esta regra está correta, mas também deve usar o ID */
|
||||
#loop-region:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.loop-handle { position: absolute; top: 0; bottom: 0; width: 10px; cursor: ew-resize; }
|
||||
.loop-handle.left { left: -5px; }
|
||||
.loop-handle.right { right: -5px; }
|
||||
#slice-tool-btn.active { color: var(--accent-blue); }
|
||||
#audio-editor-loop-btn.active { color: var(--accent-green); }
|
||||
|
||||
/* =============================================== */
|
||||
/* COMPONENTES GERAIS (KNOBS, BOTÕES, INPUTS)
|
||||
/* =============================================== */
|
||||
.knob-container { text-align: center; font-size: .7rem; color: var(--text-dark); }
|
||||
.knob { width: 28px; height: 28px; background-color: var(--bg-toolbar); border-radius: 50%; border: 1px solid var(--border-color); margin-bottom: 2px; cursor: grab; box-shadow: inset 0 0 4px #222; position: relative; }
|
||||
.knob:active { cursor: grabbing; }
|
||||
.knob-indicator { width: 2px; height: 8px; background-color: var(--text-light); position: absolute; top: 2px; left: 50%; transform-origin: bottom center; transform: translateX(-50%) rotate(0deg); border-radius: 1px; }
|
||||
.interactive-input-container { display: flex; align-items: center; justify-content: center; gap: 4px; }
|
||||
.compasso-group { display: flex; align-items: center; gap: 4px; }
|
||||
.value-input { background: 0 0; border: 0; outline: 0; color: var(--accent-green); font-weight: 700; font-size: 1.4rem; font-family: Courier New, Courier, monospace; text-align: center; padding: 0; width: 55px; }
|
||||
|
|
@ -364,13 +374,15 @@ body.sidebar-hidden .global-toolbar {
|
|||
#metronome-btn:hover { border-color: var(--text-light); background-color: var(--bg-editor); }
|
||||
#metronome-btn.active { background-color: var(--accent-green); color: var(--bg-body); border-color: var(--accent-green); }
|
||||
|
||||
/* =============================================== */
|
||||
/* MODAL E MENUS
|
||||
/* =============================================== */
|
||||
.modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.7); z-index: 2000; display: flex; justify-content: center; align-items: center; padding: 1rem; visibility: hidden; opacity: 0; transition: visibility 0s 0.3s, opacity 0.3s; }
|
||||
.modal-overlay.visible { visibility: visible; opacity: 1; transition: visibility 0s, opacity 0.3s; }
|
||||
.modal-content { background-color: var(--bg-body); padding: 1.5rem 2rem; border-radius: 6px; border: 1px solid var(--border-color); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); width: 100%; max-width: 500px; position: relative; display: flex; flex-direction: column; gap: 1.5rem; max-height: 90vh; }
|
||||
.modal-close { position: absolute; top: 10px; right: 15px; font-size: 1.5rem; color: var(--text-dark); cursor: pointer; border: none; background: none; }
|
||||
.modal-close:hover { color: var(--text-light); }
|
||||
.modal-title { margin: 0; padding-bottom: 0.5rem; border-bottom: 1px solid var(--bg-toolbar); color: var(--text-light); text-align: center; flex-shrink: 0; }
|
||||
.modal-section { margin: 0; }
|
||||
.modal-section h3 { margin-top: 0; margin-bottom: 0.8rem; font-size: 1rem; color: var(--text-light); }
|
||||
#server-projects-list { max-height: 250px; overflow-y: auto; background-color: var(--bg-toolbar); border: 1px solid var(--border-color); border-radius: 4px; padding: 0.5rem; min-height: 50px; }
|
||||
#server-projects-list .project-item { background-color: var(--bg-editor); padding: 10px 15px; border-radius: 4px; margin-bottom: 8px; cursor: pointer; transition: background-color 0.2s, color 0.2s; border: 1px solid transparent; }
|
||||
|
|
@ -378,124 +390,40 @@ body.sidebar-hidden .global-toolbar {
|
|||
#server-projects-list .project-item:hover { background-color: var(--bg-body); color: #fff; border-color: var(--accent-green); }
|
||||
.modal-button { background-color: var(--bg-toolbar); color: var(--text-light); border: 1px solid var(--border-color); padding: 0.8rem 1.5rem; border-radius: 4px; cursor: pointer; font-size: 1rem; transition: background-color 0.2s, border-color 0.2s; width: 100%; text-align: center; }
|
||||
.modal-button:hover { background-color: #4a4f57; border-color: #333; }
|
||||
|
||||
.file-menu-container { position: relative; }
|
||||
.toolbar-btn { background-color: var(--background-light); color: var(--text-light); border: 1px solid var(--border-color); border-radius: 3px; padding: 5px 10px; cursor: pointer; font-family: inherit; font-size: 0.8rem; }
|
||||
.toolbar-btn:hover { background-color: var(--background-lighter); }
|
||||
.file-menu-dropdown { position: absolute; top: 100%; left: 0; background-color: var(--background-lighter); border: 1px solid var(--border-color-dark); border-radius: 4px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); min-width: 200px; z-index: 1000; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.file-menu-dropdown.hidden { display: none; }
|
||||
.file-menu-dropdown a { color: var(--text-light); padding: 8px 12px; text-decoration: none; display: block; font-size: 0.9rem; }
|
||||
.file-menu-dropdown a:hover { background-color: var(--accent-blue); color: white; }
|
||||
.menu-divider { height: 1px; background-color: var(--border-color); margin: 4px 0; }
|
||||
|
||||
#timeline-context-menu { position: fixed; display: none; background-color: var(--bg-toolbar); border: 1px solid var(--border-color-dark); border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.5); padding: 5px 0; z-index: 2000; font-size: 0.9rem; }
|
||||
#timeline-context-menu div { padding: 8px 15px; cursor: pointer; white-space: nowrap; }
|
||||
#timeline-context-menu div:hover { background-color: var(--accent-blue); color: white; }
|
||||
|
||||
/* =============================================== */
|
||||
/* ESTILOS RESPONSIVOS (MELHORADO)
|
||||
/* ESTILOS RESPONSIVOS
|
||||
/* =============================================== */
|
||||
@media (max-width: 1200px) {
|
||||
.info-display-group {
|
||||
gap: 2px;
|
||||
}
|
||||
.info-display {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
.value-input {
|
||||
font-size: 1.2rem;
|
||||
width: 45px;
|
||||
}
|
||||
.compasso-input {
|
||||
width: 20px;
|
||||
}
|
||||
.info-display-group { gap: 2px; }
|
||||
.info-display { padding: 4px 6px; }
|
||||
.value-input { font-size: 1.2rem; width: 45px; }
|
||||
.compasso-input { width: 20px; }
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.global-toolbar {
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
height: auto; /* Permite que a toolbar cresça se o conteúdo quebrar linha */
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
body {
|
||||
padding-top: 80px; /* Aumenta o espaço para a toolbar maior */
|
||||
}
|
||||
.info-display-group {
|
||||
order: 3; /* Move o grupo de informações para o final da toolbar */
|
||||
width: 100%;
|
||||
justify-content: space-around;
|
||||
}
|
||||
.spacer {
|
||||
display: none;
|
||||
}
|
||||
.global-toolbar { gap: 10px; flex-wrap: wrap; height: auto; padding-bottom: 10px; }
|
||||
.main-content { padding-top: 100px; }
|
||||
.info-display-group { order: 3; width: 100%; justify-content: space-around; }
|
||||
.spacer { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
.sample-browser {
|
||||
transform: translateX(-100%);
|
||||
position: fixed; /* Volta a ser fixo para deslizar por cima */
|
||||
width: 280px;
|
||||
}
|
||||
body:not(.sidebar-hidden) .sample-browser {
|
||||
transform: translateX(0);
|
||||
}
|
||||
#sidebar-toggle {
|
||||
left: 5px;
|
||||
transform: translateX(0);
|
||||
position: fixed; /* Garante que o botão fique visível */
|
||||
}
|
||||
.global-toolbar {
|
||||
left: 0;
|
||||
padding-left: 45px;
|
||||
}
|
||||
.main-content {
|
||||
padding: 10px;
|
||||
padding-top: 85px; /* Ajusta o padding para a toolbar fixa */
|
||||
}
|
||||
.track-lane, .audio-track-lane {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 15px;
|
||||
padding: 15px;
|
||||
}
|
||||
.track-info,
|
||||
.track-controls {
|
||||
width: 100%;
|
||||
}
|
||||
.track-controls {
|
||||
border-left: none;
|
||||
padding-left: 0;
|
||||
justify-content: space-around;
|
||||
}
|
||||
.step-sequencer-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
.sample-browser { transform: translateX(-100%); position: fixed; width: 280px; }
|
||||
body:not(.sidebar-hidden) .sample-browser { transform: translateX(0); }
|
||||
#sidebar-toggle { left: 5px; transform: translateX(0); position: fixed; }
|
||||
.global-toolbar { padding-left: 45px; }
|
||||
.main-content { padding: 10px; }
|
||||
.track-lane, .audio-track-lane { flex-direction: column; align-items: stretch; gap: 15px; padding: 15px; }
|
||||
.track-lane .track-info, .audio-track-lane .track-info, .track-lane .track-controls, .audio-track-lane .track-controls { width: 100%; border: none; padding: 0; }
|
||||
.track-lane .track-controls, .audio-track-lane .track-controls { justify-content: space-around; }
|
||||
}
|
||||
|
||||
.spectrogram-view-wrapper {
|
||||
position: relative; /* Essencial para o posicionamento absoluto do filho */
|
||||
overflow: hidden; /* Garante que a agulha não saia dos limites */
|
||||
}
|
||||
|
||||
.playhead {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0; /* A posição será atualizada via JavaScript */
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background-color: var(--accent-red, #e74c3c); /* Use uma cor de destaque */
|
||||
z-index: 10;
|
||||
pointer-events: none; /* Impede que a agulha intercepte cliques do mouse */
|
||||
transition: background-color 0.3s; /* Efeito suave ao parar */
|
||||
}
|
||||
|
||||
/* Estilo para o botão de loop na barra de ferramentas do editor de áudio */
|
||||
#audio-editor-loop-btn {
|
||||
color: var(--text-dark); /* Cor padrão quando está desligado */
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
#audio-editor-loop-btn.active {
|
||||
color: var(--accent-green); /* Cor de destaque quando está ligado */
|
||||
}
|
||||
/* =============================================== */
|
||||
/* SCROLLBARS
|
||||
/* =============================================== */
|
||||
::-webkit-scrollbar { height: 10px; width: 10px; }
|
||||
::-webkit-scrollbar-track { background: var(--border-color); }
|
||||
::-webkit-scrollbar-thumb { background: var(--bg-toolbar); border-radius: 5px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #555; }
|
||||
|
|
@ -1,316 +1,34 @@
|
|||
// js/audio.js
|
||||
import { appState } from "./state.js";
|
||||
import { highlightStep, updateAudioEditorUI, updatePlayheadVisual, resetPlayheadVisual } from "./ui.js";
|
||||
import { getTotalSteps } from "./utils.js";
|
||||
import { PIXELS_PER_STEP } from "./config.js";
|
||||
|
||||
let audioContext;
|
||||
let mainGainNode;
|
||||
let masterPannerNode;
|
||||
|
||||
const timerDisplay = document.getElementById('timer-display');
|
||||
// O contexto de áudio agora será gerenciado principalmente pelo Tone.js.
|
||||
// Esta função garante que ele seja iniciado por uma interação do usuário.
|
||||
export function initializeAudioContext() {
|
||||
if (Tone.context.state !== 'running') {
|
||||
Tone.start();
|
||||
console.log("AudioContext iniciado com Tone.js");
|
||||
}
|
||||
}
|
||||
|
||||
// Funções de acesso ao contexto global do Tone.js
|
||||
export function getAudioContext() {
|
||||
return audioContext;
|
||||
return Tone.context;
|
||||
}
|
||||
export function getMainGainNode() {
|
||||
return mainGainNode;
|
||||
}
|
||||
|
||||
export function initializeAudioContext() {
|
||||
if (!audioContext) {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
mainGainNode = audioContext.createGain();
|
||||
masterPannerNode = audioContext.createStereoPanner();
|
||||
|
||||
mainGainNode.connect(masterPannerNode);
|
||||
masterPannerNode.connect(audioContext.destination);
|
||||
}
|
||||
if (audioContext.state === "suspended") {
|
||||
audioContext.resume();
|
||||
}
|
||||
return Tone.Destination;
|
||||
}
|
||||
|
||||
// Funções para controlar o volume e pan master
|
||||
export function updateMasterVolume(volume) {
|
||||
if (mainGainNode) {
|
||||
mainGainNode.gain.setValueAtTime(volume, audioContext.currentTime);
|
||||
// Tone.Destination.volume.value é em decibéis. Convertemos de linear (0-1.5) para dB.
|
||||
if (volume === 0) {
|
||||
Tone.Destination.volume.value = -Infinity;
|
||||
} else {
|
||||
Tone.Destination.volume.value = Tone.gainToDb(volume);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateMasterPan(pan) {
|
||||
if (masterPannerNode) {
|
||||
masterPannerNode.pan.setValueAtTime(pan, audioContext.currentTime);
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(milliseconds) {
|
||||
const totalSeconds = Math.floor(milliseconds / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0');
|
||||
const seconds = (totalSeconds % 60).toString().padStart(2, '0');
|
||||
const centiseconds = Math.floor((milliseconds % 1000) / 10).toString().padStart(2, '0');
|
||||
return `${minutes}:${seconds}:${centiseconds}`;
|
||||
}
|
||||
|
||||
export function playMetronomeSound(isDownbeat) {
|
||||
initializeAudioContext();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
const frequency = isDownbeat ? 1000 : 800;
|
||||
oscillator.frequency.setValueAtTime(frequency, audioContext.currentTime);
|
||||
oscillator.type = "sine";
|
||||
gainNode.gain.setValueAtTime(1, audioContext.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(
|
||||
0.00001,
|
||||
audioContext.currentTime + 0.05
|
||||
);
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(mainGainNode);
|
||||
oscillator.start(audioContext.currentTime);
|
||||
oscillator.stop(audioContext.currentTime + 0.05);
|
||||
}
|
||||
|
||||
export function playSample(filePath, trackId) {
|
||||
initializeAudioContext();
|
||||
if (!filePath) return;
|
||||
|
||||
const track = trackId ? appState.tracks.find((t) => t.id == trackId) : null;
|
||||
|
||||
if (!track || !track.audioBuffer) {
|
||||
const audio = new Audio(filePath);
|
||||
audio.play();
|
||||
return;
|
||||
}
|
||||
|
||||
const source = audioContext.createBufferSource();
|
||||
source.buffer = track.audioBuffer;
|
||||
|
||||
if (track.gainNode) {
|
||||
source.connect(track.gainNode);
|
||||
} else {
|
||||
source.connect(mainGainNode);
|
||||
}
|
||||
|
||||
source.start(0);
|
||||
}
|
||||
|
||||
function tick() {
|
||||
const totalSteps = getTotalSteps();
|
||||
if (totalSteps === 0 || !appState.isPlaying) {
|
||||
stopPlayback();
|
||||
return;
|
||||
}
|
||||
|
||||
const lastStepIndex = appState.currentStep === 0 ? totalSteps - 1 : appState.currentStep - 1;
|
||||
highlightStep(lastStepIndex, false);
|
||||
|
||||
const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120;
|
||||
const stepInterval = (60 * 1000) / (bpm * 4);
|
||||
const currentTime = appState.currentStep * stepInterval;
|
||||
if (timerDisplay) {
|
||||
timerDisplay.textContent = formatTime(currentTime);
|
||||
}
|
||||
|
||||
if (appState.metronomeEnabled) {
|
||||
const noteValue = parseInt(document.getElementById("compasso-b-input").value, 10) || 4;
|
||||
const stepsPerBeat = 16 / noteValue;
|
||||
if (appState.currentStep % stepsPerBeat === 0) {
|
||||
playMetronomeSound(appState.currentStep % (stepsPerBeat * 4) === 0);
|
||||
}
|
||||
}
|
||||
|
||||
appState.tracks.forEach((track) => {
|
||||
if (!track.patterns || track.patterns.length === 0) return;
|
||||
|
||||
const activePattern = track.patterns[appState.activePatternIndex];
|
||||
|
||||
if (activePattern && activePattern.steps[appState.currentStep] && track.samplePath) {
|
||||
playSample(track.samplePath, track.id);
|
||||
}
|
||||
});
|
||||
|
||||
highlightStep(appState.currentStep, true);
|
||||
appState.currentStep = (appState.currentStep + 1) % totalSteps;
|
||||
}
|
||||
|
||||
export function startPlayback() {
|
||||
if (appState.isPlaying || appState.tracks.length === 0) return;
|
||||
initializeAudioContext();
|
||||
|
||||
if (appState.currentStep === 0) {
|
||||
rewindPlayback();
|
||||
}
|
||||
|
||||
const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120;
|
||||
const stepInterval = (60 * 1000) / (bpm * 4);
|
||||
|
||||
if (appState.playbackIntervalId) clearInterval(appState.playbackIntervalId);
|
||||
|
||||
appState.isPlaying = true;
|
||||
document.getElementById("play-btn").classList.remove("fa-play");
|
||||
document.getElementById("play-btn").classList.add("fa-pause");
|
||||
|
||||
tick();
|
||||
appState.playbackIntervalId = setInterval(tick, stepInterval);
|
||||
}
|
||||
|
||||
export function stopPlayback() {
|
||||
if(appState.playbackIntervalId) {
|
||||
clearInterval(appState.playbackIntervalId);
|
||||
}
|
||||
appState.playbackIntervalId = null;
|
||||
appState.isPlaying = false;
|
||||
|
||||
document.querySelectorAll('.step.playing').forEach(s => s.classList.remove('playing'));
|
||||
|
||||
appState.currentStep = 0;
|
||||
|
||||
if (timerDisplay) timerDisplay.textContent = '00:00:00';
|
||||
|
||||
const playBtn = document.getElementById("play-btn");
|
||||
if (playBtn) {
|
||||
playBtn.classList.remove("fa-pause");
|
||||
playBtn.classList.add("fa-play");
|
||||
}
|
||||
}
|
||||
|
||||
export function rewindPlayback() {
|
||||
const lastStep = appState.currentStep > 0 ? appState.currentStep - 1 : getTotalSteps() - 1;
|
||||
appState.currentStep = 0;
|
||||
if (!appState.isPlaying) {
|
||||
if (timerDisplay) timerDisplay.textContent = '00:00:00';
|
||||
highlightStep(lastStep, false);
|
||||
}
|
||||
}
|
||||
|
||||
export function togglePlayback() {
|
||||
initializeAudioContext();
|
||||
if (appState.isPlaying) {
|
||||
stopPlayback();
|
||||
} else {
|
||||
appState.currentStep = 0;
|
||||
startPlayback();
|
||||
}
|
||||
}
|
||||
|
||||
function animationLoop() {
|
||||
if (!appState.isAudioEditorPlaying || !audioContext) return;
|
||||
|
||||
const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120;
|
||||
const stepsPerSecond = (bpm / 60) * 4;
|
||||
const pixelsPerSecond = stepsPerSecond * PIXELS_PER_STEP;
|
||||
|
||||
let totalElapsedTime = (audioContext.currentTime - appState.audioEditorStartTime) + appState.audioEditorPlaybackTime;
|
||||
|
||||
const maxDuration = appState.audioTracks.reduce((max, track) =>
|
||||
(track.audioBuffer && track.audioBuffer.duration > max) ? track.audioBuffer.duration : max, 0
|
||||
);
|
||||
|
||||
if (appState.isAudioEditorLoopEnabled && maxDuration > 0) {
|
||||
totalElapsedTime = totalElapsedTime % maxDuration;
|
||||
} else {
|
||||
if (totalElapsedTime >= maxDuration && maxDuration > 0) {
|
||||
stopAudioEditorPlayback();
|
||||
appState.audioEditorPlaybackTime = 0;
|
||||
resetPlayheadVisual();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const newPositionPx = totalElapsedTime * pixelsPerSecond;
|
||||
updatePlayheadVisual(newPositionPx);
|
||||
appState.audioEditorAnimationId = requestAnimationFrame(animationLoop);
|
||||
}
|
||||
|
||||
export function startAudioEditorPlayback() {
|
||||
if (appState.isAudioEditorPlaying || appState.audioTracks.length === 0) return;
|
||||
initializeAudioContext();
|
||||
|
||||
appState.isAudioEditorPlaying = true;
|
||||
appState.activeAudioSources = [];
|
||||
updateAudioEditorUI();
|
||||
|
||||
const startTime = audioContext.currentTime;
|
||||
appState.audioEditorStartTime = startTime;
|
||||
|
||||
appState.audioTracks.forEach(track => {
|
||||
if (track.audioBuffer && !track.isMuted && track.isSoloed) {
|
||||
if (appState.audioEditorPlaybackTime >= track.audioBuffer.duration) return;
|
||||
|
||||
const source = audioContext.createBufferSource();
|
||||
source.buffer = track.audioBuffer;
|
||||
source.loop = appState.isAudioEditorLoopEnabled;
|
||||
source.connect(track.gainNode);
|
||||
source.start(startTime, appState.audioEditorPlaybackTime);
|
||||
appState.activeAudioSources.push(source);
|
||||
}
|
||||
});
|
||||
|
||||
if (appState.activeAudioSources.length > 0) {
|
||||
if (appState.audioEditorAnimationId) {
|
||||
cancelAnimationFrame(appState.audioEditorAnimationId);
|
||||
}
|
||||
animationLoop();
|
||||
} else {
|
||||
appState.isAudioEditorPlaying = false;
|
||||
updateAudioEditorUI();
|
||||
}
|
||||
}
|
||||
|
||||
export function stopAudioEditorPlayback() {
|
||||
if (!appState.isAudioEditorPlaying) return;
|
||||
|
||||
let totalElapsedTime = (audioContext.currentTime - appState.audioEditorStartTime) + appState.audioEditorPlaybackTime;
|
||||
|
||||
const maxDuration = appState.audioTracks.reduce((max, track) =>
|
||||
(track.audioBuffer && track.audioBuffer.duration > max) ? track.audioBuffer.duration : max, 0
|
||||
);
|
||||
|
||||
// --- CORREÇÃO FINAL E ROBUSTA ---
|
||||
// Sempre aplica o módulo ao salvar o tempo.
|
||||
// Se não estava em loop, totalElapsedTime < maxDuration, e o módulo não faz nada.
|
||||
// Se estava em loop, ele corrige o valor para a posição visual correta.
|
||||
if (maxDuration > 0) {
|
||||
appState.audioEditorPlaybackTime = totalElapsedTime % maxDuration;
|
||||
} else {
|
||||
appState.audioEditorPlaybackTime = totalElapsedTime;
|
||||
}
|
||||
// --- FIM DA CORREÇÃO ---
|
||||
|
||||
if (appState.audioEditorAnimationId) {
|
||||
cancelAnimationFrame(appState.audioEditorAnimationId);
|
||||
appState.audioEditorAnimationId = null;
|
||||
}
|
||||
|
||||
appState.activeAudioSources.forEach(source => {
|
||||
try {
|
||||
source.stop(0);
|
||||
} catch (e) { /* Ignora erros */ }
|
||||
});
|
||||
|
||||
appState.activeAudioSources = [];
|
||||
appState.isAudioEditorPlaying = false;
|
||||
updateAudioEditorUI();
|
||||
}
|
||||
|
||||
export function seekAudioEditor(newTime) {
|
||||
const wasPlaying = appState.isAudioEditorPlaying;
|
||||
if (wasPlaying) {
|
||||
stopAudioEditorPlayback();
|
||||
}
|
||||
appState.audioEditorPlaybackTime = newTime;
|
||||
const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120;
|
||||
const stepsPerSecond = (bpm / 60) * 4;
|
||||
const pixelsPerSecond = stepsPerSecond * PIXELS_PER_STEP;
|
||||
const newPositionPx = newTime * pixelsPerSecond;
|
||||
updatePlayheadVisual(newPositionPx);
|
||||
if (wasPlaying) {
|
||||
startAudioEditorPlayback();
|
||||
}
|
||||
}
|
||||
|
||||
export function restartAudioEditorIfPlaying() {
|
||||
if (appState.isAudioEditorPlaying) {
|
||||
stopAudioEditorPlayback();
|
||||
startAudioEditorPlayback();
|
||||
}
|
||||
// A panorimização master em Tone.js geralmente requer um nó Panner dedicado.
|
||||
// Por enquanto, esta função servirá como um placeholder para futuras implementações.
|
||||
console.log("Master Pan ainda não implementado com Tone.js");
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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';
|
||||
});
|
||||
}
|
||||
|
|
@ -12,3 +12,6 @@ export const DEFAULT_PAN = 0.0;
|
|||
// Constantes para o layout do editor de áudio
|
||||
export const PIXELS_PER_STEP = 32; // Cada step (1/16) terá 32px de largura
|
||||
export const PIXELS_PER_BAR = 512; // 16 steps * 32px/step = 512px por compasso (bar)
|
||||
|
||||
// Níveis de zoom pré-definidos (fator de multiplicação)
|
||||
export const ZOOM_LEVELS = [0.25, 0.5, 1.0, 2.0, 4.0, 8.0];
|
||||
|
|
@ -1,13 +1,9 @@
|
|||
// js/file.js
|
||||
import { appState, loadAudioForTrack } from "./state.js";
|
||||
import { getTotalSteps } from "./utils.js";
|
||||
import { renderApp, getSamplePathMap } from "./ui.js";
|
||||
import { DEFAULT_PAN, DEFAULT_VOLUME, NOTE_LENGTH, TICKS_PER_BAR } from "./config.js";
|
||||
import {
|
||||
initializeAudioContext,
|
||||
getAudioContext,
|
||||
getMainGainNode,
|
||||
} from "./audio.js";
|
||||
import { appState, resetProjectState } from "./state.js";
|
||||
import { loadAudioForTrack } from "./pattern/pattern_state.js";
|
||||
import { renderAll, getSamplePathMap } from "./ui.js";
|
||||
import { DEFAULT_PAN, DEFAULT_VOLUME, NOTE_LENGTH } from "./config.js";
|
||||
import { initializeAudioContext, getAudioContext, getMainGainNode } from "./audio.js";
|
||||
|
||||
export async function handleFileLoad(file) {
|
||||
let xmlContent = "";
|
||||
|
|
@ -31,12 +27,31 @@ export async function handleFileLoad(file) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function loadProjectFromServer(fileName) {
|
||||
try {
|
||||
const response = await fetch(`mmp/${fileName}`);
|
||||
if (!response.ok)
|
||||
throw new Error(`Não foi possível carregar o arquivo ${fileName}`);
|
||||
|
||||
const xmlContent = await response.text();
|
||||
await parseMmpContent(xmlContent);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar projeto do servidor:", error);
|
||||
console.error(error);
|
||||
alert(`Erro ao carregar projeto: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseMmpContent(xmlString) {
|
||||
resetProjectState();
|
||||
initializeAudioContext();
|
||||
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(xmlString, "application/xml");
|
||||
|
||||
appState.originalXmlDoc = xmlDoc;
|
||||
appState.global.originalXmlDoc = xmlDoc;
|
||||
let newTracks = [];
|
||||
|
||||
const head = xmlDoc.querySelector("head");
|
||||
|
|
@ -48,25 +63,27 @@ export async function parseMmpContent(xmlString) {
|
|||
|
||||
const allBBTrackNodes = Array.from(xmlDoc.querySelectorAll('song > trackcontainer[type="song"] > track[type="1"]'));
|
||||
if (allBBTrackNodes.length === 0) {
|
||||
appState.tracks = []; renderApp(); return;
|
||||
appState.pattern.tracks = [];
|
||||
renderAll();
|
||||
return;
|
||||
}
|
||||
|
||||
// --- INÍCIO DA CORREÇÃO FINAL DE ORDENAÇÃO ---
|
||||
// A lista de NOMES é ordenada em ordem CRESCENTE (a ordem correta, cronológica).
|
||||
const sortedBBTrackNameNodes = [...allBBTrackNodes].sort((a, b) => {
|
||||
const bbtcoA = a.querySelector('bbtco');
|
||||
const bbtcoB = b.querySelector('bbtco');
|
||||
const posA = bbtcoA ? parseInt(bbtcoA.getAttribute('pos'), 10) : Infinity;
|
||||
const posB = bbtcoB ? parseInt(bbtcoB.getAttribute('pos'), 10) : Infinity;
|
||||
return posA - posB; // Ordem crescente
|
||||
return posA - posB;
|
||||
});
|
||||
|
||||
const dataSourceTrack = allBBTrackNodes[0];
|
||||
appState.currentBeatBasslineName = dataSourceTrack.getAttribute("name") || "Beat/Bassline";
|
||||
appState.global.currentBeatBasslineName = dataSourceTrack.getAttribute("name") || "Beat/Bassline";
|
||||
|
||||
const bbTrackContainer = dataSourceTrack.querySelector('bbtrack > trackcontainer');
|
||||
if (!bbTrackContainer) {
|
||||
appState.tracks = []; renderApp(); return;
|
||||
appState.pattern.tracks = [];
|
||||
renderAll();
|
||||
return;
|
||||
}
|
||||
|
||||
const instrumentTracks = bbTrackContainer.querySelectorAll('track[type="0"]');
|
||||
|
|
@ -84,13 +101,11 @@ export async function parseMmpContent(xmlString) {
|
|||
}
|
||||
|
||||
const allPatternsNodeList = trackNode.querySelectorAll("pattern");
|
||||
// A lista de CONTEÚDO dos patterns é ordenada de forma DECRESCENTE para corresponder.
|
||||
const allPatternsArray = Array.from(allPatternsNodeList).sort((a, b) => {
|
||||
const posA = parseInt(a.getAttribute('pos'), 10) || 0;
|
||||
const posB = parseInt(b.getAttribute('pos'), 10) || 0;
|
||||
return posB - posA; // Ordem decrescente
|
||||
return posB - posA;
|
||||
});
|
||||
// --- FIM DA CORREÇÃO FINAL DE ORDENAÇÃO ---
|
||||
|
||||
const patterns = sortedBBTrackNameNodes.map((bbTrack, index) => {
|
||||
const patternNode = allPatternsArray[index];
|
||||
|
|
@ -159,13 +174,15 @@ export async function parseMmpContent(xmlString) {
|
|||
|
||||
let isFirstTrackWithNotes = true;
|
||||
newTracks.forEach(track => {
|
||||
const audioContext = getAudioContext();
|
||||
track.gainNode = audioContext.createGain();
|
||||
track.pannerNode = audioContext.createStereoPanner();
|
||||
// --- INÍCIO DA CORREÇÃO ---
|
||||
// Cria os nós de áudio usando os construtores do Tone.js
|
||||
track.gainNode = new Tone.Gain(Tone.gainToDb(track.volume));
|
||||
track.pannerNode = new Tone.Panner(track.pan);
|
||||
|
||||
// Conecta a cadeia de áudio: Gain -> Panner -> Saída Principal (Destination)
|
||||
track.gainNode.connect(track.pannerNode);
|
||||
track.pannerNode.connect(getMainGainNode());
|
||||
track.gainNode.gain.value = track.volume;
|
||||
track.pannerNode.pan.value = track.pan;
|
||||
// --- FIM DA CORREÇÃO ---
|
||||
|
||||
if (isFirstTrackWithNotes) {
|
||||
const activeIdx = track.activePatternIndex || 0;
|
||||
|
|
@ -187,13 +204,13 @@ export async function parseMmpContent(xmlString) {
|
|||
console.error("Ocorreu um erro ao carregar os áudios do projeto:", error);
|
||||
}
|
||||
|
||||
appState.tracks = newTracks;
|
||||
appState.activeTrackId = appState.tracks[0]?.id || null;
|
||||
renderApp();
|
||||
appState.pattern.tracks = newTracks;
|
||||
appState.pattern.activeTrackId = appState.pattern.tracks[0]?.id || null;
|
||||
renderAll();
|
||||
}
|
||||
|
||||
export function generateMmpFile() {
|
||||
if (appState.originalXmlDoc) {
|
||||
if (appState.global.originalXmlDoc) {
|
||||
modifyAndSaveExistingMmp();
|
||||
} else {
|
||||
generateNewMmp();
|
||||
|
|
@ -202,11 +219,9 @@ export function generateMmpFile() {
|
|||
|
||||
function createTrackXml(track) {
|
||||
if (track.patterns.length === 0) return "";
|
||||
|
||||
const ticksPerStep = 12;
|
||||
const lmmsVolume = Math.round(track.volume * 100);
|
||||
const lmmsPan = Math.round(track.pan * 100);
|
||||
|
||||
const patternsXml = track.patterns.map(pattern => {
|
||||
const patternNotes = pattern.steps.map((isActive, index) => {
|
||||
if (isActive) {
|
||||
|
|
@ -215,12 +230,10 @@ function createTrackXml(track) {
|
|||
}
|
||||
return "";
|
||||
}).join("\n ");
|
||||
|
||||
return `<pattern type="0" pos="${pattern.pos}" muted="0" steps="${pattern.steps.length}" name="${pattern.name}">
|
||||
${patternNotes}
|
||||
</pattern>`;
|
||||
}).join('\n ');
|
||||
|
||||
return `
|
||||
<track type="0" solo="0" muted="0" name="${track.name}">
|
||||
<instrumenttrack vol="${lmmsVolume}" pitch="0" fxch="0" pitchrange="1" basenote="57" usemasterpitch="1" pan="${lmmsPan}">
|
||||
|
|
@ -235,39 +248,23 @@ function createTrackXml(track) {
|
|||
|
||||
function modifyAndSaveExistingMmp() {
|
||||
console.log("Modificando arquivo .mmp existente...");
|
||||
const xmlDoc = appState.originalXmlDoc.cloneNode(true);
|
||||
const xmlDoc = appState.global.originalXmlDoc.cloneNode(true);
|
||||
const head = xmlDoc.querySelector("head");
|
||||
if (head) {
|
||||
head.setAttribute("bpm", document.getElementById("bpm-input").value);
|
||||
head.setAttribute("num_bars", document.getElementById("bars-input").value);
|
||||
head.setAttribute(
|
||||
"timesig_numerator",
|
||||
document.getElementById("compasso-a-input").value
|
||||
);
|
||||
head.setAttribute(
|
||||
"timesig_denominator",
|
||||
document.getElementById("compasso-b-input").value
|
||||
);
|
||||
head.setAttribute("timesig_numerator", document.getElementById("compasso-a-input").value);
|
||||
head.setAttribute("timesig_denominator", document.getElementById("compasso-b-input").value);
|
||||
}
|
||||
|
||||
const bbTrackContainer = xmlDoc.querySelector('track[type="1"] > bbtrack > trackcontainer');
|
||||
|
||||
if (bbTrackContainer) {
|
||||
bbTrackContainer.querySelectorAll('track[type="0"]').forEach(node => node.remove());
|
||||
|
||||
const tracksXml = appState.tracks
|
||||
.map((track) => createTrackXml(track))
|
||||
.join("");
|
||||
|
||||
const tempDoc = new DOMParser().parseFromString(
|
||||
`<root>${tracksXml}</root>`,
|
||||
"application/xml"
|
||||
);
|
||||
const tracksXml = appState.pattern.tracks.map((track) => createTrackXml(track)).join("");
|
||||
const tempDoc = new DOMParser().parseFromString(`<root>${tracksXml}</root>`, "application/xml");
|
||||
Array.from(tempDoc.documentElement.children).forEach((newTrackNode) => {
|
||||
bbTrackContainer.appendChild(newTrackNode);
|
||||
});
|
||||
}
|
||||
|
||||
const serializer = new XMLSerializer();
|
||||
const mmpContent = serializer.serializeToString(xmlDoc);
|
||||
downloadFile(mmpContent, "projeto_editado.mmp");
|
||||
|
|
@ -278,10 +275,7 @@ function generateNewMmp() {
|
|||
const sig_num = document.getElementById("compasso-a-input").value;
|
||||
const sig_den = document.getElementById("compasso-b-input").value;
|
||||
const num_bars = document.getElementById("bars-input").value;
|
||||
const tracksXml = appState.tracks
|
||||
.map((track) => createTrackXml(track))
|
||||
.join("");
|
||||
|
||||
const tracksXml = appState.pattern.tracks.map((track) => createTrackXml(track)).join("");
|
||||
const mmpContent = `<?xml version="1.0"?>
|
||||
<!DOCTYPE lmms-project>
|
||||
<lmms-project version="1.0" type="song" creator="MMPCreator" creatorversion="1.0">
|
||||
|
|
@ -321,18 +315,3 @@ function downloadFile(content, fileName) {
|
|||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export async function loadProjectFromServer(fileName) {
|
||||
try {
|
||||
const response = await fetch(`mmp/${fileName}`);
|
||||
if (!response.ok)
|
||||
throw new Error(`Não foi possível carregar o arquivo ${fileName}`);
|
||||
const xmlContent = await response.text();
|
||||
await parseMmpContent(xmlContent);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar projeto do servidor:", error);
|
||||
alert(`Erro ao carregar projeto: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +1,24 @@
|
|||
// js/main.js
|
||||
import {
|
||||
appState,
|
||||
addTrackToState,
|
||||
removeLastTrackFromState,
|
||||
} from "./state.js";
|
||||
import { appState, resetProjectState } from "./state.js";
|
||||
import { addTrackToState, removeLastTrackFromState } from "./pattern/pattern_state.js";
|
||||
import { addAudioTrackLane } from "./audio/audio_state.js";
|
||||
import { updateTransportLoop } from "./audio/audio_audio.js";
|
||||
import {
|
||||
togglePlayback,
|
||||
stopPlayback,
|
||||
rewindPlayback,
|
||||
initializeAudioContext,
|
||||
updateMasterVolume,
|
||||
updateMasterPan,
|
||||
} from "./pattern/pattern_audio.js";
|
||||
import {
|
||||
startAudioEditorPlayback,
|
||||
stopAudioEditorPlayback,
|
||||
restartAudioEditorIfPlaying,
|
||||
} from "./audio.js";
|
||||
} from "./audio/audio_audio.js";
|
||||
import { initializeAudioContext } from "./audio.js";
|
||||
import { handleFileLoad, generateMmpFile } from "./file.js";
|
||||
import {
|
||||
renderApp,
|
||||
redrawSequencer,
|
||||
loadAndRenderSampleBrowser,
|
||||
showOpenProjectModal,
|
||||
closeOpenProjectModal,
|
||||
handleSampleUpload,
|
||||
} from "./ui.js";
|
||||
import { renderAll, loadAndRenderSampleBrowser, showOpenProjectModal, closeOpenProjectModal } from "./ui.js";
|
||||
import { renderAudioEditor } from "./audio/audio_ui.js";
|
||||
import { adjustValue, enforceNumericInput } from "./utils.js";
|
||||
import { DEFAULT_PAN, DEFAULT_VOLUME } from "./config.js";
|
||||
import { ZOOM_LEVELS } from "./config.js";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const newProjectBtn = document.getElementById("new-project-btn");
|
||||
|
|
@ -39,8 +32,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
const audioEditorPlayBtn = document.getElementById("audio-editor-play-btn");
|
||||
const audioEditorStopBtn = document.getElementById("audio-editor-stop-btn");
|
||||
const audioEditorLoopBtn = document.getElementById("audio-editor-loop-btn");
|
||||
const addAudioTrackBtn = document.getElementById("add-audio-track-btn");
|
||||
const rewindBtn = document.getElementById("rewind-btn");
|
||||
const metronomeBtn = document.getElementById("metronome-btn");
|
||||
const sliceToolBtn = document.getElementById("slice-tool-btn");
|
||||
const mmpFileInput = document.getElementById("mmp-file-input");
|
||||
const sampleFileInput = document.getElementById("sample-file-input");
|
||||
const openProjectModal = document.getElementById("open-project-modal");
|
||||
|
|
@ -48,36 +43,19 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
const loadFromComputerBtn = document.getElementById("load-from-computer-btn");
|
||||
const sidebarToggle = document.getElementById("sidebar-toggle");
|
||||
const addBarBtn = document.getElementById("add-bar-btn");
|
||||
const masterVolumeKnob = document.getElementById("master-volume-knob");
|
||||
const masterPanKnob = document.getElementById("master-pan-knob");
|
||||
const zoomInBtn = document.getElementById("zoom-in-btn");
|
||||
const zoomOutBtn = document.getElementById("zoom-out-btn");
|
||||
|
||||
newProjectBtn.addEventListener("click", () => {
|
||||
if (
|
||||
appState.tracks.length > 0 &&
|
||||
!confirm("Você tem certeza? Alterações não salvas serão perdidas.")
|
||||
)
|
||||
return;
|
||||
Object.assign(appState, {
|
||||
tracks: [],
|
||||
audioTracks: [],
|
||||
activeTrackId: null,
|
||||
isPlaying: false,
|
||||
playbackIntervalId: null,
|
||||
currentStep: 0,
|
||||
metronomeEnabled: false,
|
||||
originalXmlDoc: null,
|
||||
currentBeatBasslineName: 'Novo Projeto',
|
||||
masterVolume: DEFAULT_VOLUME,
|
||||
masterPan: DEFAULT_PAN
|
||||
});
|
||||
if ((appState.pattern.tracks.length > 0 || appState.audio.clips.length > 0) && !confirm("Você tem certeza? Alterações não salvas serão perdidas.")) return;
|
||||
resetProjectState();
|
||||
document.getElementById('bpm-input').value = 140;
|
||||
document.getElementById('bars-input').value = 1;
|
||||
document.getElementById('compasso-a-input').value = 4;
|
||||
document.getElementById('compasso-b-input').value = 4;
|
||||
const titleElement = document.getElementById('beat-bassline-title');
|
||||
if(titleElement) titleElement.textContent = 'Novo Projeto';
|
||||
renderApp();
|
||||
setupMasterKnobs();
|
||||
renderAll();
|
||||
});
|
||||
|
||||
addBarBtn.addEventListener("click", () => {
|
||||
|
|
@ -85,188 +63,114 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
if (barsInput) adjustValue(barsInput, 1);
|
||||
});
|
||||
|
||||
function setupMasterKnobs() {
|
||||
function updateMasterKnobVisual(knobElement, controlType) {
|
||||
const indicator = knobElement.querySelector(".knob-indicator");
|
||||
if (!indicator) return;
|
||||
const minAngle = -135;
|
||||
const maxAngle = 135;
|
||||
let percentage = 0.5;
|
||||
let title = "";
|
||||
if (controlType === "volume") {
|
||||
const value = appState.masterVolume;
|
||||
percentage = value / 1.5;
|
||||
title = `Volume Master: ${Math.round(value * 100)}%`;
|
||||
} else {
|
||||
const value = appState.masterPan;
|
||||
percentage = (value + 1) / 2;
|
||||
const panDisplay = Math.round(value * 100);
|
||||
title = `Pan Master: ${ panDisplay === 0 ? "Centro" : panDisplay < 0 ? `${-panDisplay} L` : `${panDisplay} R` }`;
|
||||
}
|
||||
const angle = minAngle + percentage * (maxAngle - minAngle);
|
||||
indicator.style.transform = `translateX(-50%) rotate(${angle}deg)`;
|
||||
knobElement.title = title;
|
||||
}
|
||||
function addMasterKnobInteraction(knobElement, controlType) {
|
||||
knobElement.addEventListener("wheel", (e) => {
|
||||
e.preventDefault();
|
||||
const step = 0.05;
|
||||
const direction = e.deltaY < 0 ? 1 : -1;
|
||||
if (controlType === "volume") {
|
||||
const newValue = appState.masterVolume + direction * step;
|
||||
appState.masterVolume = Math.max(0, Math.min(1.5, newValue));
|
||||
updateMasterVolume(appState.masterVolume);
|
||||
} else {
|
||||
const newValue = appState.masterPan + direction * step;
|
||||
appState.masterPan = Math.max(-1, Math.min(1, newValue));
|
||||
updateMasterPan(appState.masterPan);
|
||||
}
|
||||
updateMasterKnobVisual(knobElement, controlType);
|
||||
});
|
||||
knobElement.addEventListener("mousedown", (e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const startY = e.clientY;
|
||||
const startValue = controlType === "volume" ? appState.masterVolume : appState.masterPan;
|
||||
document.body.classList.add("knob-dragging");
|
||||
function onMouseMove(moveEvent) {
|
||||
const deltaY = startY - moveEvent.clientY;
|
||||
const sensitivity = controlType === "volume" ? 150 : 200;
|
||||
const newValue = startValue + deltaY / sensitivity;
|
||||
if (controlType === "volume") {
|
||||
appState.masterVolume = Math.max(0, Math.min(1.5, newValue));
|
||||
updateMasterVolume(appState.masterVolume);
|
||||
} else {
|
||||
appState.masterPan = Math.max(-1, Math.min(1, newValue));
|
||||
updateMasterPan(appState.masterPan);
|
||||
}
|
||||
updateMasterKnobVisual(knobElement, controlType);
|
||||
}
|
||||
function onMouseUp() {
|
||||
document.body.classList.remove("knob-dragging");
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
}
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("mouseup", onMouseUp);
|
||||
});
|
||||
}
|
||||
addMasterKnobInteraction(masterVolumeKnob, "volume");
|
||||
updateMasterKnobVisual(masterVolumeKnob, "volume");
|
||||
addMasterKnobInteraction(masterPanKnob, "pan");
|
||||
updateMasterKnobVisual(masterPanKnob, "pan");
|
||||
}
|
||||
|
||||
openMmpBtn.addEventListener("click", showOpenProjectModal);
|
||||
loadFromComputerBtn.addEventListener("click", () => mmpFileInput.click());
|
||||
mmpFileInput.addEventListener("change", async (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
await handleFileLoad(file);
|
||||
closeOpenProjectModal();
|
||||
}
|
||||
});
|
||||
|
||||
mmpFileInput.addEventListener("change", (event) => { const file = event.target.files[0]; if (file) { handleFileLoad(file).then(() => closeOpenProjectModal()); } });
|
||||
uploadSampleBtn.addEventListener("click", () => sampleFileInput.click());
|
||||
|
||||
sampleFileInput.addEventListener("change", async (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("sampleFile", file);
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:5000/upload-sample', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
alert("Sample enviado com sucesso!");
|
||||
await loadAndRenderSampleBrowser();
|
||||
} else {
|
||||
throw new Error(result.error || "Erro desconhecido no servidor.");
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Erro ao enviar o sample:", error);
|
||||
alert(`Falha no upload: ${error.message}`);
|
||||
}
|
||||
|
||||
event.target.value = null;
|
||||
});
|
||||
|
||||
saveMmpBtn.addEventListener("click", generateMmpFile);
|
||||
addInstrumentBtn.addEventListener("click", addTrackToState);
|
||||
removeInstrumentBtn.addEventListener("click", removeLastTrackFromState);
|
||||
playBtn.addEventListener("click", togglePlayback);
|
||||
stopBtn.addEventListener("click", stopPlayback);
|
||||
rewindBtn.addEventListener("click", rewindPlayback);
|
||||
metronomeBtn.addEventListener("click", () => {
|
||||
initializeAudioContext();
|
||||
appState.metronomeEnabled = !appState.metronomeEnabled;
|
||||
metronomeBtn.classList.toggle("active", appState.metronomeEnabled);
|
||||
});
|
||||
metronomeBtn.addEventListener("click", () => { initializeAudioContext(); appState.global.metronomeEnabled = !appState.global.metronomeEnabled; metronomeBtn.classList.toggle("active", appState.global.metronomeEnabled); });
|
||||
if(sliceToolBtn) { sliceToolBtn.addEventListener("click", () => { appState.global.sliceToolActive = !appState.global.sliceToolActive; sliceToolBtn.classList.toggle("active", appState.global.sliceToolActive); document.body.classList.toggle("slice-tool-active", appState.global.sliceToolActive); }); }
|
||||
openModalCloseBtn.addEventListener("click", closeOpenProjectModal);
|
||||
openProjectModal.addEventListener("click", (e) => {
|
||||
if (e.target === openProjectModal) closeOpenProjectModal();
|
||||
});
|
||||
|
||||
// ### CORREÇÃO 2: Adicionada verificação 'if (icon)' ###
|
||||
sidebarToggle.addEventListener("click", () => {
|
||||
document.body.classList.toggle("sidebar-hidden");
|
||||
const icon = sidebarToggle.querySelector("i");
|
||||
icon.className = document.body.classList.contains("sidebar-hidden")
|
||||
? "fa-solid fa-caret-right"
|
||||
: "fa-solid fa-caret-left";
|
||||
if (icon) {
|
||||
icon.className = document.body.classList.contains("sidebar-hidden") ? "fa-solid fa-caret-right" : "fa-solid fa-caret-left";
|
||||
}
|
||||
});
|
||||
|
||||
const inputs = document.querySelectorAll(".value-input");
|
||||
inputs.forEach((input) => {
|
||||
input.addEventListener("input", (event) => {
|
||||
enforceNumericInput(event);
|
||||
if (appState.isPlaying && (event.target.id.startsWith("compasso-") || event.target.id === 'bars-input')) {
|
||||
stopPlayback();
|
||||
}
|
||||
if (event.target.id.startsWith("compasso-") || event.target.id === 'bars-input') {
|
||||
redrawSequencer();
|
||||
}
|
||||
});
|
||||
input.addEventListener("wheel", (event) => {
|
||||
event.preventDefault();
|
||||
const step = event.deltaY < 0 ? 1 : -1;
|
||||
adjustValue(event.target, step);
|
||||
if (appState.global.isPlaying && (event.target.id.startsWith("compasso-") || event.target.id === 'bars-input')) { stopPlayback(); }
|
||||
if (event.target.id.startsWith("compasso-") || event.target.id === 'bars-input' || event.target.id === 'bpm-input') { renderAll(); }
|
||||
});
|
||||
input.addEventListener("wheel", (event) => { event.preventDefault(); const step = event.deltaY < 0 ? 1 : -1; adjustValue(event.target, step); });
|
||||
});
|
||||
|
||||
const buttons = document.querySelectorAll(".adjust-btn");
|
||||
buttons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const targetId = button.dataset.target + "-input";
|
||||
const targetInput = document.getElementById(targetId);
|
||||
const step = parseInt(button.dataset.step, 10) || 1;
|
||||
if (targetInput) {
|
||||
adjustValue(targetInput, step);
|
||||
}
|
||||
buttons.forEach((button) => { button.addEventListener("click", () => { const targetId = button.dataset.target + "-input"; const targetInput = document.getElementById(targetId); const step = parseInt(button.dataset.step, 10) || 1; if (targetInput) { adjustValue(targetInput, step); } }); });
|
||||
|
||||
if (zoomInBtn) {
|
||||
zoomInBtn.addEventListener("click", () => {
|
||||
if (appState.global.zoomLevelIndex < ZOOM_LEVELS.length - 1) {
|
||||
appState.global.zoomLevelIndex++;
|
||||
renderAll();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
audioEditorPlayBtn.addEventListener("click", () => {
|
||||
if (appState.isAudioEditorPlaying) {
|
||||
stopAudioEditorPlayback();
|
||||
} else {
|
||||
startAudioEditorPlayback();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (zoomOutBtn) {
|
||||
zoomOutBtn.addEventListener("click", () => {
|
||||
if (appState.global.zoomLevelIndex > 0) {
|
||||
appState.global.zoomLevelIndex--;
|
||||
renderAll();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
audioEditorPlayBtn.addEventListener("click", () => { if (appState.global.isAudioEditorPlaying) { stopAudioEditorPlayback(); } else { startAudioEditorPlayback(); } });
|
||||
audioEditorStopBtn.addEventListener("click", stopAudioEditorPlayback);
|
||||
|
||||
// ### CORREÇÃO 1: Listeners duplicados combinados em um só ###
|
||||
// No main.js
|
||||
audioEditorLoopBtn.addEventListener("click", () => {
|
||||
appState.isAudioEditorLoopEnabled = !appState.isAudioEditorLoopEnabled;
|
||||
audioEditorLoopBtn.classList.toggle("active", appState.isAudioEditorLoopEnabled);
|
||||
console.log("--- Botão de Loop Clicado ---"); // DEBUG 1
|
||||
|
||||
// 1. Altera o estado global de loop
|
||||
appState.global.isLoopActive = !appState.global.isLoopActive;
|
||||
console.log("Estado appState.global.isLoopActive:", appState.global.isLoopActive); // DEBUG 2
|
||||
|
||||
// 2. Sincroniza o estado do loop do editor
|
||||
appState.audio.isAudioEditorLoopEnabled = appState.global.isLoopActive;
|
||||
|
||||
// 3. Atualiza a aparência do botão
|
||||
audioEditorLoopBtn.classList.toggle("active", appState.global.isLoopActive);
|
||||
|
||||
// 4. Sincroniza o Tone.Transport
|
||||
updateTransportLoop();
|
||||
|
||||
// 5. Mostra/esconde a área de loop
|
||||
const loopArea = document.getElementById("loop-region");
|
||||
|
||||
// ESTE É O TESTE MAIS IMPORTANTE:
|
||||
if (loopArea) {
|
||||
console.log("Elemento #loop-region ENCONTRADO. Alterando classe 'visible'."); // DEBUG 3
|
||||
loopArea.classList.toggle("visible", appState.global.isLoopActive);
|
||||
} else {
|
||||
console.error("ERRO GRAVE: Elemento #loop-region NÃO FOI ENCONTRADO!"); // DEBUG 4
|
||||
}
|
||||
|
||||
// 6. Reinicia o playback se estiver tocando
|
||||
restartAudioEditorIfPlaying();
|
||||
});
|
||||
|
||||
if (addAudioTrackBtn) { addAudioTrackBtn.addEventListener("click", () => { addAudioTrackLane(); renderAudioEditor(); }); }
|
||||
|
||||
// ### CORREÇÃO 3: Ordem de execução corrigida ###
|
||||
|
||||
// 1. Carrega o conteúdo do navegador de samples
|
||||
loadAndRenderSampleBrowser();
|
||||
renderApp();
|
||||
setupMasterKnobs();
|
||||
|
||||
// 2. Adiciona o listener DEPOIS que o conteúdo supostamente existe
|
||||
const browserContent = document.getElementById('browser-content');
|
||||
if (browserContent) {
|
||||
browserContent.addEventListener('click', function(event) {
|
||||
const folderName = event.target.closest('.folder-name');
|
||||
if (folderName) {
|
||||
const folderItem = folderName.parentElement;
|
||||
folderItem.classList.toggle('open');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Renderiza o resto
|
||||
renderAll();
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,25 +1,13 @@
|
|||
// js/state.js
|
||||
import { patternState, initializePatternState } from './pattern/pattern_state.js';
|
||||
import { audioState, initializeAudioState } from './audio/audio_state.js';
|
||||
import { DEFAULT_VOLUME, DEFAULT_PAN } from "./config.js";
|
||||
import {
|
||||
initializeAudioContext,
|
||||
getAudioContext,
|
||||
getMainGainNode,
|
||||
} from "./audio.js";
|
||||
import { renderApp, renderAudioEditor } from "./ui.js";
|
||||
import { getTotalSteps } from "./utils.js";
|
||||
|
||||
export let appState = {
|
||||
tracks: [],
|
||||
audioTracks: [],
|
||||
activeTrackId: null,
|
||||
activePatternIndex: 0,
|
||||
// Estado global da aplicação
|
||||
const globalState = {
|
||||
sliceToolActive: false,
|
||||
isPlaying: false,
|
||||
isAudioEditorPlaying: false,
|
||||
activeAudioSources: [],
|
||||
audioEditorStartTime: 0,
|
||||
audioEditorAnimationId: null,
|
||||
audioEditorPlaybackTime: 0,
|
||||
isAudioEditorLoopEnabled: false, // <-- ADICIONADO: Estado para controlar o loop
|
||||
playbackIntervalId: null,
|
||||
currentStep: 0,
|
||||
metronomeEnabled: false,
|
||||
|
|
@ -27,148 +15,40 @@ export let appState = {
|
|||
currentBeatBasslineName: 'Novo Projeto',
|
||||
masterVolume: DEFAULT_VOLUME,
|
||||
masterPan: DEFAULT_PAN,
|
||||
zoomLevelIndex: 2,
|
||||
|
||||
// --- ADICIONADO PARA A ÁREA DE LOOP ---
|
||||
isLoopActive: false, // O botão de loop principal agora controla este estado
|
||||
loopStartTime: 0, // Início do loop em segundos
|
||||
loopEndTime: 8, // Fim do loop em segundos (padrão de 4 compassos a 120BPM)
|
||||
};
|
||||
|
||||
export async function loadAudioForTrack(track) {
|
||||
if (!track.samplePath) return track;
|
||||
try {
|
||||
const audioContext = getAudioContext();
|
||||
if (!audioContext) initializeAudioContext();
|
||||
const response = await fetch(track.samplePath);
|
||||
if (!response.ok) throw new Error(`Erro ao buscar o sample: ${response.statusText}`);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
track.audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
} catch (error) {
|
||||
console.error(`Falha ao carregar áudio para a trilha ${track.name}:`, error);
|
||||
track.audioBuffer = null;
|
||||
}
|
||||
return track;
|
||||
}
|
||||
// Combina todos os estados em um único objeto namespaced
|
||||
export let appState = {
|
||||
global: globalState,
|
||||
pattern: patternState,
|
||||
audio: audioState,
|
||||
};
|
||||
|
||||
export function addAudioTrack(samplePath) {
|
||||
initializeAudioContext();
|
||||
const audioContext = getAudioContext();
|
||||
const mainGainNode = getMainGainNode();
|
||||
// Função para resetar o projeto para o estado inicial
|
||||
export function resetProjectState() {
|
||||
initializePatternState();
|
||||
initializeAudioState();
|
||||
|
||||
const newAudioTrack = {
|
||||
id: Date.now() + Math.random(),
|
||||
name: samplePath.split('/').pop(),
|
||||
samplePath: samplePath,
|
||||
audioBuffer: null,
|
||||
volume: DEFAULT_VOLUME,
|
||||
pan: DEFAULT_PAN,
|
||||
isMuted: false,
|
||||
isSoloed: false, // <-- ADICIONADO: Começa como não-solada
|
||||
gainNode: audioContext.createGain(),
|
||||
pannerNode: audioContext.createStereoPanner(),
|
||||
};
|
||||
|
||||
newAudioTrack.gainNode.connect(newAudioTrack.pannerNode);
|
||||
newAudioTrack.pannerNode.connect(mainGainNode);
|
||||
newAudioTrack.gainNode.gain.value = newAudioTrack.volume;
|
||||
newAudioTrack.pannerNode.pan.value = newAudioTrack.pan;
|
||||
|
||||
appState.audioTracks.push(newAudioTrack);
|
||||
|
||||
loadAudioForTrack(newAudioTrack).then(() => {
|
||||
renderAudioEditor();
|
||||
Object.assign(globalState, {
|
||||
sliceToolActive: false,
|
||||
isPlaying: false,
|
||||
isAudioEditorPlaying: false,
|
||||
playbackIntervalId: null,
|
||||
currentStep: 0,
|
||||
metronomeEnabled: false,
|
||||
originalXmlDoc: null,
|
||||
currentBeatBasslineName: 'Novo Projeto',
|
||||
masterVolume: DEFAULT_VOLUME,
|
||||
masterPan: DEFAULT_PAN,
|
||||
zoomLevelIndex: 2,
|
||||
isLoopActive: false,
|
||||
loopStartTime: 0,
|
||||
loopEndTime: 8,
|
||||
});
|
||||
}
|
||||
|
||||
// A função de mute agora será a de solo.
|
||||
export function toggleAudioTrackSolo(trackId) {
|
||||
const track = appState.audioTracks.find(t => t.id == trackId);
|
||||
if (track) {
|
||||
track.isSoloed = !track.isSoloed;
|
||||
renderAudioEditor(); // Re-renderiza para mostrar a nova cor
|
||||
}
|
||||
}
|
||||
|
||||
// Mantemos a função de mute caso precise no futuro, mas ela não está conectada ao botão.
|
||||
export function toggleAudioTrackMute(trackId) {
|
||||
const track = appState.audioTracks.find(t => t.id == trackId);
|
||||
if (track) {
|
||||
track.isMuted = !track.isMuted;
|
||||
renderAudioEditor();
|
||||
}
|
||||
}
|
||||
|
||||
export function addTrackToState() {
|
||||
initializeAudioContext();
|
||||
const audioContext = getAudioContext();
|
||||
const mainGainNode = getMainGainNode();
|
||||
const totalSteps = getTotalSteps();
|
||||
const referenceTrack = appState.tracks[0];
|
||||
|
||||
const newTrack = {
|
||||
id: Date.now(),
|
||||
name: "novo instrumento",
|
||||
samplePath: null,
|
||||
audioBuffer: null,
|
||||
patterns: referenceTrack
|
||||
? referenceTrack.patterns.map(p => ({ name: p.name, steps: new Array(p.steps.length).fill(false), pos: p.pos }))
|
||||
: [{ name: "Pattern 1", steps: new Array(totalSteps).fill(false), pos: 0 }],
|
||||
activePatternIndex: 0,
|
||||
volume: DEFAULT_VOLUME,
|
||||
pan: DEFAULT_PAN,
|
||||
gainNode: audioContext.createGain(),
|
||||
pannerNode: audioContext.createStereoPanner(),
|
||||
};
|
||||
newTrack.gainNode.connect(newTrack.pannerNode);
|
||||
newTrack.pannerNode.connect(mainGainNode);
|
||||
newTrack.gainNode.gain.value = newTrack.volume;
|
||||
newTrack.pannerNode.pan.value = newTrack.pan;
|
||||
|
||||
appState.tracks.push(newTrack);
|
||||
renderApp();
|
||||
}
|
||||
|
||||
export function removeLastTrackFromState() {
|
||||
if (appState.tracks.length > 0) {
|
||||
appState.tracks.pop();
|
||||
renderApp();
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTrackSample(trackId, samplePath) {
|
||||
const track = appState.tracks.find((t) => t.id == trackId);
|
||||
if (track) {
|
||||
track.samplePath = samplePath;
|
||||
track.name = samplePath.split("/").pop();
|
||||
track.audioBuffer = null;
|
||||
await loadAudioForTrack(track);
|
||||
renderApp();
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleStepState(trackId, stepIndex) {
|
||||
const track = appState.tracks.find((t) => t.id == trackId);
|
||||
if (track && track.patterns && track.patterns.length > 0) {
|
||||
const activePattern = track.patterns[track.activePatternIndex];
|
||||
if (activePattern && activePattern.steps.length > stepIndex) {
|
||||
activePattern.steps[stepIndex] = !activePattern.steps[stepIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function updateTrackVolume(trackId, volume) {
|
||||
const track = appState.tracks.find((t) => t.id == trackId) || appState.audioTracks.find((t) => t.id == trackId);
|
||||
if (track) {
|
||||
const clampedVolume = Math.max(0, Math.min(1.5, volume));
|
||||
track.volume = clampedVolume;
|
||||
if (track.gainNode) {
|
||||
track.gainNode.gain.setValueAtTime(clampedVolume, getAudioContext().currentTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function updateTrackPan(trackId, pan) {
|
||||
const track = appState.tracks.find((t) => t.id == trackId) || appState.audioTracks.find((t) => t.id == trackId);
|
||||
if (track) {
|
||||
const clampedPan = Math.max(-1, Math.min(1, pan));
|
||||
track.pan = clampedPan;
|
||||
if (track.pannerNode) {
|
||||
track.pannerNode.pan.setValueAtTime(clampedPan, getAudioContext().currentTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,110 +1,22 @@
|
|||
// js/ui.js
|
||||
import {
|
||||
appState,
|
||||
toggleStepState,
|
||||
updateTrackSample,
|
||||
updateTrackVolume,
|
||||
updateTrackPan,
|
||||
addAudioTrack,
|
||||
toggleAudioTrackSolo,
|
||||
} from "./state.js";
|
||||
import { playSample, stopPlayback, seekAudioEditor } from "./audio.js";
|
||||
import { getTotalSteps } from "./utils.js";
|
||||
import { playSample } from "./pattern/pattern_audio.js";
|
||||
import { renderPatternEditor } from "./pattern/pattern_ui.js";
|
||||
import { renderAudioEditor } from "./audio/audio_ui.js";
|
||||
import { loadProjectFromServer } from "./file.js";
|
||||
import { drawWaveform } from "./waveform.js";
|
||||
import { PIXELS_PER_STEP, PIXELS_PER_BAR } from "./config.js";
|
||||
|
||||
export function updateAudioEditorUI() {
|
||||
const playBtn = document.getElementById('audio-editor-play-btn');
|
||||
if (playBtn) {
|
||||
if (appState.isAudioEditorPlaying) {
|
||||
playBtn.classList.remove('fa-play');
|
||||
playBtn.classList.add('fa-pause');
|
||||
} else {
|
||||
playBtn.classList.remove('fa-pause');
|
||||
playBtn.classList.add('fa-play');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let samplePathMap = {};
|
||||
const globalPatternSelector = document.getElementById('global-pattern-selector');
|
||||
|
||||
if (globalPatternSelector) {
|
||||
globalPatternSelector.addEventListener('change', () => {
|
||||
stopPlayback();
|
||||
appState.activePatternIndex = parseInt(globalPatternSelector.value, 10);
|
||||
export function renderAll() {
|
||||
renderPatternEditor();
|
||||
renderAudioEditor();
|
||||
const loopArea = document.getElementById("loop-region");
|
||||
|
||||
const firstTrack = appState.tracks[0];
|
||||
if (firstTrack) {
|
||||
const activePattern = firstTrack.patterns[appState.activePatternIndex];
|
||||
if (activePattern) {
|
||||
const stepsPerBar = 16;
|
||||
const requiredBars = Math.ceil(activePattern.steps.length / stepsPerBar);
|
||||
document.getElementById("bars-input").value = requiredBars > 0 ? requiredBars : 1;
|
||||
}
|
||||
}
|
||||
redrawSequencer();
|
||||
});
|
||||
}
|
||||
|
||||
export function updateGlobalPatternSelector() {
|
||||
if (!globalPatternSelector) return;
|
||||
const referenceTrack = appState.tracks[0];
|
||||
globalPatternSelector.innerHTML = '';
|
||||
if (referenceTrack && referenceTrack.patterns.length > 0) {
|
||||
referenceTrack.patterns.forEach((pattern, index) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = index;
|
||||
option.textContent = pattern.name;
|
||||
globalPatternSelector.appendChild(option);
|
||||
});
|
||||
globalPatternSelector.selectedIndex = appState.activePatternIndex;
|
||||
globalPatternSelector.disabled = false;
|
||||
} else {
|
||||
const option = document.createElement('option');
|
||||
option.textContent = 'Sem patterns';
|
||||
globalPatternSelector.appendChild(option);
|
||||
globalPatternSelector.disabled = true;
|
||||
if (loopArea) {
|
||||
// Sincroniza a visibilidade da área de loop com o estado atual
|
||||
loopArea.classList.toggle("visible", appState.global.isLoopActive);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleSampleUpload(file) {
|
||||
const validExtensions = ['.wav', '.flac', '.ogg', '.mp3'];
|
||||
const fileExtension = '.' + file.name.split('.').pop().toLowerCase();
|
||||
|
||||
if (!validExtensions.includes(fileExtension)) {
|
||||
alert("Formato de arquivo inválido. Por favor, envie .wav, .flac, .ogg, ou .mp3.");
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const dataURL = e.target.result;
|
||||
const browserContent = document.getElementById("browser-content");
|
||||
const list = browserContent.querySelector("ul");
|
||||
|
||||
if (list) {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML = `<i class="fa-solid fa-file-audio"></i> ${file.name}`;
|
||||
li.setAttribute("draggable", true);
|
||||
|
||||
li.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
playSample(dataURL, null);
|
||||
});
|
||||
|
||||
li.addEventListener("dragstart", (event) => {
|
||||
event.dataTransfer.setData("text/plain", dataURL);
|
||||
event.dataTransfer.effectAllowed = "copy";
|
||||
});
|
||||
|
||||
list.prepend(li);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
export function getSamplePathMap() {
|
||||
return samplePathMap;
|
||||
}
|
||||
|
|
@ -122,383 +34,11 @@ function buildSamplePathMap(tree, currentPath) {
|
|||
}
|
||||
}
|
||||
|
||||
export function renderAudioEditor() {
|
||||
const audioEditor = document.querySelector('.audio-editor');
|
||||
const audioTrackContainer = document.getElementById('audio-track-container');
|
||||
if (!audioEditor || !audioTrackContainer) return;
|
||||
|
||||
audioEditor.ondragover = (e) => {
|
||||
e.preventDefault();
|
||||
audioEditor.classList.add("drag-over");
|
||||
};
|
||||
audioEditor.ondragleave = () => {
|
||||
audioEditor.classList.remove("drag-over");
|
||||
};
|
||||
audioEditor.ondrop = (e) => {
|
||||
e.preventDefault();
|
||||
audioEditor.classList.remove("drag-over");
|
||||
const filePath = e.dataTransfer.getData("text/plain");
|
||||
if (filePath) {
|
||||
addAudioTrack(filePath);
|
||||
}
|
||||
};
|
||||
|
||||
audioTrackContainer.innerHTML = '';
|
||||
|
||||
appState.audioTracks.forEach(trackData => {
|
||||
const audioTrackLane = document.createElement('div');
|
||||
audioTrackLane.className = 'audio-track-lane';
|
||||
audioTrackLane.dataset.trackId = trackData.id;
|
||||
|
||||
audioTrackLane.innerHTML = `
|
||||
<div class="track-info">
|
||||
<i class="fa-solid fa-gear"></i>
|
||||
<div class="track-solo-btn"></div>
|
||||
<span class="track-name">${trackData.name}</span>
|
||||
</div>
|
||||
<div class="track-controls">
|
||||
<div class="knob-container">
|
||||
<div class="knob" data-control="volume" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
|
||||
<span>VOL</span>
|
||||
</div>
|
||||
<div class="knob-container">
|
||||
<div class="knob" data-control="pan" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
|
||||
<span>PAN</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="spectrogram-view-wrapper">
|
||||
<div class="spectrogram-view-grid">
|
||||
<div class="playhead"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const grid = audioTrackLane.querySelector('.spectrogram-view-grid');
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.className = 'waveform-canvas';
|
||||
canvas.height = 60;
|
||||
grid.prepend(canvas);
|
||||
|
||||
audioTrackContainer.appendChild(audioTrackLane);
|
||||
|
||||
if (trackData.audioBuffer) {
|
||||
const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120;
|
||||
const sampleDuration = trackData.audioBuffer.duration;
|
||||
|
||||
const stepsPerSecond = (bpm / 60) * 4;
|
||||
const totalSteps = sampleDuration * stepsPerSecond;
|
||||
|
||||
const canvasWidth = totalSteps * PIXELS_PER_STEP;
|
||||
canvas.width = canvasWidth;
|
||||
|
||||
drawWaveform(canvas, trackData.audioBuffer, 'var(--accent-green)');
|
||||
|
||||
const numberOfBars = Math.ceil(canvasWidth / PIXELS_PER_BAR);
|
||||
for (let i = 0; i < numberOfBars; i++) {
|
||||
if (i === 0) continue;
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'bar-marker';
|
||||
marker.textContent = i + 1;
|
||||
marker.style.left = `${i * PIXELS_PER_BAR}px`;
|
||||
grid.appendChild(marker);
|
||||
}
|
||||
}
|
||||
|
||||
const soloButton = audioTrackLane.querySelector('.track-solo-btn');
|
||||
if (soloButton) {
|
||||
if (trackData.isSoloed) {
|
||||
soloButton.classList.add('active');
|
||||
}
|
||||
soloButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
toggleAudioTrackSolo(trackData.id);
|
||||
});
|
||||
}
|
||||
|
||||
const volumeKnob = audioTrackLane.querySelector('.knob[data-control="volume"]');
|
||||
addKnobInteraction(volumeKnob);
|
||||
updateKnobVisual(volumeKnob, "volume");
|
||||
|
||||
const panKnob = audioTrackLane.querySelector('.knob[data-control="pan"]');
|
||||
addKnobInteraction(panKnob);
|
||||
updateKnobVisual(panKnob, "pan");
|
||||
|
||||
const waveformWrapper = audioTrackLane.querySelector('.spectrogram-view-wrapper');
|
||||
const handleSeek = (event) => {
|
||||
const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120;
|
||||
const stepsPerSecond = (bpm / 60) * 4;
|
||||
const pixelsPerSecond = stepsPerSecond * PIXELS_PER_STEP;
|
||||
|
||||
const rect = waveformWrapper.getBoundingClientRect();
|
||||
const clickX = event.clientX - rect.left;
|
||||
const scrollLeft = waveformWrapper.scrollLeft;
|
||||
const absoluteX = clickX + scrollLeft;
|
||||
|
||||
const newTime = absoluteX / pixelsPerSecond;
|
||||
|
||||
seekAudioEditor(newTime);
|
||||
};
|
||||
|
||||
waveformWrapper.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
handleSeek(e);
|
||||
const onMouseMove = (moveEvent) => handleSeek(moveEvent);
|
||||
const onMouseUp = () => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function renderApp() {
|
||||
const trackContainer = document.getElementById("track-container");
|
||||
trackContainer.innerHTML = "";
|
||||
|
||||
appState.tracks.forEach((trackData) => {
|
||||
const trackLane = document.createElement("div");
|
||||
trackLane.className = "track-lane";
|
||||
trackLane.dataset.trackId = trackData.id;
|
||||
|
||||
if (trackData.id === appState.activeTrackId) {
|
||||
trackLane.classList.add('active-track');
|
||||
}
|
||||
|
||||
trackLane.innerHTML = `
|
||||
<div class="track-info">
|
||||
<i class="fa-solid fa-gear"></i>
|
||||
<div class="track-mute"></div>
|
||||
<span class="track-name">${trackData.name}</span>
|
||||
</div>
|
||||
<div class="track-controls">
|
||||
<div class="knob-container">
|
||||
<div class="knob" data-control="volume" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
|
||||
<span>VOL</span>
|
||||
</div>
|
||||
<div class="knob-container">
|
||||
<div class="knob" data-control="pan" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
|
||||
<span>PAN</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-sequencer-wrapper"></div>
|
||||
`;
|
||||
|
||||
trackLane.addEventListener('click', () => {
|
||||
if (appState.activeTrackId === trackData.id) return;
|
||||
stopPlayback();
|
||||
appState.activeTrackId = trackData.id;
|
||||
document.querySelectorAll('.track-lane').forEach(lane => lane.classList.remove('active-track'));
|
||||
trackLane.classList.add('active-track');
|
||||
updateGlobalPatternSelector();
|
||||
redrawSequencer();
|
||||
});
|
||||
|
||||
trackLane.addEventListener("dragover", (e) => { e.preventDefault(); trackLane.classList.add("drag-over"); });
|
||||
trackLane.addEventListener("dragleave", () => trackLane.classList.remove("drag-over"));
|
||||
trackLane.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
trackLane.classList.remove("drag-over");
|
||||
const filePath = e.dataTransfer.getData("text/plain");
|
||||
if (filePath) {
|
||||
updateTrackSample(trackData.id, filePath);
|
||||
}
|
||||
});
|
||||
|
||||
trackContainer.appendChild(trackLane);
|
||||
const volumeKnob = trackLane.querySelector(".knob[data-control='volume']");
|
||||
addKnobInteraction(volumeKnob);
|
||||
updateKnobVisual(volumeKnob, "volume");
|
||||
const panKnob = trackLane.querySelector(".knob[data-control='pan']");
|
||||
addKnobInteraction(panKnob);
|
||||
updateKnobVisual(panKnob, "pan");
|
||||
});
|
||||
|
||||
updateGlobalPatternSelector();
|
||||
redrawSequencer();
|
||||
renderAudioEditor();
|
||||
}
|
||||
|
||||
export function redrawSequencer() {
|
||||
const totalGridSteps = getTotalSteps();
|
||||
document.querySelectorAll(".step-sequencer-wrapper").forEach((wrapper) => {
|
||||
let sequencerContainer = wrapper.querySelector(".step-sequencer");
|
||||
if (!sequencerContainer) {
|
||||
sequencerContainer = document.createElement("div");
|
||||
sequencerContainer.className = "step-sequencer";
|
||||
wrapper.appendChild(sequencerContainer);
|
||||
}
|
||||
|
||||
const parentTrackElement = wrapper.closest(".track-lane");
|
||||
const trackId = parentTrackElement.dataset.trackId;
|
||||
const trackData = appState.tracks.find((t) => t.id == trackId);
|
||||
|
||||
if (!trackData || !trackData.patterns || trackData.patterns.length === 0) {
|
||||
sequencerContainer.innerHTML = ""; return;
|
||||
}
|
||||
|
||||
const activePattern = trackData.patterns[appState.activePatternIndex];
|
||||
if (!activePattern) {
|
||||
sequencerContainer.innerHTML = ""; return;
|
||||
}
|
||||
const patternSteps = activePattern.steps;
|
||||
|
||||
sequencerContainer.innerHTML = "";
|
||||
for (let i = 0; i < totalGridSteps; i++) {
|
||||
const stepWrapper = document.createElement("div");
|
||||
stepWrapper.className = "step-wrapper";
|
||||
const stepElement = document.createElement("div");
|
||||
stepElement.className = "step";
|
||||
|
||||
if (patternSteps[i] === true) {
|
||||
stepElement.classList.add("active");
|
||||
}
|
||||
|
||||
stepElement.addEventListener("click", () => {
|
||||
toggleStepState(trackData.id, i);
|
||||
stepElement.classList.toggle("active");
|
||||
if (trackData && trackData.samplePath) {
|
||||
playSample(trackData.samplePath, trackData.id);
|
||||
}
|
||||
});
|
||||
|
||||
const beatsPerBar = parseInt(document.getElementById("compasso-a-input").value, 10) || 4;
|
||||
const groupIndex = Math.floor(i / beatsPerBar);
|
||||
if (groupIndex % 2 === 0) {
|
||||
stepElement.classList.add("step-dark");
|
||||
}
|
||||
|
||||
const stepsPerBar = 16;
|
||||
if (i > 0 && i % stepsPerBar === 0) {
|
||||
const marker = document.createElement("div");
|
||||
marker.className = "step-marker";
|
||||
marker.textContent = Math.floor(i / stepsPerBar) + 1;
|
||||
stepWrapper.appendChild(marker);
|
||||
}
|
||||
|
||||
stepWrapper.appendChild(stepElement);
|
||||
sequencerContainer.appendChild(stepWrapper);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addKnobInteraction(knobElement) {
|
||||
const controlType = knobElement.dataset.control;
|
||||
knobElement.addEventListener("mousedown", (e) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
const trackId = knobElement.dataset.trackId;
|
||||
const defaultValue = controlType === "volume" ? 0.8 : 0.0;
|
||||
if (controlType === "volume") {
|
||||
updateTrackVolume(trackId, defaultValue);
|
||||
} else {
|
||||
updateTrackPan(trackId, defaultValue);
|
||||
}
|
||||
updateKnobVisual(knobElement, controlType);
|
||||
}
|
||||
});
|
||||
knobElement.addEventListener("mousedown", (e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const trackId = knobElement.dataset.trackId;
|
||||
const track = appState.tracks.find((t) => t.id == trackId) || appState.audioTracks.find((t) => t.id == trackId);
|
||||
if (!track) return;
|
||||
const startY = e.clientY;
|
||||
const startValue = controlType === "volume" ? track.volume : track.pan;
|
||||
document.body.classList.add("knob-dragging");
|
||||
function onMouseMove(moveEvent) {
|
||||
const deltaY = startY - moveEvent.clientY;
|
||||
const sensitivity = controlType === "volume" ? 150 : 200;
|
||||
const newValue = startValue + deltaY / sensitivity;
|
||||
if (controlType === "volume") {
|
||||
updateTrackVolume(trackId, newValue);
|
||||
} else {
|
||||
updateTrackPan(trackId, newValue);
|
||||
}
|
||||
updateKnobVisual(knobElement, controlType);
|
||||
}
|
||||
function onMouseUp() {
|
||||
document.body.classList.remove("knob-dragging");
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
}
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("mouseup", onMouseUp);
|
||||
});
|
||||
knobElement.addEventListener("wheel", (e) => {
|
||||
e.preventDefault();
|
||||
const trackId = knobElement.dataset.trackId;
|
||||
const track = appState.tracks.find((t) => t.id == trackId) || appState.audioTracks.find((t) => t.id == trackId);
|
||||
if (!track) return;
|
||||
const step = 0.05;
|
||||
const direction = e.deltaY < 0 ? 1 : -1;
|
||||
if (controlType === "volume") {
|
||||
const newValue = track.volume + direction * step;
|
||||
updateTrackVolume(trackId, newValue);
|
||||
} else {
|
||||
const newValue = track.pan + direction * step;
|
||||
updateTrackPan(trackId, newValue);
|
||||
}
|
||||
updateKnobVisual(knobElement, controlType);
|
||||
});
|
||||
}
|
||||
|
||||
function updateKnobVisual(knobElement, controlType) {
|
||||
const trackId = knobElement.dataset.trackId;
|
||||
const track = appState.tracks.find((t) => t.id == trackId) || appState.audioTracks.find((t) => t.id == trackId);
|
||||
if (!track) return;
|
||||
const indicator = knobElement.querySelector(".knob-indicator");
|
||||
if (!indicator) return;
|
||||
const minAngle = -135;
|
||||
const maxAngle = 135;
|
||||
let percentage = 0.5;
|
||||
let title = "";
|
||||
if (controlType === "volume") {
|
||||
const value = track.volume;
|
||||
const clampedValue = Math.max(0, Math.min(1.5, value));
|
||||
percentage = clampedValue / 1.5;
|
||||
title = `Volume: ${Math.round(clampedValue * 100)}%`;
|
||||
} else {
|
||||
const value = track.pan;
|
||||
const clampedValue = Math.max(-1, Math.min(1, value));
|
||||
percentage = (clampedValue + 1) / 2;
|
||||
const panDisplay = Math.round(clampedValue * 100);
|
||||
title = `Pan: ${
|
||||
panDisplay === 0
|
||||
? "Centro"
|
||||
: panDisplay < 0
|
||||
? `${-panDisplay} L`
|
||||
: `${panDisplay} R`
|
||||
}`;
|
||||
}
|
||||
const angle = minAngle + percentage * (maxAngle - minAngle);
|
||||
indicator.style.transform = `translateX(-50%) rotate(${angle}deg)`;
|
||||
knobElement.title = title;
|
||||
}
|
||||
|
||||
export function highlightStep(stepIndex, isActive) {
|
||||
if (stepIndex < 0) return;
|
||||
document.querySelectorAll(".track-lane").forEach((track) => {
|
||||
const stepWrapper = track.querySelector(
|
||||
`.step-sequencer .step-wrapper:nth-child(${stepIndex + 1})`
|
||||
);
|
||||
if (stepWrapper) {
|
||||
const stepElement = stepWrapper.querySelector(".step");
|
||||
if (stepElement) {
|
||||
stepElement.classList.toggle("playing", isActive);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadAndRenderSampleBrowser() {
|
||||
const browserContent = document.getElementById("browser-content");
|
||||
try {
|
||||
const response = await fetch(`metadata/samples-manifest.json?v=${Date.now()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error("Arquivo samples-manifest.json não encontrado.");
|
||||
}
|
||||
if (!response.ok) throw new Error("Arquivo samples-manifest.json não encontrado.");
|
||||
const fileTree = await response.json();
|
||||
|
||||
samplePathMap = {};
|
||||
|
|
@ -511,42 +51,65 @@ export async function loadAndRenderSampleBrowser() {
|
|||
}
|
||||
}
|
||||
|
||||
// Em ui.js, substitua a função antiga por esta:
|
||||
|
||||
function renderFileTree(tree, parentElement, currentPath) {
|
||||
parentElement.innerHTML = "";
|
||||
parentElement.innerHTML = ""; // Limpa o conteúdo anterior
|
||||
const ul = document.createElement("ul");
|
||||
|
||||
// Ordena para que as pastas sempre apareçam antes dos arquivos
|
||||
const sortedKeys = Object.keys(tree).sort((a, b) => {
|
||||
const aIsFile = tree[a]._isFile;
|
||||
const bIsFile = tree[b]._isFile;
|
||||
if (aIsFile === bIsFile) return a.localeCompare(b);
|
||||
return aIsFile ? 1 : -1;
|
||||
if (aIsFile === bIsFile) return a.localeCompare(b); // Ordena alfabeticamente se ambos forem do mesmo tipo
|
||||
return aIsFile ? 1 : -1; // Pastas (-1) vêm antes de arquivos (1)
|
||||
});
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
if (key === '_isFile') continue;
|
||||
if (key === '_isFile') continue; // Pula a propriedade de metadados
|
||||
|
||||
const node = tree[key];
|
||||
const li = document.createElement("li");
|
||||
const newPath = `${currentPath}/${key}`;
|
||||
|
||||
if (node._isFile) {
|
||||
li.innerHTML = `<i class="fa-solid fa-file-audio"></i> ${key}`;
|
||||
// --- LÓGICA PARA ARQUIVOS ---
|
||||
li.className = "file-item draggable-sample"; // CORREÇÃO: Adiciona classe para consistência
|
||||
li.innerHTML = `<i class="fa-solid fa-volume-high"></i> ${key}`; // Ícone mais apropriado
|
||||
li.setAttribute("draggable", true);
|
||||
li.dataset.path = newPath; // Guarda o caminho para o drag-and-drop
|
||||
|
||||
li.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
playSample(newPath, null);
|
||||
});
|
||||
|
||||
li.addEventListener("dragstart", (e) => {
|
||||
e.dataTransfer.setData("text/plain", newPath);
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
});
|
||||
|
||||
ul.appendChild(li);
|
||||
|
||||
} else {
|
||||
li.className = "directory";
|
||||
li.innerHTML = `<i class="fa-solid fa-folder"></i> ${key}`;
|
||||
// --- LÓGICA CORRIGIDA PARA PASTAS ---
|
||||
li.className = "folder-item"; // CORREÇÃO 1: Usa a classe CSS correta
|
||||
|
||||
// CORREÇÃO 2: Cria o <span> clicável para o nome da pasta, que o CSS e o main.js esperam
|
||||
const folderNameSpan = document.createElement("span");
|
||||
folderNameSpan.className = "folder-name";
|
||||
folderNameSpan.innerHTML = `<i class="folder-icon fa-solid fa-folder"></i> ${key}`;
|
||||
li.appendChild(folderNameSpan);
|
||||
|
||||
const nestedUl = document.createElement("ul");
|
||||
nestedUl.className = "file-list"; // CORREÇÃO: Adiciona classe para o CSS
|
||||
|
||||
// Chama a função recursivamente para os conteúdos da pasta
|
||||
renderFileTree(node, nestedUl, newPath);
|
||||
li.appendChild(nestedUl);
|
||||
li.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
li.classList.toggle("open");
|
||||
});
|
||||
|
||||
// CORREÇÃO 3: Remove o addEventListener de clique daqui. O main.js já cuida disso.
|
||||
|
||||
ul.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
|
@ -560,14 +123,12 @@ export async function showOpenProjectModal() {
|
|||
openProjectModal.classList.add("visible");
|
||||
try {
|
||||
const response = await fetch("metadata/mmp-manifest.json");
|
||||
if (!response.ok)
|
||||
throw new Error("Arquivo mmp-manifest.json não encontrado.");
|
||||
if (!response.ok) throw new Error("Arquivo mmp-manifest.json não encontrado.");
|
||||
const projects = await response.json();
|
||||
|
||||
serverProjectsList.innerHTML = "";
|
||||
if (projects.length === 0) {
|
||||
serverProjectsList.innerHTML =
|
||||
'<p style="color:var(--text-dark);">Nenhum projeto encontrado no servidor.</p>';
|
||||
serverProjectsList.innerHTML = '<p style="color:var(--text-dark);">Nenhum projeto encontrado no servidor.</p>';
|
||||
}
|
||||
|
||||
projects.forEach((projectName) => {
|
||||
|
|
@ -592,15 +153,3 @@ export function closeOpenProjectModal() {
|
|||
const openProjectModal = document.getElementById("open-project-modal");
|
||||
openProjectModal.classList.remove("visible");
|
||||
}
|
||||
|
||||
export function updatePlayheadVisual(pixels) {
|
||||
document.querySelectorAll('.audio-track-lane .playhead').forEach(ph => {
|
||||
ph.style.left = `${pixels}px`;
|
||||
});
|
||||
}
|
||||
|
||||
export function resetPlayheadVisual() {
|
||||
document.querySelectorAll('.audio-track-lane .playhead').forEach(ph => {
|
||||
ph.style.left = '0px';
|
||||
});
|
||||
}
|
||||
|
|
@ -1,5 +1,23 @@
|
|||
// js/utils.js
|
||||
import { appState } from './state.js';
|
||||
import { PIXELS_PER_STEP, ZOOM_LEVELS } from './config.js';
|
||||
|
||||
/**
|
||||
* Calcula a quantidade de pixels que representa um segundo na timeline,
|
||||
* levando em conta o BPM e o nível de zoom atual.
|
||||
* @returns {number} A quantidade de pixels por segundo.
|
||||
*/
|
||||
export function getPixelsPerSecond() {
|
||||
const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120;
|
||||
const stepsPerSecond = (bpm / 60) * 4;
|
||||
const zoomFactor = ZOOM_LEVELS[appState.global.zoomLevelIndex];
|
||||
return stepsPerSecond * PIXELS_PER_STEP * zoomFactor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula o número total de steps no sequenciador de patterns.
|
||||
* @returns {number} O número total de steps.
|
||||
*/
|
||||
export function getTotalSteps() {
|
||||
const barsInput = document.getElementById("bars-input");
|
||||
const compassoAInput = document.getElementById("compasso-a-input");
|
||||
|
|
@ -13,19 +31,29 @@ export function getTotalSteps() {
|
|||
return numberOfBars * beatsPerBar * subdivisions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Garante que apenas números sejam inseridos em um campo de input.
|
||||
* @param {Event} event - O evento de input.
|
||||
*/
|
||||
export function enforceNumericInput(event) {
|
||||
event.target.value = event.target.value.replace(/[^0-9]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajusta o valor de um elemento de input com base em um passo (step),
|
||||
* respeitando os limites de min/max definidos no elemento.
|
||||
* @param {HTMLInputElement} inputElement - O elemento de input a ser ajustado.
|
||||
* @param {number} step - O valor a ser adicionado (pode ser negativo).
|
||||
*/
|
||||
export function adjustValue(inputElement, step) {
|
||||
let currentValue = parseInt(inputElement.value, 10) || 0;
|
||||
let min = parseInt(inputElement.dataset.min, 10);
|
||||
let max = parseInt(inputElement.dataset.max, 10);
|
||||
let newValue = currentValue + step;
|
||||
|
||||
if (!isNaN(min) && newValue < min) newValue = min;
|
||||
if (!isNaN(max) && newValue > max) newValue = max;
|
||||
inputElement.value = newValue;
|
||||
|
||||
// Dispara um evento 'input' para que outros listeners (como o que redesenha o sequenciador) sejam acionados.
|
||||
inputElement.value = newValue;
|
||||
inputElement.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
|
@ -2,44 +2,61 @@
|
|||
|
||||
/**
|
||||
* Desenha a forma de onda de um AudioBuffer em um elemento Canvas.
|
||||
* Pode desenhar apenas um segmento específico do buffer.
|
||||
* @param {HTMLCanvasElement} canvas - O elemento canvas onde o desenho será feito.
|
||||
* @param {AudioBuffer} audioBuffer - O buffer de áudio decodificado da faixa.
|
||||
* @param {string} color - A cor da forma de onda (ex: '#2ecc71').
|
||||
* @param {number} [offset=0] - O tempo em segundos de onde começar a desenhar no AudioBuffer.
|
||||
* @param {number} [duration] - A duração em segundos do segmento a ser desenhado.
|
||||
*/
|
||||
export function drawWaveform(canvas, audioBuffer, color) {
|
||||
export function drawWaveform(canvas, audioBuffer, color, offset = 0, duration) {
|
||||
if (!canvas || !audioBuffer) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
const channelData = audioBuffer.getChannelData(0); // Pega os dados do primeiro canal
|
||||
const step = Math.ceil(channelData.length / width);
|
||||
const amp = height / 2; // Amplitude máxima do desenho
|
||||
const channelData = audioBuffer.getChannelData(0);
|
||||
const sampleRate = audioBuffer.sampleRate;
|
||||
|
||||
ctx.clearRect(0, 0, width, height); // Limpa o canvas
|
||||
// Se a duração não for fornecida, usa a duração total a partir do offset
|
||||
const finalDuration = duration || (audioBuffer.duration - offset);
|
||||
|
||||
// Calcula os índices de início e fim no array de dados do áudio
|
||||
const startIndex = Math.floor(offset * sampleRate);
|
||||
const endIndex = Math.floor((offset + finalDuration) * sampleRate);
|
||||
const totalSamplesInSegment = endIndex - startIndex;
|
||||
|
||||
const step = Math.ceil(totalSamplesInSegment / width);
|
||||
const amp = height / 2;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
|
||||
// Desenha a linha do meio (zero amplitude)
|
||||
ctx.moveTo(0, amp);
|
||||
ctx.lineTo(width, amp);
|
||||
|
||||
for (let i = 0; i < width; i++) {
|
||||
let min = 1.0;
|
||||
let max = -1.0;
|
||||
|
||||
// Encontra o valor mínimo e máximo para um bloco de amostras
|
||||
for (let j = 0; j < step; j++) {
|
||||
const datum = channelData[(i * step) + j];
|
||||
if (datum < min) {
|
||||
min = datum;
|
||||
}
|
||||
if (datum > max) {
|
||||
max = datum;
|
||||
// --- CORREÇÃO CRÍTICA AQUI ---
|
||||
// Calcula o índice da amostra considerando o startIndex do segmento
|
||||
const sampleIndex = startIndex + (i * step) + j;
|
||||
if (sampleIndex < channelData.length) {
|
||||
const datum = channelData[sampleIndex];
|
||||
if (datum < min) min = datum;
|
||||
if (datum > max) max = datum;
|
||||
}
|
||||
}
|
||||
|
||||
// Desenha a linha vertical para aquele ponto no tempo
|
||||
const x = i;
|
||||
const y_max = (1 + max) * amp;
|
||||
const y_min = (1 + min) * amp;
|
||||
// Ajusta o desenho para ser centrado verticalmente
|
||||
const y_max = (1 - max) * amp;
|
||||
const y_min = (1 - min) * amp;
|
||||
|
||||
ctx.moveTo(x, y_max);
|
||||
ctx.lineTo(x, y_min);
|
||||
|
|
|
|||
|
|
@ -123,42 +123,72 @@
|
|||
<span>Editor de Amostras de Áudio</span>
|
||||
|
||||
<div class="playback-controls">
|
||||
<i class="fa-solid fa-search-minus" id="zoom-out-btn" title="Zoom Out"></i>
|
||||
<i class="fa-solid fa-search-plus" id="zoom-in-btn" title="Zoom In"></i>
|
||||
<i class="fa-solid fa-scissors" id="slice-tool-btn" title="Ferramenta de Corte"></i>
|
||||
<i class="fa-solid fa-play" id="audio-editor-play-btn" title="Play/Pause"></i>
|
||||
<i class="fa-solid fa-stop" id="audio-editor-stop-btn" title="Stop"></i>
|
||||
<i class="fa-solid fa-repeat" id="audio-editor-loop-btn" title="Ativar/Desativar Loop"></i>
|
||||
<i class="fa-solid fa-plus" id="add-audio-track-btn" title="Adicionar Pista de Áudio"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div id="audio-track-container">
|
||||
<div class="audio-track-lane">
|
||||
<div class="track-info">
|
||||
<i class="fa-solid fa-gear"></i>
|
||||
<div class="track-mute"></div>
|
||||
<span class="track-name">bassslap02.ogg</span>
|
||||
</div>
|
||||
<div class="track-controls">
|
||||
<div class="knob-container">
|
||||
<div class="knob" data-control="volume">
|
||||
<div class="knob-indicator"></div>
|
||||
</div>
|
||||
<span>VOL</span>
|
||||
</div>
|
||||
<div class="knob-container">
|
||||
<div class="knob" data-control="pan">
|
||||
<div class="knob-indicator"></div>
|
||||
</div>
|
||||
<span>PAN</span>
|
||||
</div>
|
||||
<div class="spectrogram-view-wrapper">
|
||||
<div class="spectrogram-view-grid"></div>
|
||||
</div>
|
||||
<div class="spectrogram-view-wrapper">
|
||||
<canvas id="spectrogram-canvas-1" width="800" height="100"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="audio-track-lane">
|
||||
<div class="track-info">
|
||||
<div class="track-info-header">
|
||||
<i class="fa-solid fa-gear"></i>
|
||||
<span class="track-name">Pista de Áudio 1</span>
|
||||
<div class="track-mute"></div>
|
||||
</div>
|
||||
<div class="track-controls">
|
||||
<div class="knob-container">
|
||||
<div class="knob" data-control="volume"><div class="knob-indicator"></div></div>
|
||||
<span>VOL</span>
|
||||
</div>
|
||||
<div class="knob-container">
|
||||
<div class="knob" data-control="pan"><div class="knob-indicator"></div></div>
|
||||
<span>PAN</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timeline-container">
|
||||
<div class="spectrogram-view-grid" style="width: 4000px;"> <div class="timeline-clip" style="left: 100px; width: 400px;"></div>
|
||||
<div class="playhead"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="audio-track-lane">
|
||||
<div class="track-info">
|
||||
<div class="track-info-header">
|
||||
<i class="fa-solid fa-gear"></i>
|
||||
<span class="track-name">Pista de Áudio 2</span>
|
||||
<div class="track-mute"></div>
|
||||
</div>
|
||||
<div class="track-controls">
|
||||
<div class="knob-container">
|
||||
<div class="knob" data-control="volume"><div class="knob-indicator"></div></div>
|
||||
<span>VOL</span>
|
||||
</div>
|
||||
<div class="knob-container">
|
||||
<div class="knob" data-control="pan"><div class="knob-indicator"></div></div>
|
||||
<span>PAN</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline-container">
|
||||
<div id="loop-region" class="loop-region">
|
||||
<div class="spectrogram-view-grid" style="width: 4000px;">
|
||||
|
||||
<div class="timeline-clip" style="left: 50px; width: 600px;">
|
||||
<div class="clip-name">jungle01.ogg</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="playhead"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<input type="file" id="mmp-file-input" accept=".mmp, .mmpz" style="display: none"/>
|
||||
|
|
@ -181,8 +211,15 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
||||
|
||||
<div id="timeline-context-menu">
|
||||
<div id="set-loop-start">Definir Início do Loop</div>
|
||||
<div id="set-loop-end">Definir Fim do Loop</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.7.77/Tone.js"></script>
|
||||
<script src="assets/js/creations/main.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue