From facc329b03bc71dfd333bd1ee606ec2a294398fd Mon Sep 17 00:00:00 2001 From: JotaChina Date: Sun, 12 Oct 2025 09:23:27 -0300 Subject: [PATCH] =?UTF-8?q?Amostras=20de=20=C3=A1udio=20funcionais=20at?= =?UTF-8?q?=C3=A9=20o=20momento.=20Agulha=20de=20tempo,=20mute/solo=20e=20?= =?UTF-8?q?ajustado=20com=20a=20velocidade=20de=20reprodu=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/css/style.css | 823 ++++++------------ assets/js/creations/audio.js | 105 ++- assets/js/creations/config.js | 5 + assets/js/creations/main.js | 15 + assets/js/creations/state.js | 81 +- assets/js/creations/ui.js | 192 +++- assets/js/creations/waveform.js | 48 + creation.html | 228 +++-- metadata/samples-manifest.json | 6 +- readme.md | 5 +- src/manual.md | 129 +++ .../samples/bassdrum_acoustic02_-_Copia.ogg | Bin 0 -> 15836 bytes 12 files changed, 947 insertions(+), 690 deletions(-) create mode 100644 assets/js/creations/waveform.js create mode 100644 src/manual.md create mode 100644 src/samples/samples/bassdrum_acoustic02_-_Copia.ogg diff --git a/assets/css/style.css b/assets/css/style.css index 56571df..a3489e4 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -10,18 +10,28 @@ --text-dark: #888; --accent-green: #2ecc71; --accent-red: #d9534f; + --background-light: #4a4f57; + --background-lighter: #5c626b; + --border-color-dark: #1a1c1e; + --accent-blue: #3498db; } /* =============================================== */ -/* ESTILOS GLOBAIS E LAYOUT PRINCIPAL +/* ESTILOS GLOBAIS E LAYOUT PRINCIPAL (CORRIGIDO) /* =============================================== */ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 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; } body.sidebar-hidden { @@ -33,7 +43,13 @@ body.knob-dragging { } .main-content { - padding: 2rem; + padding: 1rem; + flex-grow: 1; /* Faz o conteúdo principal ocupar o espaço restante */ + display: flex; + flex-direction: column; + gap: 1rem; + overflow: hidden; /* Evita que o conteúdo transborde */ + height: 100%; /* Garante que o flexbox interno funcione */ } /* =============================================== */ @@ -73,43 +89,13 @@ body.sidebar-hidden .sample-browser { 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 { 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); } #sidebar-toggle { position: fixed; @@ -150,27 +136,90 @@ body.sidebar-hidden #sidebar-toggle { 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) +/* =============================================== */ -/* =============================================== */ -/* EDITOR DE BATIDAS (BEAT EDITOR) -/* =============================================== */ -.beat-editor { - background-color: var(--bg-body); +/* O container principal que substitui o .future-panel */ +.audio-editor { + height: 50%; + background-color: var(--bg-editor); border: 1px solid var(--border-color); - width: 100%; - max-width: 900px; - margin: auto; + border-radius: 8px; box-shadow: 0 5px 15px rgba(0, 0, 0, .3); - border-radius: 4px; overflow: hidden; + 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; +} + + +/* =============================================== */ +/* 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; @@ -179,590 +228,264 @@ body.sidebar-hidden .global-toolbar { 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; +.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; - display: flex; - align-items: center; - gap: 15px; border-bottom: 2px solid var(--border-color); + flex-shrink: 0; } -.editor-toolbar i { - cursor: pointer; - padding: 5px; - border-radius: 3px; +.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; -} +.editor-toolbar i.enabled { background-color: var(--bg-body); box-shadow: inset 0 0 2px #000; } -.pattern-selector { - background-color: var(--bg-body); - padding: 5px 15px; - border: 1px solid var(--border-color); - flex-grow: 1; - font-size: .9rem; - border-radius: 2px; -} +.pattern-manager { display: flex; align-items: center; gap: 10px; } +.pattern-selector { background-color: var(--bg-body); color: var(--text-light); padding: 5px 10px; border: 1px solid var(--border-color); font-size: .9rem; border-radius: 2px; } +.pattern-btn { background: var(--bg-body); border: 1px solid var(--border-color); color: var(--text-light); cursor: pointer; border-radius: 3px; width: 28px; height: 28px; font-size: 1.2rem; } /* =============================================== */ /* FAIXAS (TRACK LANES) E SEQUENCIADOR /* =============================================== */ +#track-container { + overflow-y: auto; + flex-grow: 1; +} + .track-lane { display: flex; align-items: center; padding: 8px 10px; background-color: var(--bg-editor); - border: 2px dashed transparent; - transition: border-color 0.2s; + 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; +} + +.track-lane.active-track { + background-color: #40454d; } .track-lane.drag-over { border-color: var(--accent-green); } -.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 { +/* Localize a regra .track-mute e substitua por esta */ +.track-solo-btn { width: 25px; height: 12px; - background-color: var(--accent-green); + 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-name { - color: var(--accent-red); - font-weight: 700; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +.track-solo-btn:hover { + opacity: 0.8; } -.track-controls { - display: flex; - gap: 5px; - margin: 0 10px; - padding-left: 10px; - border-left: 1px solid var(--bg-toolbar); - flex-shrink: 0; -} - -.knob-container { - text-align: center; - font-size: .7rem; - color: var(--text-dark); -} - -.knob { - width: 28px; - height: 28px; - background-color: var(--bg-toolbar); - border-radius: 50%; - border: 1px solid var(--border-color); - margin-bottom: 2px; - cursor: grab; - box-shadow: inset 0 0 4px #222; - position: relative; -} - -.knob:active { - cursor: grabbing; -} - -.knob-indicator { - width: 2px; - height: 8px; - background-color: var(--text-light); - position: absolute; - top: 2px; - left: 50%; - transform-origin: bottom center; - transform: translateX(-50%) rotate(0deg); - border-radius: 1px; -} - -.step-sequencer-wrapper { - flex-grow: 1; - overflow-x: auto; - overflow-y: hidden; - padding-bottom: 8px; -} - -.step-sequencer { - display: flex; - gap: 4px; -} - -.step-sequencer-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; -} - -/* (CORREÇÃO) CSS para as marcações de compasso */ -.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; -} - -.step-dark { - background-color: #1e1e1e; -} - -.step:hover { - background-color: #555; - border-color: #888; -} - -.step.active { +/* Quando solado (ativo), o botão fica verde */ +.track-solo-btn.active { background-color: var(--accent-green); - border: 1px solid #fff; - box-shadow: 0 0 8px var(--accent-green); + opacity: 1; } -.step.playing { - transform: scale(1.1); - box-shadow: inset 0 0 8px rgba(255, 255, 255, 0.8); +.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; } +.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; } +.step-dark { background-color: #1e1e1e; } +.step:hover { background-color: #555; border-color: #888; } +.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); } /* =============================================== */ /* CONTROLES E INPUTS /* =============================================== */ -.interactive-input-container { - display: flex; - align-items: center; - justify-content: center; - gap: 4px; -} +.interactive-input-container { display: flex; align-items: center; justify-content: center; gap: 4px; } +.compasso-group { display: flex; align-items: center; gap: 4px; } +.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; } +.compasso-input { width: 25px; } +.compasso-separator { color: var(--accent-green); font-weight: 700; font-size: 1.4rem; font-family: Courier New, Courier, monospace; margin: 0 2px; } +.value-input::-webkit-outer-spin-button, .value-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } +.value-input[type=number] { -moz-appearance: textfield; } +.adjust-btn { background: 0 0; border: 0; color: var(--text-dark); font-size: 1rem; font-weight: 700; cursor: pointer; padding: 0 5px; transition: color .2s; line-height: 1; } +.adjust-btn:hover { color: #fff; } +.control-group { display: flex; align-items: center; gap: 15px; padding: 0 10px; } +.control-group i { font-size: 1.2rem; cursor: pointer; color: var(--text-light); transition: color .2s; } +.control-group i:hover { color: #fff; } +.fa-play, .fa-pause { color: var(--accent-green) !important; } +.divider { width: 1px; height: 25px; background-color: var(--border-color); } +.info-display-group { display: flex; align-items: center; gap: 5px; } +.info-display { background-color: #1a1c1e; padding: 5px 8px; border-radius: 3px; text-align: center; } +.info-display .label { color: var(--text-dark); font-size: .6rem; text-transform: uppercase; } +.spacer { flex-grow: 1; } +#metronome-btn { background: 0 0; border: 1px solid var(--text-dark); color: var(--accent-green); font-family: inherit; font-weight: 700; font-size: .8rem; padding: 5px 10px; border-radius: 3px; cursor: pointer; transition: all .2s; } +#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); } -.compasso-group { - display: flex; - align-items: center; - gap: 4px; -} +.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; } +#server-projects-list .project-item:last-child { margin-bottom: 0; } +#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; } -.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; -} - -.compasso-input { - width: 25px; -} - -.compasso-separator { - color: var(--accent-green); - font-weight: 700; - font-size: 1.4rem; - font-family: Courier New, Courier, monospace; - margin: 0 2px; -} - -.value-input::-webkit-outer-spin-button, -.value-input::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; -} - -.value-input[type=number] { - -moz-appearance: textfield; -} - -.adjust-btn { - background: 0 0; - border: 0; - color: var(--text-dark); - font-size: 1rem; - font-weight: 700; - cursor: pointer; - padding: 0 5px; - transition: color .2s; - line-height: 1; -} - -.adjust-btn:hover { - color: #fff; -} - -.control-group { - display: flex; - align-items: center; - gap: 15px; - padding: 0 10px; -} - -.control-group i { - font-size: 1.2rem; - cursor: pointer; - color: var(--text-light); - transition: color .2s; -} - -.control-group i:hover { - color: #fff; -} - -.fa-play, -.fa-pause { - color: var(--accent-green) !important; -} - -.divider { - width: 1px; - height: 25px; - background-color: var(--border-color); -} - -.info-display-group { - display: flex; - align-items: center; - gap: 5px; -} - -.info-display { - background-color: #1a1c1e; - padding: 5px 8px; - border-radius: 3px; - text-align: center; -} - -.info-display .label { - color: var(--text-dark); - font-size: .6rem; - text-transform: uppercase; -} - -.spacer { - flex-grow: 1; -} - -#metronome-btn { - background: 0 0; - border: 1px solid var(--text-dark); - color: var(--accent-green); - font-family: inherit; - font-weight: 700; - font-size: .8rem; - padding: 5px 10px; - border-radius: 3px; - cursor: pointer; - transition: all .2s; -} - -#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 (CAIXA DE DIÁLOGO) -/* =============================================== */ -.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; -} -#server-projects-list .project-item:last-child { - margin-bottom: 0; -} -#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; } /* =============================================== */ -/* ESTILOS RESPONSIVOS +/* ESTILOS RESPONSIVOS (MELHORADO) /* =============================================== */ +@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; + } +} @media (max-width: 992px) { - .main-content { - padding: 1.5rem; + .global-toolbar { + gap: 10px; + flex-wrap: wrap; + height: auto; /* Permite que a toolbar cresça se o conteúdo quebrar linha */ + padding-bottom: 10px; } - .beat-editor { - max-width: 100%; + body { + padding-top: 80px; /* Aumenta o espaço para a toolbar maior */ + } + .info-display-group { + order: 3; /* Move o grupo de informações para o final da toolbar */ + width: 100%; + justify-content: space-around; + } + .spacer { + display: none; } } @media (max-width: 768px) { body { - padding-left: 0 !important; - } - .main-content { - padding: 1rem; + padding-left: 0 !important; } .sample-browser { - transform: translateX(-100%); - width: 280px; + transform: translateX(-100%); + position: fixed; /* Volta a ser fixo para deslizar por cima */ + width: 280px; } body:not(.sidebar-hidden) .sample-browser { - transform: translateX(0); + transform: translateX(0); } #sidebar-toggle { - left: 5px; + left: 5px; + transform: translateX(0); + position: fixed; /* Garante que o botão fique visível */ } .global-toolbar { - left: 0; - padding-left: 45px; + left: 0; + padding-left: 45px; } - .editor-toolbar, - .control-group { - flex-wrap: wrap; - gap: 10px; + .main-content { + padding: 10px; + padding-top: 85px; /* Ajusta o padding para a toolbar fixa */ } - .track-lane { - flex-direction: column; - align-items: stretch; - gap: 15px; - padding: 15px; + .track-lane, .audio-track-lane { + flex-direction: column; + align-items: stretch; + gap: 15px; + padding: 15px; } .track-info, .track-controls { - width: 100%; + width: 100%; } .track-controls { - border-left: none; - padding-left: 0; - justify-content: space-around; + border-left: none; + padding-left: 0; + justify-content: space-around; } .step-sequencer-wrapper { - width: 100%; - } - .modal-content { - max-width: 95vw; - padding: 1rem 1.5rem; - gap: 1rem; + width: 100%; } } -/* --- ESTILOS PARA O MENU ARQUIVO --- */ - -.file-menu-container { - position: relative; /* Essencial para o posicionamento do dropdown */ +.spectrogram-view-wrapper { + position: relative; /* Essencial para o posicionamento absoluto do filho */ + overflow: hidden; /* Garante que a agulha não saia dos limites */ } -.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 { +.playhead { 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; + 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 */ } \ No newline at end of file diff --git a/assets/js/creations/audio.js b/assets/js/creations/audio.js index 8e2377a..ce17269 100644 --- a/assets/js/creations/audio.js +++ b/assets/js/creations/audio.js @@ -1,7 +1,8 @@ // js/audio.js import { appState } from "./state.js"; -import { highlightStep } from "./ui.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; @@ -30,6 +31,7 @@ export function initializeAudioContext() { } } +// ... (funções de master volume/pan, formatTime, metronome, sample player, tick, etc. permanecem iguais)... export function updateMasterVolume(volume) { if (mainGainNode) { mainGainNode.gain.setValueAtTime(volume, audioContext.currentTime); @@ -120,7 +122,6 @@ function tick() { appState.tracks.forEach((track) => { if (!track.patterns || track.patterns.length === 0) return; - // Usa o índice GLOBAL para saber qual pattern tocar, sincronizando com a UI. const activePattern = track.patterns[appState.activePatternIndex]; if (activePattern && activePattern.steps[appState.currentStep] && track.samplePath) { @@ -190,4 +191,104 @@ export function togglePlayback() { 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; + const totalElapsedTime = (audioContext.currentTime - appState.audioEditorStartTime) + appState.audioEditorPlaybackTime; + const newPositionPx = totalElapsedTime * pixelsPerSecond; + const maxDuration = appState.audioTracks.reduce((max, track) => (track.audioBuffer && track.audioBuffer.duration > max) ? track.audioBuffer.duration : max, 0); + if (totalElapsedTime >= maxDuration && maxDuration > 0) { + stopAudioEditorPlayback(); + appState.audioEditorPlaybackTime = 0; + resetPlayheadVisual(); + return; + } + updatePlayheadVisual(newPositionPx); + appState.audioEditorAnimationId = requestAnimationFrame(animationLoop); +} + +// --- LÓGICA DE REPRODUÇÃO ATUALIZADA --- +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; + + // Verifica se existe alguma faixa no modo "solo" + const isAnyTrackSoloed = appState.audioTracks.some(t => t.isSoloed); + + appState.audioTracks.forEach(track => { + // Condições para tocar: + // 1. A faixa deve ter um buffer de áudio. + // 2. A faixa não pode estar mutada. + const canPlay = track.audioBuffer && !track.isMuted; + + // 3. Lógica de solo: + // - Se houver alguma faixa solada, esta faixa TAMBÉM deve estar solada. + // - Se NENHUMA faixa estiver solada, todas podem tocar. + const shouldPlay = isAnyTrackSoloed ? track.isSoloed : true; + + if (canPlay && shouldPlay) { + if (appState.audioEditorPlaybackTime >= track.audioBuffer.duration) return; + + const source = audioContext.createBufferSource(); + source.buffer = track.audioBuffer; + 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; + const elapsedTime = (audioContext.currentTime - appState.audioEditorStartTime); + appState.audioEditorPlaybackTime += elapsedTime; + 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(); + } } \ No newline at end of file diff --git a/assets/js/creations/config.js b/assets/js/creations/config.js index 4b0ef63..69b9edd 100644 --- a/assets/js/creations/config.js +++ b/assets/js/creations/config.js @@ -7,3 +7,8 @@ export const NOTE_LENGTH = 12; // Constantes para os valores padrão dos knobs export const DEFAULT_VOLUME = 0.8; export const DEFAULT_PAN = 0.0; + +// --- 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 diff --git a/assets/js/creations/main.js b/assets/js/creations/main.js index 2debcf2..fb7b049 100644 --- a/assets/js/creations/main.js +++ b/assets/js/creations/main.js @@ -11,6 +11,8 @@ import { initializeAudioContext, updateMasterVolume, updateMasterPan, + startAudioEditorPlayback, + stopAudioEditorPlayback, } from "./audio.js"; import { handleFileLoad, generateMmpFile } from "./file.js"; import { @@ -33,6 +35,8 @@ document.addEventListener("DOMContentLoaded", () => { const removeInstrumentBtn = document.getElementById("remove-instrument-btn"); const playBtn = document.getElementById("play-btn"); const stopBtn = document.getElementById("stop-btn"); + const audioEditorPlayBtn = document.getElementById("audio-editor-play-btn"); + const audioEditorStopBtn = document.getElementById("audio-editor-stop-btn"); const rewindBtn = document.getElementById("rewind-btn"); const metronomeBtn = document.getElementById("metronome-btn"); const mmpFileInput = document.getElementById("mmp-file-input"); @@ -248,6 +252,17 @@ document.addEventListener("DOMContentLoaded", () => { }); }); + // Listeners para os controles do editor de áudio + audioEditorPlayBtn.addEventListener("click", () => { + if (appState.isAudioEditorPlaying) { + stopAudioEditorPlayback(); + } else { + startAudioEditorPlayback(); + } + }); + + audioEditorStopBtn.addEventListener("click", stopAudioEditorPlayback); + loadAndRenderSampleBrowser(); renderApp(); setupMasterKnobs(); diff --git a/assets/js/creations/state.js b/assets/js/creations/state.js index b2b9060..1418dcc 100644 --- a/assets/js/creations/state.js +++ b/assets/js/creations/state.js @@ -5,18 +5,25 @@ import { getAudioContext, getMainGainNode, } from "./audio.js"; -import { renderApp } from "./ui.js"; +import { renderApp, renderAudioEditor } from "./ui.js"; import { getTotalSteps } from "./utils.js"; export let appState = { tracks: [], + audioTracks: [], activeTrackId: null, - activePatternIndex: 0, // <-- VOLTOU A SER GLOBAL + activePatternIndex: 0, isPlaying: false, + isAudioEditorPlaying: false, + activeAudioSources: [], + audioEditorStartTime: 0, + audioEditorAnimationId: null, + audioEditorPlaybackTime: 0, playbackIntervalId: null, currentStep: 0, metronomeEnabled: false, originalXmlDoc: null, + currentBeatBasslineName: 'Novo Projeto', masterVolume: DEFAULT_VOLUME, masterPan: DEFAULT_PAN, }; @@ -37,6 +44,54 @@ export async function loadAudioForTrack(track) { return track; } +export function addAudioTrack(samplePath) { + initializeAudioContext(); + const audioContext = getAudioContext(); + const mainGainNode = getMainGainNode(); + + 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(); + }); +} + +// 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(); @@ -52,7 +107,7 @@ export function addTrackToState() { 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 foi removido daqui + activePatternIndex: 0, volume: DEFAULT_VOLUME, pan: DEFAULT_PAN, gainNode: audioContext.createGain(), @@ -64,18 +119,12 @@ export function addTrackToState() { newTrack.pannerNode.pan.value = newTrack.pan; appState.tracks.push(newTrack); - if (!appState.activeTrackId) { - appState.activeTrackId = newTrack.id; - } renderApp(); } export function removeLastTrackFromState() { if (appState.tracks.length > 0) { - const removedTrack = appState.tracks.pop(); - if (appState.activeTrackId === removedTrack.id) { - appState.activeTrackId = appState.tracks[0]?.id || null; - } + appState.tracks.pop(); renderApp(); } } @@ -87,18 +136,14 @@ export async function updateTrackSample(trackId, samplePath) { track.name = samplePath.split("/").pop(); track.audioBuffer = null; await loadAudioForTrack(track); - const trackLane = document.querySelector(`.track-lane[data-track-id="${trackId}"] .track-name`); - if (trackLane) { - trackLane.textContent = track.name; - } + renderApp(); } } export function toggleStepState(trackId, stepIndex) { const track = appState.tracks.find((t) => t.id == trackId); if (track && track.patterns && track.patterns.length > 0) { - // Usa o índice GLOBAL para saber qual pattern modificar - const activePattern = track.patterns[appState.activePatternIndex]; + const activePattern = track.patterns[track.activePatternIndex]; if (activePattern && activePattern.steps.length > stepIndex) { activePattern.steps[stepIndex] = !activePattern.steps[stepIndex]; } @@ -106,7 +151,7 @@ export function toggleStepState(trackId, stepIndex) { } export function updateTrackVolume(trackId, volume) { - const track = appState.tracks.find((t) => t.id == trackId); + 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; @@ -117,7 +162,7 @@ export function updateTrackVolume(trackId, volume) { } export function updateTrackPan(trackId, pan) { - const track = appState.tracks.find((t) => t.id == trackId); + 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; diff --git a/assets/js/creations/ui.js b/assets/js/creations/ui.js index a553b17..d684a60 100644 --- a/assets/js/creations/ui.js +++ b/assets/js/creations/ui.js @@ -5,17 +5,34 @@ import { updateTrackSample, updateTrackVolume, updateTrackPan, + addAudioTrack, + toggleAudioTrackSolo, } from "./state.js"; -import { playSample, stopPlayback } from "./audio.js"; +import { playSample, stopPlayback, seekAudioEditor } from "./audio.js"; import { getTotalSteps } from "./utils.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', () => { - // A linha stopPlayback() foi REMOVIDA daqui, permitindo a troca em tempo real. + stopPlayback(); appState.activePatternIndex = parseInt(globalPatternSelector.value, 10); const firstTrack = appState.tracks[0]; @@ -105,6 +122,137 @@ 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 = ""; @@ -139,25 +287,12 @@ export function renderApp() { trackLane.addEventListener('click', () => { if (appState.activeTrackId === trackData.id) return; - - // A linha stopPlayback() também foi REMOVIDA daqui - + stopPlayback(); appState.activeTrackId = trackData.id; document.querySelectorAll('.track-lane').forEach(lane => lane.classList.remove('active-track')); trackLane.classList.add('active-track'); updateGlobalPatternSelector(); - - // Apenas redesenha a UI, sem parar a música - const activeTrack = appState.tracks.find(t => t.id === appState.activeTrackId); - if (activeTrack) { - const activePattern = activeTrack.patterns[activeTrack.activePatternIndex]; - if (activePattern) { - const stepsPerBar = 16; - const requiredBars = Math.ceil(activePattern.steps.length / stepsPerBar); - document.getElementById("bars-input").value = requiredBars > 0 ? requiredBars : 1; - redrawSequencer(); - } - } + redrawSequencer(); }); trackLane.addEventListener("dragover", (e) => { e.preventDefault(); trackLane.classList.add("drag-over"); }); @@ -182,6 +317,7 @@ export function renderApp() { updateGlobalPatternSelector(); redrawSequencer(); + renderAudioEditor(); } export function redrawSequencer() { @@ -266,7 +402,7 @@ function addKnobInteraction(knobElement) { if (e.button !== 0) return; e.preventDefault(); const trackId = knobElement.dataset.trackId; - const track = appState.tracks.find((t) => t.id == 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; @@ -293,7 +429,7 @@ function addKnobInteraction(knobElement) { knobElement.addEventListener("wheel", (e) => { e.preventDefault(); const trackId = knobElement.dataset.trackId; - const track = appState.tracks.find((t) => t.id == 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; @@ -310,7 +446,7 @@ function addKnobInteraction(knobElement) { function updateKnobVisual(knobElement, controlType) { const trackId = knobElement.dataset.trackId; - const track = appState.tracks.find((t) => t.id == 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; @@ -359,11 +495,7 @@ export function highlightStep(stepIndex, isActive) { export async function loadAndRenderSampleBrowser() { const browserContent = document.getElementById("browser-content"); try { - // --- CORREÇÃO AQUI --- - // Adiciona um timestamp à URL para evitar que o navegador use uma versão antiga (em cache) do arquivo. const response = await fetch(`metadata/samples-manifest.json?v=${Date.now()}`); - // --- FIM DA CORREÇÃO --- - if (!response.ok) { throw new Error("Arquivo samples-manifest.json não encontrado."); } @@ -459,4 +591,16 @@ 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/waveform.js b/assets/js/creations/waveform.js new file mode 100644 index 0000000..8504f1c --- /dev/null +++ b/assets/js/creations/waveform.js @@ -0,0 +1,48 @@ +// js/waveform.js + +/** + * Desenha a forma de onda de um AudioBuffer em um elemento Canvas. + * @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'). + */ +export function drawWaveform(canvas, audioBuffer, color) { + 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 + + ctx.clearRect(0, 0, width, height); // Limpa o canvas + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.beginPath(); + + 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; + } + } + + // Desenha a linha vertical para aquele ponto no tempo + const x = i; + const y_max = (1 + max) * amp; + const y_min = (1 + min) * amp; + + ctx.moveTo(x, y_max); + ctx.lineTo(x, y_min); + } + ctx.stroke(); +} \ No newline at end of file diff --git a/creation.html b/creation.html index 234d32e..a2d7824 100644 --- a/creation.html +++ b/creation.html @@ -18,108 +18,148 @@ - -
-
- - - - -
-
-
- - - - -
-
-
-
-
- - - -
-
ANDAMENTO/BPM
+
+
+
+ + + +
-
-
- - - -
-
COMPASSOS
+
+
+ + + +
-
-
-
- - - +
+
+
+
+ + +
- / -
- - - +
ANDAMENTO/BPM
+
+
+
+ + + +
+
COMPASSOS
+
+
+
+
+ + + +
+ / +
+ + + +
+
+
COMPASSO
+
+
+
00:00:00
+
MIN:SEC:MSEC
+
+
+
+ +
+
+
+
+
+ VOL MASTER +
+
+
+ PAN MASTER +
+
+
+ +
+
+
+ Mostrar/esconder Editor de Bases +
+
-
COMPASSO
-
-
-
00:00:00
-
MIN:SEC:MSEC
-
-
-
- -
-
-
-
-
- VOL MASTER -
-
-
- PAN MASTER -
-
-
- -
-
-
- Mostrar/esconder Editor de Bases -
- +
+
+ + +
+
+

+ + + +
+
+ +
+
+ +
+
-
-
- - -
-
-

- - - -
-
- -
-
- -
+
+
+ Editor de Amostras de Áudio + +
+ + +
+
+
+
+
+ +
+ bassslap02.ogg +
+
+
+
+
+
+ VOL +
+
+
+
+
+ PAN +
+
+
+
+
+ +
+
+ +
+
-
-
-
- + + diff --git a/metadata/samples-manifest.json b/metadata/samples-manifest.json index 5dac132..12cdbc4 100644 --- a/metadata/samples-manifest.json +++ b/metadata/samples-manifest.json @@ -528,7 +528,11 @@ "_isFile": true } }, - "samples": {}, + "samples": { + "bassdrum_acoustic02_-_Copia.ogg": { + "_isFile": true + } + }, "shapes": { "additive.wav": { "_isFile": true diff --git a/readme.md b/readme.md index 728fd24..9e9bc11 100644 --- a/readme.md +++ b/readme.md @@ -8,9 +8,12 @@ Isso ativará o ambiente de desenvolvimento. # ----------------------- // -------------------------- # Serviço Watchdog para verificar alterações nas pastas de samples -Foi criado um serviço(/etc/systemd/system/mmpCreator-upload-server.service) com nome "mmpCreator-upload-server.service" para verificar alterações na pasta src/samples se há alguma mudança. Caso tenha, ele fará um novo build do site para que seja atualizado em tempo real todas as alterações. (tempo de 5 em 5 segundos) +Foi criado um serviço (/etc/systemd/system/mmpCreator-upload-server.service) com nome "mmpCreator-upload-server.service" para verificar alterações na pasta src/samples se há alguma mudança. Caso tenha, ele fará um novo build do site para que seja atualizado em tempo real todas as alterações. (tempo de 5 em 5 segundos) A atualização no site é feita a partir de build do site feito pelo script. +# TO DO +- Verificar caminho do build, pois está sendo feito apenas na parte de testes + # ----------------------- // -------------------------- # Servidor de Upload de samples diff --git a/src/manual.md b/src/manual.md new file mode 100644 index 0000000..a3f473c --- /dev/null +++ b/src/manual.md @@ -0,0 +1,129 @@ +# Visão Geral do Código + Este é o código de um sequenciador de música para a web, similar a um "DAW" (Digital Audio Workstation) simplificado, focado em criar batidas e melodias. Ele permite carregar e salvar projetos no formato .mmp (LMMS), manipular trilhas de instrumentos, programar notas em um sequenciador e controlar aspectos como volume e pan. + +# main.js - O Ponto de Partida + Este arquivo inicializa a aplicação. Ele conecta os botões da interface (como "Play", "Salvar", "Adicionar Trilha") às suas funções correspondentes, que estão em outros arquivos. Pense nele como o cérebro que delega as tarefas. + +# Função Principal + - Configura todos os "ouvintes de eventos" (event listeners). + + Exemplo: Quando o usuário clica no botão com o ID play-btn, a função togglePlayback do arquivo audio.js é chamada para iniciar ou parar a música. + +```JavaScript + +// Exemplo de como um botão é conectado a uma função +playBtn.addEventListener("click", togglePlayback); +addInstrumentBtn.addEventListener("click", addTrackToState); +saveMmpBtn.addEventListener("click", generateMmpFile); +``` +# state.js - O Coração da Aplicação + + Aqui fica guardado todo o estado atual do projeto. Isso inclui as trilhas, as notas, o volume de cada instrumento, se a música está tocando ou não, etc. Quando algo muda (por exemplo, o usuário adiciona uma nota), este estado é atualizado, e a interface visual reflete essa mudança. + + +# Função Principal + - Manter um objeto central (appState) com todas as informações do projeto. + + Exemplo: O objeto appState contém uma lista (tracks) onde cada item representa um instrumento com seus padrões de notas, volume e nome do sample. + +```JavaScript + +export let appState = { + tracks: [], // Lista de instrumentos + isPlaying: false, // A música está tocando? + currentStep: 0, // Qual passo da batida está sendo tocado + masterVolume: 0.8, // Volume geral +}; +``` + +# ui.js - A Interface Gráfica + + Este arquivo é responsável por desenhar tudo o que o usuário vê e com o que interage na tela: as trilhas, os "knobs" de volume, a grade do sequenciador e o navegador de arquivos de áudio. Ele lê os dados do state.js para saber o que mostrar. + +# Função Principal + - Gerar e atualizar os elementos HTML da página. + + Exemplo: A função renderApp cria uma "div" para cada trilha existente no appState, e redrawSequencer desenha os quadradinhos de notas, colorindo os que estão ativos. + +```JavaScript + +// Exemplo da lógica para desenhar os passos da batida +for (let i = 0; i < totalGridSteps; i++) { + const stepElement = document.createElement("div"); + // Se a nota 'i' estiver marcada como ativa no estado, adiciona a classe 'active' + if (patternSteps[i] === true) { + stepElement.classList.add("active"); + } + sequencerContainer.appendChild(stepElement); +} +``` + +# audio.js - O Módulo de Som + + Este é o motor de áudio. Ele usa a API de Áudio do navegador para carregar os samples (os sons dos instrumentos), controlar a reprodução, gerenciar o tempo (BPM), tocar o metrônomo e garantir que as notas toquem na hora certa. + +# Função Principal + - Controlar a reprodução de áudio e o tempo da música. + + Exemplo: A função tick é chamada repetidamente em um intervalo de tempo preciso (calculado a partir do BPM). A cada chamada, ela verifica quais notas devem ser tocadas no passo atual e dispara os sons correspondentes. + +```JavaScript + +function tick() { + // Para cada trilha... + appState.tracks.forEach((track) => { + // Se a nota no passo atual estiver ativa... + if (activePattern.steps[appState.currentStep]) { + // Toca o som daquela trilha + playSample(track.samplePath, track.id); + } + }); + // Avança para o próximo passo + appState.currentStep = (appState.currentStep + 1) % totalSteps; +} +``` + +# file.js - O Gerenciador de Arquivos + + Responsável por ler, interpretar e salvar arquivos de projeto. Ele sabe como decodificar o formato .mmp (baseado em XML) e traduzi-lo para a estrutura de dados que a aplicação entende (appState). Também faz o caminho inverso, convertendo o estado atual do projeto em um arquivo .mmp que pode ser salvo. + +# Função Principal + - Ler e escrever projetos no formato .mmp. + + Exemplo: Ao carregar um arquivo, a função parseMmpContent usa o DOMParser do navegador para ler o XML, extrair informações como BPM e as notas de cada trilha, e popular o appState. + +# Utilitários + - config.js e utils.js + +# config.js + - Guarda valores constantes e configurações padrão, como o volume inicial ou o pan. Isso facilita a manutenção, mantendo números "mágicos" em um só lugar. + +# utils.js + - Contém pequenas funções de ajuda usadas em várias partes do código. Por exemplo, a função getTotalSteps calcula quantos "passos" a música terá com base no número de compassos e na fórmula de compasso definidos pelo usuário. + +# upload_server.py - O Servidor de Suporte + + Este é um pequeno servidor web escrito em Python que roda nos bastidores. Sua principal tarefa é permitir o upload de novos samples de áudio. Quando um novo arquivo é enviado, ele o salva na pasta correta e, crucialmente, atualiza os arquivos de "manifesto" (.json). Esses manifestos são listas que a interface web consulta para saber quais samples e projetos estão disponíveis para serem carregados. + +# Função Principal + - Receber uploads de arquivos e manter os manifestos de samples e projetos atualizados. + + Exemplo: Um usuário faz o upload do arquivo kick_drum.wav. O servidor salva este arquivo e automaticamente adiciona uma entrada para kick_drum.wav no arquivo samples-manifest.json. Da próxima vez que o usuário abrir o navegador de samples, o novo bumbo aparecerá na lista. + +# style.css - A Aparência + + Este é o arquivo de estilização. Ele define todas as cores, tamanhos, fontes e o layout da aplicação. É o que dá ao sequenciador sua aparência de "software de música", com temas escuros e elementos visuais que lembram equipamentos de estúdio. + +# Função Principal + - Definir o estilo visual de todos os componentes. + + Exemplo: O estilo para um passo ativo no sequenciador é definido aqui, fazendo-o brilhar em verde. + +```CSS + +.step.active { + background-color: var(--accent-green); + border: 1px solid #fff; + box-shadow: 0 0 8px var(--accent-green); +} +``` \ No newline at end of file diff --git a/src/samples/samples/bassdrum_acoustic02_-_Copia.ogg b/src/samples/samples/bassdrum_acoustic02_-_Copia.ogg new file mode 100644 index 0000000000000000000000000000000000000000..ece972e944db4247a782300597bb81f2e90e982a GIT binary patch literal 15836 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