diff --git a/assets/css/style.css b/assets/css/style.css index a8bfced..c136f74 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -155,6 +155,36 @@ body.sidebar-hidden .sample-browser { .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; } +/* Estilo para o botão de gravação */ +#record-btn.active.recording { + color: var(--accent-red); + animation: pulse-red 1.5s infinite; +} + +/* Animação de "pulsar" */ +@keyframes pulse-red { + 0% { + opacity: 1; + text-shadow: 0 0 3px var(--accent-red); + } + 50% { + opacity: 0.7; + text-shadow: 0 0 10px var(--accent-red); + } + 100% { + opacity: 1; + text-shadow: 0 0 3px var(--accent-red); + } +} + +#live-waveform-canvas { + width: 100%; /* Faz ocupar a largura do painel .track-info */ + height: 40px; /* Altura que definimos em JS */ + background-color: var(--background-dark); + border-radius: 4px; + margin-top: 8px; /* Um pequeno espaçamento */ + border: 1px solid var(--border-color-light); +} /* =============================================== */ /* EDITOR DE BASES (BEAT EDITOR / STEP SEQUENCER) diff --git a/assets/js/creations/audio/audio_state.js b/assets/js/creations/audio/audio_state.js index ad1f91a..5cb416d 100644 --- a/assets/js/creations/audio/audio_state.js +++ b/assets/js/creations/audio/audio_state.js @@ -58,12 +58,15 @@ export async function loadAudioForClip(clip) { return clip; } -export function addAudioClipToTimeline(samplePath, trackId = 1, startTime = 0) { +export function addAudioClipToTimeline(samplePath, trackId = 1, startTime = 0, clipName = null) { const newClip = { id: Date.now() + Math.random(), trackId: trackId, sourcePath: samplePath, - name: samplePath.split('/').pop(), + + // --- MODIFICAÇÃO AQUI --- + // Usa o nome fornecido, ou extrai do caminho se não for fornecido + name: clipName || samplePath.split('/').pop(), startTimeInSeconds: startTime, offset: 0, diff --git a/assets/js/creations/main.js b/assets/js/creations/main.js index 80233e2..93b5c92 100644 --- a/assets/js/creations/main.js +++ b/assets/js/creations/main.js @@ -2,6 +2,7 @@ import { appState, resetProjectState } from "./state.js"; import { addTrackToState, removeLastTrackFromState } from "./pattern/pattern_state.js"; // --- CORREÇÃO AQUI --- +import { toggleRecording } from "./recording.js"; import { addAudioTrackLane, removeAudioClip } from "./audio/audio_state.js"; import { updateTransportLoop } from "./audio/audio_audio.js"; import { @@ -56,6 +57,7 @@ document.addEventListener("DOMContentLoaded", () => { // --- NOVOS BOTÕES --- const resizeToolTrimBtn = document.getElementById("resize-tool-trim"); const resizeToolStretchBtn = document.getElementById("resize-tool-stretch"); + const recordBtn = document.getElementById("record-btn"); const mmpFileInput = document.getElementById("mmp-file-input"); const sampleFileInput = document.getElementById("sample-file-input"); @@ -68,6 +70,15 @@ document.addEventListener("DOMContentLoaded", () => { const zoomOutBtn = document.getElementById("zoom-out-btn"); // --- LISTENERS ADICIONADOS (COM LÓGICA DE CONTROLLER) --- + + // --- NOVO LISTENER PARA O BOTÃO DE GRAVAR --- + if (recordBtn) { + recordBtn.addEventListener("click", () => { + // Garante que o contexto de áudio foi iniciado por um gesto do usuário + initializeAudioContext(); + toggleRecording(); + }); + } // Listener para o botão "Excluir Clipe" no menu de contexto const deleteClipBtn = document.getElementById('delete-clip'); diff --git a/assets/js/creations/recording.js b/assets/js/creations/recording.js new file mode 100644 index 0000000..83868c7 --- /dev/null +++ b/assets/js/creations/recording.js @@ -0,0 +1,246 @@ +// js/recording.js +import { appState } from './state.js'; +import { addAudioTrackLane, addAudioClipToTimeline } from './audio/audio_state.js'; +import { renderAudioEditor } from './audio/audio_ui.js'; + +let userMedia = null; +let recorder = null; +let isRecordingInitialized = false; + +// --- NOVO: Variáveis para análise e desenho em tempo real --- +let analyser = null; +let liveWaveformCanvas = null; +let liveWaveformCtx = null; +let animationFrameId = null; +let currentRecordingTrackId = null; +// ----------------------------------------------------------- + +/** + * Pede permissão e prepara o microfone (Tone.UserMedia) e o gravador (Tone.Recorder). + */ +async function _initializeRecorder() { + if (isRecordingInitialized) return true; + + try { + userMedia = new Tone.UserMedia(); + await userMedia.open(); + + recorder = new Tone.Recorder(); + + // --- NOVO: Inicializa o Analisador --- + // 'waveform' nos dá os dados de amplitude ao longo do tempo. 1024 é um bom tamanho de buffer. + analyser = new Tone.Analyser('waveform', 1024); + + // 3. Conecta o microfone a *ambos*: o gravador E o analisador + userMedia.connect(recorder); + userMedia.connect(analyser); // "Fan-out" para o analisador + + isRecordingInitialized = true; + console.log("Sistema de gravação e análise inicializado."); + return true; + + } catch (err) { + console.error("Erro ao inicializar a gravação (permissão negada?):", err); + alert("Erro ao acessar o microfone. Verifique as permissões do navegador."); + return false; + } +} + +// --- NOVO: Função para desenhar a onda ao vivo --- +function _drawLiveWaveform() { + // Continua o loop enquanto estivermos gravando + if (!appState.global.isRecording || !analyser || !liveWaveformCtx) { // + return; + } + + // Pede o próximo quadro de animação + animationFrameId = requestAnimationFrame(_drawLiveWaveform); + + // Pega os dados da forma de onda do analisador + const waveformData = analyser.getValue(); // Retorna um Float32Array + + const canvas = liveWaveformCanvas; + const ctx = liveWaveformCtx; + const width = canvas.width; + const height = canvas.height; + + ctx.clearRect(0, 0, width, height); + ctx.strokeStyle = 'var(--accent-red)'; // Cor vermelha para "gravando" + ctx.lineWidth = 1; + ctx.beginPath(); + + const sliceWidth = width * 1.0 / waveformData.length; + let x = 0; + + for (let i = 0; i < waveformData.length; i++) { + const v = waveformData[i]; // Valor entre -1.0 e 1.0 + const y = (v * 0.5 + 0.5) * height; // Mapeia para 0..height + + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + x += sliceWidth; + } + + ctx.lineTo(width, height / 2); // Linha final até o meio + ctx.stroke(); +} +// ---------------------------------------------------- + + +/** + * Inicia a gravação de fato. + */ +async function _startRecording() { + if (appState.global.isAudioEditorPlaying) { + console.warn("A gravação foi iniciada, mas o playback do editor continua."); + } + + const success = await _initializeRecorder(); + if (!success) return; + + try { + // --- MUDANÇA: Lógica movida para cá --- + // 1. Cria a pista de áudio *antes* de gravar + addAudioTrackLane(); // + + // 2. Pega o ID da pista recém-criada + const newTrack = appState.audio.tracks[appState.audio.tracks.length - 1]; // + if (!newTrack) { + console.error("Falha ao criar a nova pista de áudio no estado."); + return; + } + currentRecordingTrackId = newTrack.id; + + // 3. Renderiza o editor para a nova pista aparecer + renderAudioEditor(); // + + // 4. Encontra o painel de info da nova pista e injeta o canvas + const trackInfoPanel = document.querySelector(`.audio-track-lane[data-track-id="${currentRecordingTrackId}"] .track-info`); // + if (!trackInfoPanel) { + console.error("Não foi possível encontrar o painel da nova pista para o canvas."); + return; + } + + liveWaveformCanvas = document.createElement('canvas'); + liveWaveformCanvas.id = 'live-waveform-canvas'; + // Ajuste a largura e altura como preferir para caber no painel + liveWaveformCanvas.width = 100; + liveWaveformCanvas.height = 40; + trackInfoPanel.appendChild(liveWaveformCanvas); + liveWaveformCtx = liveWaveformCanvas.getContext('2d'); + // --- Fim da lógica movida --- + + // 5. Inicia a gravação (Tone.Recorder) + await recorder.start(); + appState.global.isRecording = true; // + console.log("Gravação iniciada..."); + _updateRecordButtonUI(true); // + + // 6. Inicia o loop de desenho + _drawLiveWaveform(); + + } catch (err) { + console.error("Erro ao iniciar a gravação:", err); + appState.global.isRecording = false; // + _updateRecordButtonUI(false); // + } +} + +/** + * Para a gravação e processa o áudio resultante. + */ +async function _stopRecording() { + if (!recorder || !userMedia) return; + + try { + const recordingBlob = await recorder.stop(); + + // --- MUDANÇA: Limpa tudo --- + userMedia.close(); + if(analyser) analyser.dispose(); // Limpa o analisador + analyser = null; + isRecordingInitialized = false; + + appState.global.isRecording = false; // + + // Para o loop de animação + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } + + // Remove o canvas temporário + if (liveWaveformCanvas) { + liveWaveformCanvas.remove(); + liveWaveformCanvas = null; + liveWaveformCtx = null; + } + // --- Fim da limpeza --- + + console.log("Gravação parada."); + _updateRecordButtonUI(false); // + + _processRecording(recordingBlob); + + } catch (err) { + console.error("Erro ao parar a gravação:", err); + appState.global.isRecording = false; // + _updateRecordButtonUI(false); // + } +} + +/** + * Adiciona o áudio gravado (Blob) ao editor. + */ +function _processRecording(blob) { + if (!blob || blob.size === 0) { + console.warn("Blob de gravação está vazio. Nada a adicionar."); + return; + } + + // --- MUDANÇA: Não criamos mais a pista aqui, apenas pegamos o ID --- + // const newTrackId = newTrack.id; // [removido] + const targetTrackId = currentRecordingTrackId; + if (!targetTrackId) { + console.error("ID da pista de gravação não encontrado."); + return; + } + currentRecordingTrackId = null; // Limpa o ID + // ------------------------------------------------------------------ + + const blobUrl = URL.createObjectURL(blob); + const clipName = `Rec_${new Date().toISOString().slice(11, 19).replace(/:/g, '-')}`; + + // Adiciona o clipe à pista que já criamos + addAudioClipToTimeline(blobUrl, targetTrackId, 0, clipName); // + + // addAudioClipToTimeline já chama o render, mas como o estado mudou + // (o clipe foi adicionado), renderizar de novo garante que o + // waveform *final* (do blob) seja desenhado corretamente. + renderAudioEditor(); // +} + +/** + * Atualiza o visual do botão de gravação. + */ +function _updateRecordButtonUI(isRecording) { + const recordBtn = document.getElementById("record-btn"); // + if (recordBtn) { + recordBtn.classList.toggle("active", isRecording); + recordBtn.classList.toggle("recording", isRecording); + } +} + +/** + * Função pública que será chamada pelo botão em main.js + */ +export function toggleRecording() { + if (appState.global.isRecording) { // + _stopRecording(); + } else { + _startRecording(); + } +} \ No newline at end of file diff --git a/assets/js/creations/state.js b/assets/js/creations/state.js index 2b62d07..0ec3a24 100644 --- a/assets/js/creations/state.js +++ b/assets/js/creations/state.js @@ -25,6 +25,9 @@ const globalState = { // --- ADICIONADO PARA O MODO DE REDIMENSIONAMENTO --- resizeMode: 'trim', // Pode ser 'trim' (Modo 2) ou 'stretch' (Modo 1) selectedClipId: null, + + // --- RECORDING --- + isRecording: false, }; // Combina todos os estados em um único objeto namespaced @@ -56,5 +59,8 @@ export function resetProjectState() { loopEndTime: 8, resizeMode: 'trim', // Reseta para o modo 'trim' selectedClipId: null, + + // --- RECORDING --- + isRecording: false, }); } \ No newline at end of file diff --git a/creation.html b/creation.html index b83e3fc..60c0fd6 100644 --- a/creation.html +++ b/creation.html @@ -10,7 +10,65 @@ href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" /> - + + +