From 531fb7b36a45fa905dc095f5ac075687ff1e3768 Mon Sep 17 00:00:00 2001 From: JotaChina Date: Mon, 20 Oct 2025 21:53:09 -0300 Subject: [PATCH] =?UTF-8?q?vers=C3=A3o=202.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/css/style.css | 634 ++++++++----------- assets/js/creations/audio.js | 322 +--------- assets/js/creations/audio/audio_audio.js | 154 +++++ assets/js/creations/audio/audio_state.js | 136 ++++ assets/js/creations/audio/audio_ui.js | 367 +++++++++++ assets/js/creations/config.js | 5 +- assets/js/creations/file.js | 123 ++-- assets/js/creations/main.js | 300 +++------ assets/js/creations/pattern/pattern_audio.js | 141 +++++ assets/js/creations/pattern/pattern_state.js | 97 +++ assets/js/creations/pattern/pattern_ui.js | 171 +++++ assets/js/creations/state.js | 192 ++---- assets/js/creations/ui.js | 545 ++-------------- assets/js/creations/utils.js | 32 +- assets/js/creations/waveform.js | 49 +- creation.html | 93 ++- 16 files changed, 1735 insertions(+), 1626 deletions(-) create mode 100644 assets/js/creations/audio/audio_audio.js create mode 100644 assets/js/creations/audio/audio_state.js create mode 100644 assets/js/creations/audio/audio_ui.js create mode 100644 assets/js/creations/pattern/pattern_audio.js create mode 100644 assets/js/creations/pattern/pattern_state.js create mode 100644 assets/js/creations/pattern/pattern_ui.js diff --git a/assets/css/style.css b/assets/css/style.css index cdf12bd..6592aa6 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -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 */ -} \ No newline at end of file +/* =============================================== */ +/* 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; } \ No newline at end of file diff --git a/assets/js/creations/audio.js b/assets/js/creations/audio.js index 7bbfde1..4dfed83 100644 --- a/assets/js/creations/audio.js +++ b/assets/js/creations/audio.js @@ -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"); } \ No newline at end of file diff --git a/assets/js/creations/audio/audio_audio.js b/assets/js/creations/audio/audio_audio.js new file mode 100644 index 0000000..3b7fdcf --- /dev/null +++ b/assets/js/creations/audio/audio_audio.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(); + } +} \ No newline at end of file diff --git a/assets/js/creations/audio/audio_state.js b/assets/js/creations/audio/audio_state.js new file mode 100644 index 0000000..f5f2cc2 --- /dev/null +++ b/assets/js/creations/audio/audio_state.js @@ -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 +} \ No newline at end of file diff --git a/assets/js/creations/audio/audio_ui.js b/assets/js/creations/audio/audio_ui.js new file mode 100644 index 0000000..a6594a1 --- /dev/null +++ b/assets/js/creations/audio/audio_ui.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 = ` +
+
+ `; + 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 = `
`; + 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 = ` +
+
+ + ${trackData.name} +
+
+
+
+
+ VOL +
+
+
+ PAN +
+
+
+
+
+
+
+ `; + 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 = ` +
+ ${clip.name} ${pitchStr} + +
+ `; + 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'; + }); +} \ No newline at end of file diff --git a/assets/js/creations/config.js b/assets/js/creations/config.js index 69b9edd..f264d34 100644 --- a/assets/js/creations/config.js +++ b/assets/js/creations/config.js @@ -11,4 +11,7 @@ export const DEFAULT_PAN = 0.0; // --- ADICIONADO --- // 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) \ No newline at end of file +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]; \ No newline at end of file diff --git a/assets/js/creations/file.js b/assets/js/creations/file.js index dd21b72..2c41b63 100644 --- a/assets/js/creations/file.js +++ b/assets/js/creations/file.js @@ -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 ` ${patternNotes} `; }).join('\n '); - return ` @@ -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( - `${tracksXml}`, - "application/xml" - ); + const tracksXml = appState.pattern.tracks.map((track) => createTrackXml(track)).join(""); + const tempDoc = new DOMParser().parseFromString(`${tracksXml}`, "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 = ` @@ -320,19 +314,4 @@ function downloadFile(content, fileName) { a.click(); 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; - } } \ No newline at end of file diff --git a/assets/js/creations/main.js b/assets/js/creations/main.js index 0395c23..af3cd12 100644 --- a/assets/js/creations/main.js +++ b/assets/js/creations/main.js @@ -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,225 +43,134 @@ 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", () => { const barsInput = document.getElementById("bars-input"); 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(); - }); - 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"; + + // ### CORREÇÃO 2: Adicionada verificação 'if (icon)' ### + sidebarToggle.addEventListener("click", () => { + document.body.classList.toggle("sidebar-hidden"); + const icon = sidebarToggle.querySelector("i"); + 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(); }); \ No newline at end of file diff --git a/assets/js/creations/pattern/pattern_audio.js b/assets/js/creations/pattern/pattern_audio.js new file mode 100644 index 0000000..3eca9f6 --- /dev/null +++ b/assets/js/creations/pattern/pattern_audio.js @@ -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(); + } +} \ No newline at end of file diff --git a/assets/js/creations/pattern/pattern_state.js b/assets/js/creations/pattern/pattern_state.js new file mode 100644 index 0000000..c584c6d --- /dev/null +++ b/assets/js/creations/pattern/pattern_state.js @@ -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]; + } + } +} \ No newline at end of file diff --git a/assets/js/creations/pattern/pattern_ui.js b/assets/js/creations/pattern/pattern_ui.js new file mode 100644 index 0000000..746e474 --- /dev/null +++ b/assets/js/creations/pattern/pattern_ui.js @@ -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 = ` +
+ +
+ ${trackData.name} +
+
+
+
+ VOL +
+
+
+ PAN +
+
+
+ `; + + 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); + } + } + }); +} \ No newline at end of file diff --git a/assets/js/creations/state.js b/assets/js/creations/state.js index b2abe24..7684e1d 100644 --- a/assets/js/creations/state.js +++ b/assets/js/creations/state.js @@ -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); - } - } } \ No newline at end of file diff --git a/assets/js/creations/ui.js b/assets/js/creations/ui.js index d684a60..c84214c 100644 --- a/assets/js/creations/ui.js +++ b/assets/js/creations/ui.js @@ -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); - - 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 renderAll() { + renderPatternEditor(); + renderAudioEditor(); + const loopArea = document.getElementById("loop-region"); -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 = ` ${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 = ` -
- -
- ${trackData.name} -
-
-
-
- VOL -
-
-
- PAN -
-
-
-
-
-
-
- `; - - 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 = ` -
- -
- ${trackData.name} -
-
-
-
- VOL -
-
-
- PAN -
-
-
- `; - - 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 = ` ${key}`; + // --- LÓGICA PARA ARQUIVOS --- + li.className = "file-item draggable-sample"; // CORREÇÃO: Adiciona classe para consistência + li.innerHTML = ` ${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 = ` ${key}`; + // --- LÓGICA CORRIGIDA PARA PASTAS --- + li.className = "folder-item"; // CORREÇÃO 1: Usa a classe CSS correta + + // CORREÇÃO 2: Cria o 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 = ` ${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 = - '

Nenhum projeto encontrado no servidor.

'; + serverProjectsList.innerHTML = '

Nenhum projeto encontrado no servidor.

'; } projects.forEach((projectName) => { @@ -591,16 +152,4 @@ export async function showOpenProjectModal() { 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'; - }); } \ No newline at end of file diff --git a/assets/js/creations/utils.js b/assets/js/creations/utils.js index 9187e5f..e54fca5 100644 --- a/assets/js/creations/utils.js +++ b/assets/js/creations/utils.js @@ -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 })); } \ No newline at end of file diff --git a/assets/js/creations/waveform.js b/assets/js/creations/waveform.js index 8504f1c..796d7c7 100644 --- a/assets/js/creations/waveform.js +++ b/assets/js/creations/waveform.js @@ -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); diff --git a/creation.html b/creation.html index a9dbea9..f981238 100644 --- a/creation.html +++ b/creation.html @@ -99,7 +99,7 @@
- +

@@ -113,7 +113,7 @@
- +
@@ -123,42 +123,72 @@ Editor de Amostras de Áudio
+ + + +
-
-
+
+
+
+ + Pista de Áudio 1 +
+
+
+
+
+ VOL +
+
+
+ PAN +
+
+
+ +
+
+
+
+
+ +
+
+
+ Pista de Áudio 2
- bassslap02.ogg -
-
-
-
-
-
- VOL -
-
-
-
-
- PAN -
-
-
-
-
- -
-
-
+
+
+
+ VOL +
+
+
+ PAN +
+
+
+
+ +
+
jungle01.ogg
+
+
+
+
+
+
+
@@ -181,8 +211,15 @@
- + +
+
Definir Início do Loop
+
Definir Fim do Loop
+
+ + + \ No newline at end of file