sync mode on
This commit is contained in:
1 parent
1818932244
commit
1174b328e5
415 files changed
+44294
-1182
No files matched your search
@@ -1,5 +1,7 @@
|
||||
// js/audio.js
|
||||
|
||||
import * as Tone from "https://esm.sh/tone";
|
||||
|
||||
// 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() {
|
||||
@@ -9,10 +11,13 @@ export function initializeAudioContext() {
|
||||
}
|
||||
}
|
||||
|
||||
// Funções de acesso ao contexto global do Tone.js
|
||||
// ✅ DEPOIS: devolve o *raw* AudioContext (nativo do Web Audio)
|
||||
export function getAudioContext() {
|
||||
return Tone.context;
|
||||
// compatível com versões novas/antigas do Tone
|
||||
const ctx = typeof Tone.getContext === 'function' ? Tone.getContext() : Tone.context;
|
||||
return ctx.rawContext || ctx; // rawContext quando existir
|
||||
}
|
||||
|
||||
export function getMainGainNode() {
|
||||
return Tone.Destination;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// js/audio/audio_audio.js
|
||||
import { appState } from "../state.js";
|
||||
import { updateAudioEditorUI, updatePlayheadVisual, resetPlayheadVisual } from "./audio_ui.js";
|
||||
import { initializeAudioContext, getAudioContext } from "../audio.js";
|
||||
import { initializeAudioContext, getAudioContext, getMainGainNode } from "../audio.js";
|
||||
import { getPixelsPerSecond } from "../utils.js";
|
||||
// 🔊 ADIÇÃO: usar a MESMA instância do Tone que o projeto usa
|
||||
import * as Tone from "https://esm.sh/tone";
|
||||
|
||||
// --- Configurações do Scheduler ---
|
||||
const LOOKAHEAD_INTERVAL_MS = 25.0;
|
||||
@@ -16,16 +18,17 @@ let animationFrameId = null;
|
||||
|
||||
// Sincronização de Tempo
|
||||
let startTime = 0;
|
||||
let seekTime = 0;
|
||||
let logicalPlaybackTime = 0;
|
||||
// (seek/logical ficam em appState.audio)
|
||||
|
||||
// Configurações de Loop
|
||||
let isLoopActive = false;
|
||||
let loopStartTimeSec = 0;
|
||||
let loopEndTimeSec = 8;
|
||||
|
||||
// estado runtime
|
||||
const runtimeClipState = new Map();
|
||||
const scheduledNodes = new Map();
|
||||
// ⚠️ agora armazenamos Tone.Player em vez de BufferSource
|
||||
const scheduledNodes = new Map(); // eventId -> { player, clipId }
|
||||
let nextEventId = 0;
|
||||
|
||||
const callbacks = {
|
||||
@@ -42,49 +45,78 @@ function _getSecondsPerBeat() { return 60.0 / _getBpm(); }
|
||||
function _convertBeatToSeconds(beat) { return beat * _getSecondsPerBeat(); }
|
||||
function _convertSecondsToBeat(seconds) { return seconds / _getSecondsPerBeat(); }
|
||||
|
||||
|
||||
// garante um único contexto — o rawContext do Tone
|
||||
function _initContext() {
|
||||
if (!audioCtx) {
|
||||
initializeAudioContext();
|
||||
audioCtx = getAudioContext();
|
||||
audioCtx = getAudioContext(); // deve ser o rawContext do Tone
|
||||
}
|
||||
}
|
||||
|
||||
// --- Lógica Principal do Scheduler (sem alterações) ---
|
||||
// helper: normaliza AudioBuffer → ToneAudioBuffer (mesmo contexto)
|
||||
function _toToneBuffer(buffer) {
|
||||
if (!buffer) return null;
|
||||
if (buffer._buffer) return buffer; // já é Tone.ToneAudioBuffer
|
||||
const tab = new Tone.ToneAudioBuffer();
|
||||
tab._buffer = buffer; // injeta o AudioBuffer (já no rawContext do Tone)
|
||||
return tab;
|
||||
}
|
||||
|
||||
// --- Lógica Principal do Scheduler (mantida) ---
|
||||
|
||||
function _scheduleClip(clip, absolutePlayTime, durationSec) {
|
||||
if (!clip.buffer) {
|
||||
console.warn(`Clip ${clip.id} não possui áudio buffer carregado.`);
|
||||
return;
|
||||
}
|
||||
if (!clip.gainNode || !clip.pannerNode) {
|
||||
console.warn(`Clip ${clip.id} não possui gainNode ou pannerNode.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const source = new Tone.BufferSource(clip.buffer);
|
||||
source.connect(clip.gainNode);
|
||||
|
||||
// --- CORREÇÃO: Aplica o pitch (que pode ser de stretch ou wheel) ---
|
||||
if (clip.pitch && clip.pitch !== 0) {
|
||||
source.playbackRate.value = Math.pow(2, clip.pitch / 12);
|
||||
} else {
|
||||
source.playbackRate.value = 1.0; // Garante que o modo 'trim' toque normal
|
||||
}
|
||||
// --- FIM DA CORREÇÃO ---
|
||||
// usamos Player .sync() conectando no mesmo grafo do Tone
|
||||
const toneBuf = _toToneBuffer(clip.buffer);
|
||||
if (!toneBuf) return;
|
||||
|
||||
// cadeia de ganho/pan por clipe (se já tiver no estado, use; aqui garantimos)
|
||||
const gain = clip.gainNode instanceof Tone.Gain ? clip.gainNode : new Tone.Gain(clip.volume ?? 1);
|
||||
const pan = clip.pannerNode instanceof Tone.Panner ? clip.pannerNode : new Tone.Panner(clip.pan ?? 0);
|
||||
|
||||
// conecta no destino principal (é um ToneAudioNode)
|
||||
try {
|
||||
gain.disconnect(); // evita duplicatas caso exista de execuções anteriores
|
||||
} catch {}
|
||||
try {
|
||||
pan.disconnect();
|
||||
} catch {}
|
||||
gain.connect(pan).connect(getMainGainNode());
|
||||
|
||||
// player sincronizado no Transport
|
||||
const player = new Tone.Player(toneBuf).sync().connect(gain);
|
||||
|
||||
// aplica pitch como rate (semitons → rate)
|
||||
const rate = (clip.pitch && clip.pitch !== 0) ? Math.pow(2, clip.pitch / 12) : 1;
|
||||
player.playbackRate = rate;
|
||||
|
||||
// calculamos o "when" no tempo do Transport:
|
||||
// absolutePlayTime é em audioCtx.currentTime; o "zero" lógico foi quando demos play:
|
||||
// logical = (now - startTime) + seek; => occurrence = (absolutePlayTime - startTime) + seek
|
||||
const occurrenceInTransportSec = (absolutePlayTime - startTime) + (appState.audio.audioEditorSeekTime || 0);
|
||||
|
||||
const offset = clip.offsetInSeconds ?? clip.offset ?? 0;
|
||||
const dur = durationSec ?? toneBuf.duration;
|
||||
|
||||
// agenda
|
||||
player.start(occurrenceInTransportSec, offset, dur);
|
||||
|
||||
const eventId = nextEventId++;
|
||||
const clipOffset = clip.offsetInSeconds || clip.offset || 0;
|
||||
source.start(absolutePlayTime, clipOffset, durationSec);
|
||||
scheduledNodes.set(eventId, { sourceNode: source, clipId: clip.id });
|
||||
scheduledNodes.set(eventId, { player, clipId: clip.id });
|
||||
|
||||
if (callbacks.onClipScheduled) {
|
||||
callbacks.onClipScheduled(clip);
|
||||
}
|
||||
|
||||
source.onended = () => {
|
||||
// quando parar naturalmente, limpamos runtime
|
||||
player.onstop = () => {
|
||||
_handleClipEnd(eventId, clip.id);
|
||||
source.dispose();
|
||||
try { player.unsync(); } catch {}
|
||||
try { player.dispose(); } catch {}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,7 +126,7 @@ function _handleClipEnd(eventId, clipId) {
|
||||
|
||||
if (callbacks.onClipPlayed) {
|
||||
const clip = appState.audio.clips.find(c => c.id == clipId);
|
||||
if(clip) callbacks.onClipPlayed(clip);
|
||||
if (clip) callbacks.onClipPlayed(clip);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,26 +134,18 @@ function _schedulerTick() {
|
||||
if (!isPlaying || !audioCtx) return;
|
||||
|
||||
const now = audioCtx.currentTime;
|
||||
const logicalTime = (now - startTime) + seekTime;
|
||||
const logicalTime = (now - startTime) + (appState.audio.audioEditorSeekTime || 0);
|
||||
const scheduleWindowStartSec = logicalTime;
|
||||
const scheduleWindowEndSec = logicalTime + SCHEDULE_AHEAD_TIME_SEC;
|
||||
|
||||
for (const clip of appState.audio.clips) {
|
||||
const clipRuntime = runtimeClipState.get(clip.id) || { isScheduled: false };
|
||||
|
||||
if (clipRuntime.isScheduled) {
|
||||
continue;
|
||||
}
|
||||
if (!clip.buffer) {
|
||||
continue;
|
||||
}
|
||||
if (clipRuntime.isScheduled) continue;
|
||||
if (!clip.buffer) continue;
|
||||
|
||||
const clipStartTimeSec = clip.startTimeInSeconds;
|
||||
const clipDurationSec = clip.durationInSeconds;
|
||||
|
||||
if (typeof clipStartTimeSec === 'undefined' || typeof clipDurationSec === 'undefined') {
|
||||
continue;
|
||||
}
|
||||
if (typeof clipStartTimeSec === 'undefined' || typeof clipDurationSec === 'undefined') continue;
|
||||
|
||||
let occurrenceStartTimeSec = clipStartTimeSec;
|
||||
|
||||
@@ -137,11 +161,12 @@ function _schedulerTick() {
|
||||
occurrenceStartTimeSec += loopsMissed * loopDuration;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
occurrenceStartTimeSec >= scheduleWindowStartSec &&
|
||||
occurrenceStartTimeSec < scheduleWindowEndSec
|
||||
) {
|
||||
const absolutePlayTime = startTime + (occurrenceStartTimeSec - seekTime);
|
||||
const absolutePlayTime = startTime + (occurrenceStartTimeSec - (appState.audio.audioEditorSeekTime || 0));
|
||||
_scheduleClip(clip, absolutePlayTime, clipDurationSec);
|
||||
clipRuntime.isScheduled = true;
|
||||
runtimeClipState.set(clip.id, clipRuntime);
|
||||
@@ -149,23 +174,26 @@ function _schedulerTick() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Loop de Animação (sem alterações) ---
|
||||
// --- Loop de Animação (mantido) ---
|
||||
function _animationLoop() {
|
||||
if (!isPlaying) {
|
||||
animationFrameId = null;
|
||||
return;
|
||||
}
|
||||
const now = audioCtx.currentTime;
|
||||
let newLogicalTime = (now - startTime) + seekTime;
|
||||
let newLogicalTime = (now - startTime) + (appState.audio.audioEditorSeekTime || 0);
|
||||
|
||||
if (isLoopActive) {
|
||||
if (newLogicalTime >= loopEndTimeSec) {
|
||||
const loopDuration = loopEndTimeSec - loopStartTimeSec;
|
||||
newLogicalTime = loopStartTimeSec + ((newLogicalTime - loopStartTimeSec) % loopDuration);
|
||||
startTime = now;
|
||||
seekTime = newLogicalTime;
|
||||
appState.audio.audioEditorSeekTime = newLogicalTime;
|
||||
}
|
||||
}
|
||||
logicalPlaybackTime = newLogicalTime;
|
||||
|
||||
appState.audio.audioEditorLogicalTime = newLogicalTime;
|
||||
|
||||
if (!isLoopActive) {
|
||||
let maxTime = 0;
|
||||
appState.audio.clips.forEach(clip => {
|
||||
@@ -174,15 +202,14 @@ function _animationLoop() {
|
||||
const endTime = clipStartTime + clipDuration;
|
||||
if (endTime > maxTime) maxTime = endTime;
|
||||
});
|
||||
|
||||
if (maxTime > 0 && logicalPlaybackTime >= maxTime) {
|
||||
if (maxTime > 0 && appState.audio.audioEditorLogicalTime >= maxTime) {
|
||||
stopAudioEditorPlayback(true); // Rebobina no fim
|
||||
resetPlayheadVisual();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const pixelsPerSecond = getPixelsPerSecond();
|
||||
const newPositionPx = logicalPlaybackTime * pixelsPerSecond;
|
||||
const newPositionPx = appState.audio.audioEditorLogicalTime * pixelsPerSecond;
|
||||
updatePlayheadVisual(newPositionPx);
|
||||
animationFrameId = requestAnimationFrame(_animationLoop);
|
||||
}
|
||||
@@ -196,79 +223,111 @@ export function updateTransportLoop() {
|
||||
|
||||
runtimeClipState.clear();
|
||||
|
||||
scheduledNodes.forEach(nodeData => {
|
||||
// --- CORREÇÃO BUG 1: Remove a linha 'onended = null' ---
|
||||
nodeData.sourceNode.stop(0);
|
||||
nodeData.sourceNode.dispose();
|
||||
// parar e descartar players agendados
|
||||
scheduledNodes.forEach(({ player }) => {
|
||||
try { player.unsync(); } catch {}
|
||||
try { player.stop(); } catch {}
|
||||
try { player.dispose(); } catch {}
|
||||
});
|
||||
scheduledNodes.clear();
|
||||
}
|
||||
|
||||
export function startAudioEditorPlayback() {
|
||||
export async function startAudioEditorPlayback(seekTime) { // 1. Aceita 'seekTime' como parâmetro
|
||||
if (isPlaying) return;
|
||||
|
||||
_initContext();
|
||||
|
||||
// garante contexto ativo do Tone (gesto do usuário já ocorreu antes)
|
||||
await Tone.start();
|
||||
if (audioCtx.state === 'suspended') {
|
||||
audioCtx.resume();
|
||||
await audioCtx.resume();
|
||||
}
|
||||
|
||||
isPlaying = true;
|
||||
// --- CORREÇÃO BUG 2: Atualiza o estado global ---
|
||||
appState.global.isAudioEditorPlaying = true;
|
||||
|
||||
|
||||
// alinhamento de relógio próprio (mantido para o seu scheduler)
|
||||
startTime = audioCtx.currentTime;
|
||||
|
||||
// =================================================================
|
||||
// 👇 INÍCIO DA CORREÇÃO (Bugs 1 & 2)
|
||||
// =================================================================
|
||||
|
||||
updateTransportLoop();
|
||||
// 1. Determine o tempo de início:
|
||||
// Use o 'seekTime' recebido (da ação global) se for um número válido (>= 0).
|
||||
// Caso contrário, use o tempo de seek local atual.
|
||||
const timeToStart = (seekTime !== null && seekTime !== undefined && !isNaN(seekTime))
|
||||
? seekTime
|
||||
: (appState.audio.audioEditorSeekTime || 0); // 👈 Usa sua variável de estado
|
||||
|
||||
// 2. Atualize o estado global (para a agulha pular)
|
||||
// Isso garante que o estado local E o Tone estejam sincronizados.
|
||||
appState.audio.audioEditorSeekTime = timeToStart;
|
||||
|
||||
// 3. Alinhe o Tone.Transport a esse tempo
|
||||
try {
|
||||
Tone.Transport.seconds = timeToStart; // 👈 Usa o tempo sincronizado
|
||||
} catch {}
|
||||
|
||||
// =================================================================
|
||||
// 👆 FIM DA CORREÇÃO
|
||||
// =================================================================
|
||||
|
||||
updateTransportLoop();
|
||||
|
||||
console.log("%cIniciando Playback...", "color: #3498db;");
|
||||
|
||||
_schedulerTick();
|
||||
// inicia o Transport (para disparar os Players .sync())
|
||||
try {
|
||||
Tone.Transport.start();
|
||||
} catch {}
|
||||
|
||||
// mantém seu scheduler/animador
|
||||
_schedulerTick();
|
||||
schedulerIntervalId = setInterval(_schedulerTick, LOOKAHEAD_INTERVAL_MS);
|
||||
animationFrameId = requestAnimationFrame(_animationLoop);
|
||||
updateAudioEditorUI();
|
||||
const playBtn = document.getElementById("audio-editor-play-btn");
|
||||
if (playBtn) {
|
||||
playBtn.className = 'fa-solid fa-pause';
|
||||
}
|
||||
if (playBtn) playBtn.className = "fa-solid fa-pause";
|
||||
}
|
||||
|
||||
export function stopAudioEditorPlayback(rewind = false) {
|
||||
if (!isPlaying) return;
|
||||
|
||||
isPlaying = false;
|
||||
// --- CORREÇÃO BUG 2: Atualiza o estado global ---
|
||||
appState.global.isAudioEditorPlaying = false;
|
||||
|
||||
console.log(`%cParando Playback... (Rewind: ${rewind})`, "color: #d9534f;");
|
||||
|
||||
// para o Transport (para Players .sync())
|
||||
try { Tone.Transport.stop(); } catch {}
|
||||
|
||||
clearInterval(schedulerIntervalId);
|
||||
schedulerIntervalId = null;
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
|
||||
seekTime = logicalPlaybackTime;
|
||||
logicalPlaybackTime = 0;
|
||||
|
||||
appState.audio.audioEditorSeekTime = appState.audio.audioEditorLogicalTime || 0;
|
||||
appState.audio.audioEditorLogicalTime = 0;
|
||||
if (rewind) {
|
||||
seekTime = 0;
|
||||
appState.audio.audioEditorSeekTime = 0;
|
||||
try { Tone.Transport.seconds = 0; } catch {}
|
||||
}
|
||||
|
||||
scheduledNodes.forEach(nodeData => {
|
||||
// --- CORREÇÃO BUG 1: Remove a linha 'onended = null' ---
|
||||
nodeData.sourceNode.stop(0);
|
||||
nodeData.sourceNode.dispose();
|
||||
// parar e descartar players agendados
|
||||
scheduledNodes.forEach(({ player }) => {
|
||||
try { player.unsync(); } catch {}
|
||||
try { player.stop(); } catch {}
|
||||
try { player.dispose(); } catch {}
|
||||
});
|
||||
scheduledNodes.clear();
|
||||
runtimeClipState.clear();
|
||||
|
||||
updateAudioEditorUI();
|
||||
const playBtn = document.getElementById("audio-editor-play-btn");
|
||||
if (playBtn) {
|
||||
playBtn.className = 'fa-solid fa-play';
|
||||
}
|
||||
if (playBtn) playBtn.className = 'fa-solid fa-play';
|
||||
|
||||
if (rewind) {
|
||||
resetPlayheadVisual();
|
||||
resetPlayheadVisual();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,21 +343,26 @@ export function seekAudioEditor(newTime) {
|
||||
if (wasPlaying) {
|
||||
stopAudioEditorPlayback(false); // Pausa
|
||||
}
|
||||
seekTime = newTime;
|
||||
logicalPlaybackTime = newTime;
|
||||
|
||||
appState.audio.audioEditorSeekTime = newTime;
|
||||
appState.audio.audioEditorLogicalTime = newTime;
|
||||
|
||||
try { Tone.Transport.seconds = newTime; } catch {}
|
||||
|
||||
const pixelsPerSecond = getPixelsPerSecond();
|
||||
const newPositionPx = newTime * pixelsPerSecond;
|
||||
updatePlayheadVisual(newPositionPx);
|
||||
|
||||
if (wasPlaying) {
|
||||
startAudioEditorPlayback();
|
||||
}
|
||||
}
|
||||
|
||||
export function registerCallbacks(newCallbacks) {
|
||||
if (newCallbacks.onClipScheduled) {
|
||||
callbacks.onClipScheduled = newCallbacks.onClipScheduled;
|
||||
}
|
||||
if (newCallbacks.onClipPlayed) {
|
||||
callbacks.onClipPlayed = newCallbacks.onClipPlayed;
|
||||
}
|
||||
}
|
||||
if (newCallbacks.onClipScheduled) {
|
||||
callbacks.onClipScheduled = newCallbacks.onClipScheduled;
|
||||
}
|
||||
if (newCallbacks.onClipPlayed) {
|
||||
callbacks.onClipPlayed = newCallbacks.onClipPlayed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// js/audio/audio_clipboard.js
|
||||
import { appState } from '../state.js';
|
||||
import { removeAudioClip, loadAudioForClip } from './audio_state.js';
|
||||
import { renderAudioEditor } from './audio_ui.js';
|
||||
import { getMainGainNode } from '../audio.js';
|
||||
|
||||
/**
|
||||
* Copia o clipe selecionado para a área de transferência global.
|
||||
*/
|
||||
export function copyAudioClip() {
|
||||
const clipId = appState.global.selectedClipId;
|
||||
if (!clipId) return;
|
||||
|
||||
const clip = appState.audio.clips.find(c => c.id == clipId);
|
||||
if (!clip) return;
|
||||
|
||||
// Remove a marca de "recortado" se houver
|
||||
if (appState.global.clipboard?.cutSourceId) {
|
||||
appState.global.clipboard.cutSourceId = null;
|
||||
}
|
||||
|
||||
// Cria uma cópia limpa dos dados do clipe
|
||||
const clipData = { ...clip };
|
||||
// Remove referências a nós de áudio, que devem ser únicos
|
||||
delete clipData.gainNode;
|
||||
delete clipData.pannerNode;
|
||||
delete clipData.player;
|
||||
|
||||
appState.global.clipboard = {
|
||||
type: 'audio',
|
||||
clip: clipData,
|
||||
cutSourceId: null
|
||||
};
|
||||
|
||||
console.log("Clipe copiado:", appState.global.clipboard.clip.name);
|
||||
renderAudioEditor(); // Re-renderiza para remover o visual "cut"
|
||||
}
|
||||
|
||||
/**
|
||||
* Recorta o clipe selecionado para a área de transferência global.
|
||||
*/
|
||||
export function cutAudioClip() {
|
||||
const clipId = appState.global.selectedClipId;
|
||||
if (!clipId) return;
|
||||
|
||||
// Limpa o "cut" anterior
|
||||
if (appState.global.clipboard?.cutSourceId) {
|
||||
appState.global.clipboard.cutSourceId = null;
|
||||
}
|
||||
|
||||
const clip = appState.audio.clips.find(c => c.id == clipId);
|
||||
if (!clip) return;
|
||||
|
||||
// Cria uma cópia limpa dos dados do clipe
|
||||
const clipData = { ...clip };
|
||||
delete clipData.gainNode;
|
||||
delete clipData.pannerNode;
|
||||
delete clipData.player;
|
||||
|
||||
appState.global.clipboard = {
|
||||
type: 'audio',
|
||||
clip: clipData,
|
||||
cutSourceId: clipId // Marca o ID original para exclusão
|
||||
};
|
||||
|
||||
console.log("Clipe recortado:", appState.global.clipboard.clip.name);
|
||||
renderAudioEditor(); // Re-renderiza para adicionar o visual "cut"
|
||||
}
|
||||
|
||||
/**
|
||||
* Cola o clipe da área de transferência na timeline.
|
||||
* @param {number} targetTrackId - O ID da pista onde colar.
|
||||
* @param {number} targetTimeInSeconds - O tempo (em segundos) onde colar.
|
||||
*/
|
||||
export function pasteAudioClip(targetTrackId, targetTimeInSeconds) {
|
||||
const clipboard = appState.global.clipboard;
|
||||
if (!clipboard || clipboard.type !== 'audio' || !clipboard.clip) {
|
||||
console.warn("Área de transferência vazia ou inválida para colar áudio.");
|
||||
return;
|
||||
}
|
||||
|
||||
const clipToPaste = clipboard.clip;
|
||||
|
||||
// Se for um "recorte", primeiro remove o clipe original
|
||||
if (clipboard.cutSourceId) {
|
||||
removeAudioClip(clipboard.cutSourceId); //
|
||||
clipboard.cutSourceId = null; // Limpa para que não remova de novo
|
||||
}
|
||||
|
||||
// Cria um novo clipe a partir dos dados copiados
|
||||
const newClip = {
|
||||
...clipToPaste,
|
||||
id: Date.now() + Math.random(), // ID novo e único
|
||||
trackId: targetTrackId,
|
||||
startTimeInSeconds: targetTimeInSeconds,
|
||||
|
||||
// Cria novos nós de áudio
|
||||
gainNode: new Tone.Gain(Tone.gainToDb(clipToPaste.volume || 1.0)),
|
||||
pannerNode: new Tone.Panner(clipToPaste.pan || 0),
|
||||
|
||||
player: null,
|
||||
// O buffer será copiado/referenciado
|
||||
};
|
||||
|
||||
newClip.gainNode.connect(newClip.pannerNode);
|
||||
newClip.pannerNode.connect(getMainGainNode()); //
|
||||
|
||||
appState.audio.clips.push(newClip);
|
||||
|
||||
// Como o buffer já deve existir no clipe original,
|
||||
// não precisamos de 'loadAudioForClip', mas chamamos
|
||||
// para garantir (caso a fonte seja um 'sourcePath').
|
||||
// A função 'loadAudioForClip' precisa ser inteligente
|
||||
// para não recarregar se o buffer já existir.
|
||||
loadAudioForClip(newClip).then(() => { //
|
||||
renderAudioEditor();
|
||||
});
|
||||
}
|
||||
@@ -1,18 +1,89 @@
|
||||
// js/audio_state.js
|
||||
// js/audio/audio_state.js
|
||||
import { DEFAULT_VOLUME, DEFAULT_PAN } from "../config.js";
|
||||
import { renderAudioEditor } from "./audio_ui.js";
|
||||
import { getMainGainNode } from "../audio.js";
|
||||
import { getAudioContext } from "../audio.js";
|
||||
import { getMainGainNode, getAudioContext } from "../audio.js";
|
||||
import * as Tone from "https://esm.sh/tone";
|
||||
|
||||
export let audioState = {
|
||||
tracks: [],
|
||||
clips: [],
|
||||
// --- TEMPOS MOVIDOS DO audio_audio.js PARA O ESTADO GLOBAL ---
|
||||
audioEditorSeekTime: 0,
|
||||
audioEditorLogicalTime: 0,
|
||||
// --- FIM DA MUDANÇA ---
|
||||
audioEditorStartTime: 0,
|
||||
audioEditorAnimationId: null,
|
||||
audioEditorPlaybackTime: 0,
|
||||
isAudioEditorLoopEnabled: false,
|
||||
};
|
||||
|
||||
// ==== SNAPSHOT: exportação do estado atual (tracks + clips) ====
|
||||
export function getAudioSnapshot() {
|
||||
// Se seu estado “oficial” é audioState.* use ele;
|
||||
// se for appState.audio.* troque abaixo.
|
||||
const tracks = (audioState.tracks || []).map(t => ({
|
||||
id: t.id, name: t.name
|
||||
}));
|
||||
|
||||
const clips = (audioState.clips || []).map(c => ({
|
||||
id: c.id,
|
||||
trackId: c.trackId,
|
||||
name: c.name,
|
||||
sourcePath: c.sourcePath || null, // URL do asset (precisa ser acessível)
|
||||
startTimeInSeconds: c.startTimeInSeconds || 0,
|
||||
durationInSeconds: c.durationInSeconds || (c.buffer?.duration || 0),
|
||||
offset: c.offset || 0,
|
||||
pitch: c.pitch || 0,
|
||||
volume: c.volume ?? 1,
|
||||
pan: c.pan ?? 0,
|
||||
originalDuration: c.originalDuration || (c.buffer?.duration || 0),
|
||||
}));
|
||||
|
||||
return { tracks, clips };
|
||||
}
|
||||
|
||||
// ==== SNAPSHOT: aplicação do estado recebido ====
|
||||
export async function applyAudioSnapshot(snapshot) {
|
||||
if (!snapshot) return;
|
||||
|
||||
// aplica trilhas (mantém ids/nome)
|
||||
if (Array.isArray(snapshot.tracks) && snapshot.tracks.length) {
|
||||
audioState.tracks = snapshot.tracks.map(t => ({ id: t.id, name: t.name }));
|
||||
}
|
||||
|
||||
// insere clipes usando os MESMOS ids do emissor (idempotente)
|
||||
if (Array.isArray(snapshot.clips)) {
|
||||
for (const c of snapshot.clips) {
|
||||
// evita duplicar se já existir (idempotência)
|
||||
if (audioState.clips.some(x => String(x.id) === String(c.id))) continue;
|
||||
|
||||
// usa a própria função de criação (agora ela aceita id e nome)
|
||||
// assinatura: addAudioClipToTimeline(samplePath, trackId, start, clipId, name)
|
||||
addAudioClipToTimeline(c.sourcePath, c.trackId, c.startTimeInSeconds, c.id, c.name);
|
||||
|
||||
// aplica propriedades adicionais (dur/offset/pitch/vol/pan) no mesmo id
|
||||
const idx = audioState.clips.findIndex(x => String(x.id) === String(c.id));
|
||||
if (idx >= 0) {
|
||||
const clip = audioState.clips[idx];
|
||||
clip.durationInSeconds = c.durationInSeconds;
|
||||
clip.offset = c.offset;
|
||||
clip.pitch = c.pitch;
|
||||
clip.volume = c.volume;
|
||||
clip.pan = c.pan;
|
||||
clip.originalDuration = c.originalDuration;
|
||||
|
||||
// reflete nos nós Tone já criados
|
||||
if (clip.gainNode) clip.gainNode.gain.value = clip.volume ?? 1;
|
||||
if (clip.pannerNode) clip.pannerNode.pan.value = clip.pan ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// re-render geral do editor
|
||||
renderAudioEditor();
|
||||
}
|
||||
|
||||
|
||||
export function initializeAudioState() {
|
||||
audioState.clips.forEach(clip => {
|
||||
if (clip.pannerNode) clip.pannerNode.dispose();
|
||||
@@ -21,6 +92,10 @@ export function initializeAudioState() {
|
||||
Object.assign(audioState, {
|
||||
tracks: [],
|
||||
clips: [],
|
||||
// --- ADICIONADO ---
|
||||
audioEditorSeekTime: 0,
|
||||
audioEditorLogicalTime: 0,
|
||||
// --- FIM ---
|
||||
audioEditorStartTime: 0,
|
||||
audioEditorAnimationId: null,
|
||||
audioEditorPlaybackTime: 0,
|
||||
@@ -29,6 +104,16 @@ export function initializeAudioState() {
|
||||
}
|
||||
|
||||
export async function loadAudioForClip(clip) {
|
||||
// --- ADIÇÃO ---
|
||||
// Se já temos um buffer (do bounce ou colagem), não faz fetch
|
||||
if (clip.buffer) {
|
||||
// Garante que as durações estão corretas
|
||||
if (clip.originalDuration === 0) clip.originalDuration = clip.buffer.duration;
|
||||
if (clip.durationInSeconds === 0) clip.durationInSeconds = clip.buffer.duration;
|
||||
return clip;
|
||||
}
|
||||
// --- FIM DA ADIÇÃO ---
|
||||
|
||||
if (!clip.sourcePath) return clip;
|
||||
|
||||
const audioCtx = getAudioContext();
|
||||
@@ -58,51 +143,82 @@ export async function loadAudioForClip(clip) {
|
||||
return clip;
|
||||
}
|
||||
|
||||
export function addAudioClipToTimeline(samplePath, trackId = 1, startTime = 0, clipName = null) {
|
||||
// helper de id (fallback se o emissor não mandar)
|
||||
function genClipId() {
|
||||
return (crypto?.randomUUID?.() || `clip_${Date.now()}_${Math.floor(Math.random()*1e6)}`);
|
||||
}
|
||||
|
||||
// --- FUNÇÃO MODIFICADA ---
|
||||
// agora aceita clipId e clipName vindos do emissor; mantém compat com chamadas antigas
|
||||
export function addAudioClipToTimeline(samplePath, trackId = 1, startTime = 0, clipIdOrName = null, nameOrBuffer = null, maybeBuffer = null) {
|
||||
// compat: se passaram (filePath, trackId, start, clipId)
|
||||
// mas versões antigas chamavam (filePath, trackId, start) ou (filePath, trackId, start, name, buffer)
|
||||
let incomingId = null;
|
||||
let clipName = null;
|
||||
let existingBuffer = null;
|
||||
|
||||
// heurística: se clipIdOrName parece um UUID/clip_ → é id, senão é nome
|
||||
if (typeof clipIdOrName === 'string' && (clipIdOrName.startsWith('clip_') || clipIdOrName.length >= 16)) {
|
||||
incomingId = clipIdOrName;
|
||||
clipName = (typeof nameOrBuffer === 'string') ? nameOrBuffer : null;
|
||||
existingBuffer = maybeBuffer || (nameOrBuffer && typeof nameOrBuffer !== 'string' ? nameOrBuffer : null);
|
||||
} else {
|
||||
// assinatura antiga: 4º arg era nome
|
||||
clipName = (typeof clipIdOrName === 'string') ? clipIdOrName : null;
|
||||
existingBuffer = (nameOrBuffer && typeof nameOrBuffer !== 'string') ? nameOrBuffer : null;
|
||||
}
|
||||
|
||||
const finalId = incomingId || genClipId();
|
||||
|
||||
// idempotência: se o id já existe, não duplica
|
||||
if (audioState.clips.some(c => String(c.id) === String(finalId))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newClip = {
|
||||
id: Date.now() + Math.random(),
|
||||
id: finalId,
|
||||
trackId: trackId,
|
||||
sourcePath: samplePath,
|
||||
|
||||
// --- MODIFICAÇÃO AQUI ---
|
||||
// Usa o nome fornecido, ou extrai do caminho se não for fornecido
|
||||
name: clipName || samplePath.split('/').pop(),
|
||||
sourcePath: samplePath, // Pode ser null se existingBuffer for fornecido
|
||||
name: clipName || (samplePath ? String(samplePath).split('/').pop() : 'Bounced Clip'),
|
||||
|
||||
startTimeInSeconds: startTime,
|
||||
offset: 0,
|
||||
durationInSeconds: 0,
|
||||
originalDuration: 0, // Será preenchido pelo loadAudioForClip
|
||||
originalDuration: 0,
|
||||
|
||||
pitch: 0,
|
||||
volume: DEFAULT_VOLUME,
|
||||
pan: DEFAULT_PAN,
|
||||
|
||||
gainNode: new Tone.Gain(Tone.gainToDb(DEFAULT_VOLUME)),
|
||||
pannerNode: new Tone.Panner(DEFAULT_PAN),
|
||||
|
||||
buffer: null,
|
||||
buffer: existingBuffer || null,
|
||||
player: null,
|
||||
};
|
||||
|
||||
// volume linear (0–1)
|
||||
newClip.gainNode = new Tone.Gain(DEFAULT_VOLUME);
|
||||
newClip.pannerNode = new Tone.Panner(DEFAULT_PAN);
|
||||
|
||||
// conecta tudo no grafo do Tone (mesmo contexto)
|
||||
newClip.gainNode.connect(newClip.pannerNode);
|
||||
newClip.pannerNode.connect(getMainGainNode());
|
||||
|
||||
audioState.clips.push(newClip);
|
||||
|
||||
// loadAudioForClip agora vai lidar com 'existingBuffer'
|
||||
loadAudioForClip(newClip).then(() => {
|
||||
renderAudioEditor();
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAudioClipProperties(clipId, properties) {
|
||||
const clip = audioState.clips.find(c => c.id == clipId);
|
||||
const clip = audioState.clips.find(c => String(c.id) == String(clipId));
|
||||
if (clip) {
|
||||
Object.assign(clip, properties);
|
||||
}
|
||||
}
|
||||
|
||||
export function sliceAudioClip(clipId, sliceTimeInTimeline) {
|
||||
const originalClip = audioState.clips.find(c => c.id == clipId);
|
||||
const originalClip = audioState.clips.find(c => String(c.id) == String(clipId));
|
||||
|
||||
if (!originalClip ||
|
||||
sliceTimeInTimeline <= originalClip.startTimeInSeconds ||
|
||||
@@ -115,7 +231,7 @@ export function sliceAudioClip(clipId, sliceTimeInTimeline) {
|
||||
const cutPointInClip = sliceTimeInTimeline - originalClip.startTimeInSeconds;
|
||||
|
||||
const newClip = {
|
||||
id: Date.now() + Math.random(),
|
||||
id: genClipId(),
|
||||
trackId: originalClip.trackId,
|
||||
sourcePath: originalClip.sourcePath,
|
||||
name: originalClip.name,
|
||||
@@ -132,7 +248,7 @@ export function sliceAudioClip(clipId, sliceTimeInTimeline) {
|
||||
volume: originalClip.volume,
|
||||
pan: originalClip.pan,
|
||||
|
||||
gainNode: new Tone.Gain(Tone.gainToDb(originalClip.volume)),
|
||||
gainNode: new Tone.Gain(originalClip.volume),
|
||||
pannerNode: new Tone.Panner(originalClip.pan),
|
||||
|
||||
player: null
|
||||
@@ -148,20 +264,19 @@ export function sliceAudioClip(clipId, sliceTimeInTimeline) {
|
||||
console.log("Clipe dividido. Original:", originalClip, "Novo:", newClip);
|
||||
}
|
||||
|
||||
// ... (resto do arquivo 'audio_state.js' sem alterações) ...
|
||||
export function updateClipVolume(clipId, volume) {
|
||||
const clip = audioState.clips.find((c) => c.id == clipId);
|
||||
const clip = audioState.clips.find((c) => String(c.id) == String(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);
|
||||
clip.gainNode.gain.value = clampedVolume;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function updateClipPan(clipId, pan) {
|
||||
const clip = audioState.clips.find((c) => c.id == clipId);
|
||||
const clip = audioState.clips.find((c) => String(c.id) == String(clipId));
|
||||
if (clip) {
|
||||
const clampedPan = Math.max(-1, Math.min(1, pan));
|
||||
clip.pan = clampedPan;
|
||||
@@ -177,19 +292,19 @@ export function addAudioTrackLane() {
|
||||
}
|
||||
|
||||
export function removeAudioClip(clipId) {
|
||||
const clipIndex = audioState.clips.findIndex(c => c.id == clipId);
|
||||
const clipIndex = audioState.clips.findIndex(c => String(c.id) == String(clipId));
|
||||
if (clipIndex === -1) return false; // Retorna false se não encontrou
|
||||
|
||||
const clip = audioState.clips[clipIndex];
|
||||
|
||||
// 1. Limpa os nós de áudio do Tone.js
|
||||
if (clip.gainNode) {
|
||||
clip.gainNode.disconnect();
|
||||
clip.gainNode.dispose();
|
||||
try { clip.gainNode.disconnect(); } catch {}
|
||||
try { clip.gainNode.dispose(); } catch {}
|
||||
}
|
||||
if (clip.pannerNode) {
|
||||
clip.pannerNode.disconnect();
|
||||
clip.pannerNode.dispose();
|
||||
try { clip.pannerNode.disconnect(); } catch {}
|
||||
try { clip.pannerNode.dispose(); } catch {}
|
||||
}
|
||||
|
||||
// 2. Remove o clipe do array de estado
|
||||
@@ -197,4 +312,4 @@ export function removeAudioClip(clipId) {
|
||||
|
||||
// 3. Retorna true para o chamador (Controller)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -8,7 +8,6 @@ export const NOTE_LENGTH = 12;
|
||||
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)
|
||||
|
||||
@@ -4,6 +4,10 @@ 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";
|
||||
import * as Tone from "https://esm.sh/tone";
|
||||
|
||||
// --- NOVA IMPORTAÇÃO ---
|
||||
import { sendAction } from "./socket.js";
|
||||
|
||||
export async function handleFileLoad(file) {
|
||||
let xmlContent = "";
|
||||
@@ -20,7 +24,12 @@ export async function handleFileLoad(file) {
|
||||
} else {
|
||||
xmlContent = await file.text();
|
||||
}
|
||||
await parseMmpContent(xmlContent);
|
||||
|
||||
// ANTES: await parseMmpContent(xmlContent);
|
||||
// DEPOIS:
|
||||
// Envia o XML para o servidor, que o transmitirá para todos (incluindo nós)
|
||||
sendAction({ type: 'LOAD_PROJECT', xml: xmlContent });
|
||||
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar o projeto:", error);
|
||||
alert(`Erro ao carregar projeto: ${error.message}`);
|
||||
@@ -34,8 +43,16 @@ export async function loadProjectFromServer(fileName) {
|
||||
throw new Error(`Não foi possível carregar o arquivo ${fileName}`);
|
||||
|
||||
const xmlContent = await response.text();
|
||||
await parseMmpContent(xmlContent);
|
||||
return true;
|
||||
|
||||
// ANTES:
|
||||
// await parseMmpContent(xmlContent);
|
||||
// return true;
|
||||
|
||||
// DEPOIS:
|
||||
// Envia o XML para o servidor
|
||||
sendAction({ type: 'LOAD_PROJECT', xml: xmlContent });
|
||||
return true; // Retorna true para que o modal de UI feche
|
||||
|
||||
} catch (error) {
|
||||
console.error("Erro ao carregar projeto do servidor:", error);
|
||||
console.error(error);
|
||||
@@ -44,6 +61,10 @@ export async function loadProjectFromServer(fileName) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- NENHUMA MUDANÇA DAQUI PARA BAIXO ---
|
||||
// 'parseMmpContent' agora é chamado pelo 'socket.js'
|
||||
// quando ele recebe a ação 'LOAD_PROJECT' ou 'load_project_state'.
|
||||
|
||||
export async function parseMmpContent(xmlString) {
|
||||
resetProjectState();
|
||||
initializeAudioContext();
|
||||
@@ -175,12 +196,12 @@ export async function parseMmpContent(xmlString) {
|
||||
let isFirstTrackWithNotes = true;
|
||||
newTracks.forEach(track => {
|
||||
// --- 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));
|
||||
// Agora usando Volume em dB (Opção B)
|
||||
track.volumeNode = new Tone.Volume(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);
|
||||
// Cadeia de áudio: Volume(dB) -> Panner -> Saída Principal
|
||||
track.volumeNode.connect(track.pannerNode);
|
||||
track.pannerNode.connect(getMainGainNode());
|
||||
// --- FIM DA CORREÇÃO ---
|
||||
|
||||
@@ -206,7 +227,13 @@ export async function parseMmpContent(xmlString) {
|
||||
|
||||
appState.pattern.tracks = newTracks;
|
||||
appState.pattern.activeTrackId = appState.pattern.tracks[0]?.id || null;
|
||||
|
||||
// força atualização total da UI e dos editores de pattern
|
||||
await Promise.resolve(); // garante que os tracks estejam no estado
|
||||
renderAll();
|
||||
|
||||
console.log('[UI] Projeto renderizado após parseMmpContent');
|
||||
|
||||
}
|
||||
|
||||
export function generateMmpFile() {
|
||||
@@ -314,4 +341,4 @@ function downloadFile(content, fileName) {
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
+368
-187
@@ -1,43 +1,65 @@
|
||||
// js/main.js
|
||||
// js/main.js (ESM com import absoluto de socket.js + ROOM_NAME local)
|
||||
|
||||
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 {
|
||||
togglePlayback,
|
||||
stopPlayback,
|
||||
rewindPlayback,
|
||||
} from "./pattern/pattern_audio.js";
|
||||
import {
|
||||
startAudioEditorPlayback,
|
||||
stopAudioEditorPlayback,
|
||||
updateTransportLoop,
|
||||
restartAudioEditorIfPlaying,
|
||||
} from "./audio/audio_audio.js";
|
||||
import { initializeAudioContext } from "./audio.js";
|
||||
import { handleFileLoad, generateMmpFile } from "./file.js";
|
||||
import { renderAll, loadAndRenderSampleBrowser, showOpenProjectModal, closeOpenProjectModal } 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 { ZOOM_LEVELS } from "./config.js";
|
||||
|
||||
// --- NOVA FUNÇÃO ---
|
||||
// Atualiza a aparência dos botões de ferramenta
|
||||
// ⚠️ IMPORT ABSOLUTO para evitar 404/text/html quando a página estiver em /creation/ ou fora dela.
|
||||
// Ajuste o prefixo abaixo para o caminho real onde seus assets vivem no servidor:
|
||||
import { sendAction, joinRoom, setUserName } from "./socket.js";
|
||||
|
||||
// Descobre a sala pela URL (local ao main.js) e expõe no window para debug
|
||||
const ROOM_NAME = new URLSearchParams(window.location.search).get("room");
|
||||
window.ROOM_NAME = ROOM_NAME;
|
||||
|
||||
// ✅ NOVO: se tem sala na URL, entra já na sala (independe do áudio)
|
||||
|
||||
if (ROOM_NAME) {
|
||||
// entra na sala para receber estado/broadcasts imediatamente
|
||||
joinRoom();
|
||||
}
|
||||
|
||||
// Função util para alternar estado dos botões de ferramenta
|
||||
function updateToolButtons() {
|
||||
const sliceToolBtn = document.getElementById("slice-tool-btn");
|
||||
const trimToolBtn = document.getElementById("resize-tool-trim");
|
||||
const stretchToolBtn = document.getElementById("resize-tool-stretch");
|
||||
const sliceToolBtn = document.getElementById("slice-tool-btn");
|
||||
const trimToolBtn = document.getElementById("resize-tool-trim");
|
||||
const stretchToolBtn = document.getElementById("resize-tool-stretch");
|
||||
|
||||
if(sliceToolBtn) sliceToolBtn.classList.toggle("active", appState.global.sliceToolActive);
|
||||
if(trimToolBtn) trimToolBtn.classList.toggle("active", !appState.global.sliceToolActive && appState.global.resizeMode === 'trim');
|
||||
if(stretchToolBtn) stretchToolBtn.classList.toggle("active", !appState.global.sliceToolActive && appState.global.resizeMode === 'stretch');
|
||||
if (sliceToolBtn)
|
||||
sliceToolBtn.classList.toggle("active", appState.global.sliceToolActive);
|
||||
if (trimToolBtn)
|
||||
trimToolBtn.classList.toggle(
|
||||
"active",
|
||||
!appState.global.sliceToolActive && appState.global.resizeMode === "trim"
|
||||
);
|
||||
if (stretchToolBtn)
|
||||
stretchToolBtn.classList.toggle(
|
||||
"active",
|
||||
!appState.global.sliceToolActive &&
|
||||
appState.global.resizeMode === "stretch"
|
||||
);
|
||||
|
||||
// Desativa a ferramenta de corte se outra for selecionada
|
||||
document.body.classList.toggle("slice-tool-active", appState.global.sliceToolActive);
|
||||
document.body.classList.toggle(
|
||||
"slice-tool-active",
|
||||
appState.global.sliceToolActive
|
||||
);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// Botões e elementos
|
||||
const newProjectBtn = document.getElementById("new-project-btn");
|
||||
const openMmpBtn = document.getElementById("open-mmp-btn");
|
||||
const saveMmpBtn = document.getElementById("save-mmp-btn");
|
||||
@@ -53,12 +75,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const rewindBtn = document.getElementById("rewind-btn");
|
||||
const metronomeBtn = document.getElementById("metronome-btn");
|
||||
const sliceToolBtn = document.getElementById("slice-tool-btn");
|
||||
|
||||
// --- NOVOS BOTÕES ---
|
||||
const resizeToolTrimBtn = document.getElementById("resize-tool-trim");
|
||||
const resizeToolStretchBtn = document.getElementById("resize-tool-stretch");
|
||||
const recordBtn = document.getElementById("record-btn");
|
||||
|
||||
const createRoomBtn = document.getElementById("create-room-btn");
|
||||
const mmpFileInput = document.getElementById("mmp-file-input");
|
||||
const sampleFileInput = document.getElementById("sample-file-input");
|
||||
const openProjectModal = document.getElementById("open-project-modal");
|
||||
@@ -68,192 +87,354 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const addBarBtn = document.getElementById("add-bar-btn");
|
||||
const zoomInBtn = document.getElementById("zoom-in-btn");
|
||||
const zoomOutBtn = document.getElementById("zoom-out-btn");
|
||||
const deleteClipBtn = document.getElementById("delete-clip");
|
||||
|
||||
// --- 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();
|
||||
// =================================================================
|
||||
// 👇 INÍCIO DA CORREÇÃO (Botão de Sincronia - Agora envia Ação)
|
||||
// =================================================================
|
||||
const syncModeBtn = document.getElementById("sync-mode-btn"); //
|
||||
if (syncModeBtn) {
|
||||
//
|
||||
// Define o estado inicial (global por padrão)
|
||||
appState.global.syncMode = "global"; //
|
||||
syncModeBtn.classList.add("active"); //
|
||||
syncModeBtn.textContent = "Global"; //
|
||||
|
||||
syncModeBtn.addEventListener("click", () => {
|
||||
//
|
||||
// 1. Determina qual será o *novo* modo
|
||||
const newMode =
|
||||
appState.global.syncMode === "global" ? "local" : "global"; //
|
||||
|
||||
// 2. Envia a ação para sincronizar. O handleActionBroadcast
|
||||
// cuidará de atualizar o appState, o botão e mostrar o toast.
|
||||
sendAction({
|
||||
type: "SET_SYNC_MODE",
|
||||
mode: newMode,
|
||||
});
|
||||
}
|
||||
|
||||
// Listener para o botão "Excluir Clipe" no menu de contexto
|
||||
const deleteClipBtn = document.getElementById('delete-clip');
|
||||
if (deleteClipBtn) {
|
||||
deleteClipBtn.addEventListener('click', () => {
|
||||
const clipId = appState.global.selectedClipId; // 1. Lê o estado
|
||||
if (clipId) {
|
||||
if (removeAudioClip(clipId)) { // 2. Chama a função de state
|
||||
appState.global.selectedClipId = null; // 3. Atualiza o estado global
|
||||
renderAudioEditor(); // 4. Renderiza a mudança
|
||||
}
|
||||
}
|
||||
// Esconde o menu
|
||||
const menu = document.getElementById('timeline-context-menu');
|
||||
if (menu) menu.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Listener global para a tecla Delete/Backspace
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Ignora se estiver digitando em um input
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
|
||||
const clipId = appState.global.selectedClipId; // 1. Lê o estado
|
||||
// Verifica se há um clipe selecionado e a tecla pressionada é Delete ou Backspace
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && clipId) {
|
||||
e.preventDefault(); // Impede o navegador de voltar a página (ação do Backspace)
|
||||
|
||||
if (removeAudioClip(clipId)) { // 2. Chama a função de state
|
||||
appState.global.selectedClipId = null; // 3. Atualiza o estado global
|
||||
renderAudioEditor(); // 4. Renderiza a mudança
|
||||
}
|
||||
}
|
||||
// Lógica antiga removida daqui (movida para o handler)
|
||||
/*
|
||||
const isNowLocal = appState.global.syncMode === "global";
|
||||
appState.global.syncMode = isNowLocal ? "local" : "global";
|
||||
syncModeBtn.classList.toggle("active", !isNowLocal);
|
||||
syncModeBtn.textContent = isNowLocal ? "Local" : "Global";
|
||||
showToast( `🎧 Modo de Playback: ${isNowLocal ? "Local" : "Global"}`, "info" );
|
||||
*/
|
||||
});
|
||||
|
||||
// Listener global para fechar menu de contexto ou desselecionar clipe
|
||||
document.addEventListener('click', (e) => {
|
||||
// Esconde o menu de contexto se clicar fora dele
|
||||
const menu = document.getElementById('timeline-context-menu');
|
||||
if (menu && !e.target.closest('#timeline-context-menu')) {
|
||||
menu.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// --- FIM DOS LISTENERS ADICIONADOS ---
|
||||
// Esconde o botão se não estiver em uma sala (lógica movida do socket.js)
|
||||
if (!ROOM_NAME) {
|
||||
//
|
||||
//syncModeBtn.style.display = 'none'; // REMOVIDO PARA TESTE VISUAL
|
||||
}
|
||||
}
|
||||
// =================================================================
|
||||
// 👆 FIM DA CORREÇÃO
|
||||
// =================================================================
|
||||
|
||||
newProjectBtn.addEventListener("click", () => {
|
||||
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';
|
||||
renderAll();
|
||||
// Excluir clipe
|
||||
if (deleteClipBtn) {
|
||||
deleteClipBtn.addEventListener("click", () => {
|
||||
initializeAudioContext();
|
||||
const clipId = appState.global.selectedClipId;
|
||||
if (clipId) {
|
||||
sendAction({ type: "REMOVE_AUDIO_CLIP", clipId });
|
||||
appState.global.selectedClipId = null;
|
||||
}
|
||||
const menu = document.getElementById("timeline-context-menu");
|
||||
if (menu) menu.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
// Delete/Backspace
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
|
||||
const clipId = appState.global.selectedClipId;
|
||||
if ((e.key === "Delete" || e.key === "Backspace") && clipId) {
|
||||
e.preventDefault();
|
||||
sendAction({ type: "REMOVE_AUDIO_CLIP", clipId });
|
||||
appState.global.selectedClipId = null;
|
||||
}
|
||||
});
|
||||
|
||||
addBarBtn.addEventListener("click", () => {
|
||||
// Fechar menu contexto
|
||||
document.addEventListener("click", (e) => {
|
||||
const menu = document.getElementById("timeline-context-menu");
|
||||
if (menu && !e.target.closest("#timeline-context-menu")) {
|
||||
menu.style.display = "none";
|
||||
}
|
||||
});
|
||||
|
||||
// Ações principais (broadcast)
|
||||
newProjectBtn?.addEventListener("click", () => {
|
||||
initializeAudioContext();
|
||||
if (
|
||||
(appState.pattern.tracks.length > 0 || appState.audio.clips.length > 0) &&
|
||||
!confirm(
|
||||
"Você tem certeza? Isso irá resetar o projeto para TODOS na sala."
|
||||
)
|
||||
)
|
||||
return;
|
||||
sendAction({ type: "RESET_PROJECT" });
|
||||
});
|
||||
|
||||
addBarBtn?.addEventListener("click", () => {
|
||||
const barsInput = document.getElementById("bars-input");
|
||||
if (barsInput) adjustValue(barsInput, 1);
|
||||
});
|
||||
|
||||
openMmpBtn.addEventListener("click", showOpenProjectModal);
|
||||
loadFromComputerBtn.addEventListener("click", () => mmpFileInput.click());
|
||||
mmpFileInput.addEventListener("change", (event) => { const file = event.target.files[0]; if (file) { handleFileLoad(file).then(() => closeOpenProjectModal()); } });
|
||||
uploadSampleBtn.addEventListener("click", () => sampleFileInput.click());
|
||||
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.global.metronomeEnabled = !appState.global.metronomeEnabled; metronomeBtn.classList.toggle("active", appState.global.metronomeEnabled); });
|
||||
|
||||
// --- LISTENERS DE FERRAMENTAS ATUALIZADOS ---
|
||||
if(sliceToolBtn) {
|
||||
sliceToolBtn.addEventListener("click", () => {
|
||||
appState.global.sliceToolActive = !appState.global.sliceToolActive;
|
||||
updateToolButtons();
|
||||
});
|
||||
}
|
||||
if(resizeToolTrimBtn) {
|
||||
resizeToolTrimBtn.addEventListener("click", () => {
|
||||
appState.global.resizeMode = 'trim';
|
||||
appState.global.sliceToolActive = false;
|
||||
updateToolButtons();
|
||||
});
|
||||
}
|
||||
if(resizeToolStretchBtn) {
|
||||
resizeToolStretchBtn.addEventListener("click", () => {
|
||||
appState.global.resizeMode = 'stretch';
|
||||
appState.global.sliceToolActive = false;
|
||||
updateToolButtons();
|
||||
});
|
||||
}
|
||||
|
||||
openModalCloseBtn.addEventListener("click", closeOpenProjectModal);
|
||||
|
||||
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";
|
||||
if (barsInput) {
|
||||
adjustValue(barsInput, 1);
|
||||
barsInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
openMmpBtn?.addEventListener("click", showOpenProjectModal);
|
||||
loadFromComputerBtn?.addEventListener("click", () => mmpFileInput?.click());
|
||||
mmpFileInput?.addEventListener("change", (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file) handleFileLoad(file).then(() => closeOpenProjectModal());
|
||||
});
|
||||
uploadSampleBtn?.addEventListener("click", () => sampleFileInput?.click());
|
||||
saveMmpBtn?.addEventListener("click", generateMmpFile);
|
||||
|
||||
addInstrumentBtn?.addEventListener("click", () => {
|
||||
initializeAudioContext();
|
||||
sendAction({ type: "ADD_TRACK" });
|
||||
});
|
||||
removeInstrumentBtn?.addEventListener("click", () => {
|
||||
initializeAudioContext();
|
||||
sendAction({ type: "REMOVE_LAST_TRACK" });
|
||||
});
|
||||
|
||||
playBtn?.addEventListener("click", () => {
|
||||
initializeAudioContext();
|
||||
sendAction({ type: "TOGGLE_PLAYBACK" });
|
||||
});
|
||||
stopBtn?.addEventListener("click", () => {
|
||||
initializeAudioContext();
|
||||
sendAction({ type: "STOP_PLAYBACK" });
|
||||
});
|
||||
rewindBtn?.addEventListener("click", () => {
|
||||
initializeAudioContext();
|
||||
sendAction({ type: "REWIND_PLAYBACK" });
|
||||
});
|
||||
|
||||
metronomeBtn?.addEventListener("click", () => {
|
||||
initializeAudioContext();
|
||||
appState.global.metronomeEnabled = !appState.global.metronomeEnabled;
|
||||
metronomeBtn.classList.toggle("active", appState.global.metronomeEnabled);
|
||||
});
|
||||
|
||||
// Ferramentas locais
|
||||
if (sliceToolBtn) {
|
||||
sliceToolBtn.addEventListener("click", () => {
|
||||
appState.global.sliceToolActive = !appState.global.sliceToolActive;
|
||||
updateToolButtons();
|
||||
});
|
||||
}
|
||||
if (resizeToolTrimBtn) {
|
||||
resizeToolTrimBtn.addEventListener("click", () => {
|
||||
appState.global.resizeMode = "trim";
|
||||
appState.global.sliceToolActive = false;
|
||||
updateToolButtons();
|
||||
});
|
||||
}
|
||||
if (resizeToolStretchBtn) {
|
||||
resizeToolStretchBtn.addEventListener("click", () => {
|
||||
appState.global.resizeMode = "stretch";
|
||||
appState.global.sliceToolActive = false;
|
||||
updateToolButtons();
|
||||
});
|
||||
}
|
||||
|
||||
openModalCloseBtn?.addEventListener("click", closeOpenProjectModal);
|
||||
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.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(); }
|
||||
if (
|
||||
appState.global.isPlaying &&
|
||||
(event.target.id.startsWith("compasso-") ||
|
||||
event.target.id === "bars-input")
|
||||
) {
|
||||
sendAction({ type: "STOP_PLAYBACK" });
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener("change", (event) => {
|
||||
const target = event.target;
|
||||
if (target.id === "bpm-input") {
|
||||
sendAction({ type: "SET_BPM", value: target.value });
|
||||
} else if (target.id === "bars-input") {
|
||||
sendAction({ type: "SET_BARS", value: target.value });
|
||||
} else if (target.id === "compasso-a-input") {
|
||||
sendAction({ type: "SET_TIMESIG_A", value: target.value });
|
||||
} else if (target.id === "compasso-b-input") {
|
||||
sendAction({ type: "SET_TIMESIG_B", value: target.value });
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener("wheel", (event) => {
|
||||
event.preventDefault();
|
||||
const step = event.deltaY < 0 ? 1 : -1;
|
||||
adjustValue(event.target, step);
|
||||
event.target.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
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); } }); });
|
||||
|
||||
if (zoomInBtn) {
|
||||
zoomInBtn.addEventListener("click", () => {
|
||||
if (appState.global.zoomLevelIndex < ZOOM_LEVELS.length - 1) {
|
||||
appState.global.zoomLevelIndex++;
|
||||
renderAll();
|
||||
}
|
||||
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);
|
||||
targetInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
}
|
||||
if (zoomOutBtn) {
|
||||
zoomOutBtn.addEventListener("click", () => {
|
||||
if (appState.global.zoomLevelIndex > 0) {
|
||||
appState.global.zoomLevelIndex--;
|
||||
renderAll();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
audioEditorPlayBtn.addEventListener("click", () => {
|
||||
if (appState.global.isAudioEditorPlaying) {
|
||||
stopAudioEditorPlayback(false); // Pausa
|
||||
} else {
|
||||
startAudioEditorPlayback();
|
||||
}
|
||||
});
|
||||
audioEditorStopBtn.addEventListener("click", () => stopAudioEditorPlayback(true)); // Stop (rebobina)
|
||||
|
||||
audioEditorLoopBtn.addEventListener("click", () => {
|
||||
appState.global.isLoopActive = !appState.global.isLoopActive;
|
||||
appState.audio.isAudioEditorLoopEnabled = appState.global.isLoopActive;
|
||||
audioEditorLoopBtn.classList.toggle("active", appState.global.isLoopActive);
|
||||
updateTransportLoop();
|
||||
const loopArea = document.getElementById("loop-region");
|
||||
if (loopArea) {
|
||||
loopArea.classList.toggle("visible", appState.global.isLoopActive);
|
||||
|
||||
// Zoom local
|
||||
zoomInBtn?.addEventListener("click", () => {
|
||||
if (appState.global.zoomLevelIndex < ZOOM_LEVELS.length - 1) {
|
||||
appState.global.zoomLevelIndex++;
|
||||
renderAll();
|
||||
}
|
||||
});
|
||||
zoomOutBtn?.addEventListener("click", () => {
|
||||
if (appState.global.zoomLevelIndex > 0) {
|
||||
appState.global.zoomLevelIndex--;
|
||||
renderAll();
|
||||
}
|
||||
restartAudioEditorIfPlaying();
|
||||
});
|
||||
|
||||
if (addAudioTrackBtn) { addAudioTrackBtn.addEventListener("click", () => { addAudioTrackLane(); renderAudioEditor(); }); }
|
||||
|
||||
// Editor de Áudio
|
||||
audioEditorPlayBtn?.addEventListener("click", () => {
|
||||
initializeAudioContext();
|
||||
if (appState.global.isAudioEditorPlaying) {
|
||||
sendAction({ type: "STOP_AUDIO_PLAYBACK", rewind: false });
|
||||
} else {
|
||||
sendAction({
|
||||
type: "START_AUDIO_PLAYBACK",
|
||||
seekTime: appState.audio.audioEditorSeekTime, // Corrigido
|
||||
loopState: {
|
||||
isLoopActive: appState.global.isLoopActive,
|
||||
loopStartTime: appState.global.loopStartTime,
|
||||
loopEndTime: appState.global.loopEndTime,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
audioEditorStopBtn?.addEventListener("click", () => {
|
||||
initializeAudioContext();
|
||||
sendAction({ type: "STOP_AUDIO_PLAYBACK", rewind: true });
|
||||
});
|
||||
|
||||
// Loop Button (agora envia ação)
|
||||
audioEditorLoopBtn?.addEventListener("click", () => {
|
||||
initializeAudioContext(); // Garante contexto
|
||||
const newLoopState = !appState.global.isLoopActive;
|
||||
sendAction({
|
||||
type: "SET_LOOP_STATE",
|
||||
isLoopActive: newLoopState,
|
||||
loopStartTime: appState.global.loopStartTime,
|
||||
loopEndTime: appState.global.loopEndTime,
|
||||
});
|
||||
});
|
||||
|
||||
if (addAudioTrackBtn) {
|
||||
addAudioTrackBtn.addEventListener("click", () => {
|
||||
initializeAudioContext();
|
||||
sendAction({ type: "ADD_AUDIO_LANE" });
|
||||
});
|
||||
}
|
||||
|
||||
// Navegador de Samples (local)
|
||||
loadAndRenderSampleBrowser();
|
||||
|
||||
const browserContent = document.getElementById('browser-content');
|
||||
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');
|
||||
}
|
||||
});
|
||||
browserContent.addEventListener("click", function (event) {
|
||||
const folderName = event.target.closest(".folder-name");
|
||||
if (folderName) {
|
||||
const folderItem = folderName.parentElement;
|
||||
folderItem.classList.toggle("open");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Criar sala (gera link com ?room=...)
|
||||
if (createRoomBtn) {
|
||||
createRoomBtn.addEventListener("click", () => {
|
||||
initializeAudioContext();
|
||||
const currentParams = new URLSearchParams(window.location.search);
|
||||
if (currentParams.has("room")) {
|
||||
alert(
|
||||
`Você já está na sala: ${currentParams.get(
|
||||
"room"
|
||||
)}\n\nCopie o link da barra de endereços para convidar.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const defaultName = `sessao-${Math.random()
|
||||
.toString(36)
|
||||
.substring(2, 7)}`;
|
||||
const roomName = prompt(
|
||||
"Digite um nome para a sala compartilhada:",
|
||||
defaultName
|
||||
);
|
||||
if (!roomName) return;
|
||||
const currentUrl = window.location.origin + window.location.pathname;
|
||||
const shareableLink = `${currentUrl}?room=${encodeURIComponent(
|
||||
roomName
|
||||
)}`;
|
||||
try {
|
||||
navigator.clipboard.writeText(shareableLink);
|
||||
alert(
|
||||
`Link da sala copiado para a área de transferência!\n\n${shareableLink}\n\nA página será recarregada agora para entrar na nova sala.`
|
||||
);
|
||||
} catch (err) {
|
||||
alert(
|
||||
`Link da sala: ${shareableLink}\n\nA página será recarregada agora para entrar na nova sala.`
|
||||
);
|
||||
}
|
||||
window.location.href = shareableLink;
|
||||
});
|
||||
}
|
||||
|
||||
// Modal “destravar áudio” + entrar na sala
|
||||
const audioUnlockModal = document.getElementById("audio-unlock-modal");
|
||||
const audioUnlockBtn = document.getElementById("audio-unlock-btn");
|
||||
|
||||
if (ROOM_NAME && audioUnlockModal && audioUnlockBtn) {
|
||||
audioUnlockModal.style.display = "flex";
|
||||
audioUnlockBtn.addEventListener("click", () => {
|
||||
const userName = prompt(
|
||||
"Qual o seu nome?",
|
||||
`Alicer-${Math.floor(Math.random() * 999)}`
|
||||
);
|
||||
if (!userName) return;
|
||||
setUserName(userName);
|
||||
initializeAudioContext();
|
||||
// joinRoom() já foi chamado no início se ROOM_NAME existe
|
||||
audioUnlockModal.style.display = "none";
|
||||
});
|
||||
} else {
|
||||
console.log("Modo local. Áudio será iniciado no primeiro clique.");
|
||||
// Comentado para permitir teste visual
|
||||
// if (syncModeBtn) syncModeBtn.style.display = "none";
|
||||
}
|
||||
|
||||
renderAll();
|
||||
updateToolButtons(); // Define o estado inicial dos botões
|
||||
});
|
||||
updateToolButtons();
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
// js/pattern_audio.js
|
||||
import * as Tone from "https://esm.sh/tone";
|
||||
|
||||
import { appState } from "../state.js";
|
||||
import { highlightStep } from "./pattern_ui.js";
|
||||
import { getTotalSteps } from "../utils.js";
|
||||
@@ -21,24 +23,39 @@ export function playMetronomeSound(isDownbeat) {
|
||||
synth.triggerAttackRelease(freq, "8n", Tone.now());
|
||||
}
|
||||
|
||||
// --- FUNÇÃO CORRIGIDA E EFICIENTE ---
|
||||
// Dispara o sample de uma track, garantindo que o player esteja roteado corretamente
|
||||
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.
|
||||
// Se a faixa existe e tem um player pré-carregado
|
||||
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)
|
||||
if (track.player.loaded) {
|
||||
// Ajusta volume/pan sempre que tocar (robustez a alterações em tempo real)
|
||||
if (track.volumeNode) {
|
||||
track.volumeNode.volume.value =
|
||||
track.volume === 0 ? -Infinity : Tone.gainToDb(track.volume);
|
||||
}
|
||||
if (track.pannerNode) {
|
||||
track.pannerNode.pan.value = track.pan ?? 0;
|
||||
}
|
||||
|
||||
// Garante conexão: player -> volumeNode (não usar mais gainNode)
|
||||
try { track.player.disconnect(); } catch {}
|
||||
if (track.volumeNode) {
|
||||
track.player.connect(track.volumeNode);
|
||||
}
|
||||
|
||||
// Dispara imediatamente
|
||||
track.player.start(Tone.now());
|
||||
} else {
|
||||
console.warn(`Player da trilha "${track.name}" ainda não carregado — pulando este tick.`);
|
||||
}
|
||||
}
|
||||
// Fallback para preview de sample sem trackId
|
||||
else if (!trackId && filePath) {
|
||||
const previewPlayer = new Tone.Player(filePath).toDestination();
|
||||
previewPlayer.autostart = true;
|
||||
const previewPlayer = new Tone.Player(filePath).toDestination();
|
||||
previewPlayer.autostart = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +76,7 @@ function tick() {
|
||||
timerDisplay.textContent = formatTime(currentTime);
|
||||
}
|
||||
|
||||
// Metrônomo
|
||||
if (appState.global.metronomeEnabled) {
|
||||
const noteValue = parseInt(document.getElementById("compasso-b-input").value, 10) || 4;
|
||||
const stepsPerBeat = 16 / noteValue;
|
||||
@@ -67,12 +85,16 @@ function tick() {
|
||||
}
|
||||
}
|
||||
|
||||
// Percorre tracks e toca o step atual se ativo
|
||||
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) {
|
||||
// IMPORTANTE: usar o pattern ativo da PRÓPRIA TRILHA
|
||||
const activePattern = track.patterns[track.activePatternIndex];
|
||||
|
||||
if (activePattern &&
|
||||
activePattern.steps[appState.global.currentStep] &&
|
||||
track.samplePath) {
|
||||
playSample(track.samplePath, track.id);
|
||||
}
|
||||
});
|
||||
@@ -84,9 +106,9 @@ function tick() {
|
||||
export function startPlayback() {
|
||||
if (appState.global.isPlaying || appState.pattern.tracks.length === 0) return;
|
||||
initializeAudioContext();
|
||||
|
||||
|
||||
if (appState.global.currentStep === 0) {
|
||||
rewindPlayback();
|
||||
rewindPlayback();
|
||||
}
|
||||
|
||||
const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120;
|
||||
@@ -96,15 +118,18 @@ export function startPlayback() {
|
||||
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");
|
||||
const playBtn = document.getElementById("play-btn");
|
||||
if (playBtn) {
|
||||
playBtn.classList.remove("fa-play");
|
||||
playBtn.classList.add("fa-pause");
|
||||
}
|
||||
|
||||
tick();
|
||||
appState.global.playbackIntervalId = setInterval(tick, stepInterval);
|
||||
}
|
||||
|
||||
export function stopPlayback() {
|
||||
if(appState.global.playbackIntervalId) {
|
||||
if (appState.global.playbackIntervalId) {
|
||||
clearInterval(appState.global.playbackIntervalId);
|
||||
}
|
||||
appState.global.playbackIntervalId = null;
|
||||
@@ -138,4 +163,4 @@ export function togglePlayback() {
|
||||
appState.global.currentStep = 0;
|
||||
startPlayback();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// js/pattern/pattern_bounce.js
|
||||
import { appState } from '../state.js';
|
||||
import { getSecondsPerStep } from '../utils.js';
|
||||
import { addAudioClipToTimeline, addAudioTrackLane } from '../audio/audio_state.js';
|
||||
import { getAudioContext } from '../audio.js';
|
||||
import { renderAudioEditor } from '../audio/audio_ui.js';
|
||||
|
||||
/**
|
||||
* Renderiza (bounce) o pattern de beat atualmente ativo para uma nova pista de áudio.
|
||||
*/
|
||||
export async function bounceActivePatternToAudio() {
|
||||
console.log("Iniciando 'bounce' do pattern...");
|
||||
|
||||
// 1. Encontrar o pattern ativo
|
||||
const activeTrackId = appState.pattern.activeTrackId;
|
||||
|
||||
// --- DEBUG ---
|
||||
console.log(`[DEBUG bounce] activeTrackId lido do estado: ${activeTrackId}`);
|
||||
|
||||
const activeTrack = appState.pattern.tracks.find(t => t.id === activeTrackId);
|
||||
|
||||
// --- DEBUG ---
|
||||
if (activeTrack) {
|
||||
console.log(`[DEBUG bounce] Pista ativa encontrada:`, activeTrack.name);
|
||||
console.log(`[DEBUG bounce] Verificando track.buffer:`, activeTrack.buffer);
|
||||
} else {
|
||||
console.error(`[DEBUG bounce] NENHUMA PISTA ATIVA ENCONTRADA. activeTrackId é nulo ou inválido.`);
|
||||
console.log(`[DEBUG bounce] Pistas disponíveis no estado:`, appState.pattern.tracks.map(t => ({id: t.id, name: t.name})));
|
||||
}
|
||||
// --- FIM DEBUG ---
|
||||
|
||||
if (!activeTrack) {
|
||||
alert('Nenhuma pista de pattern selecionada para renderizar.');
|
||||
return;
|
||||
}
|
||||
|
||||
const activePattern = activeTrack.patterns[activeTrack.activePatternIndex];
|
||||
if (!activePattern) {
|
||||
alert('Nenhum pattern ativo encontrado na pista.');
|
||||
return;
|
||||
}
|
||||
|
||||
const trackBuffer = activeTrack.buffer;
|
||||
if (!trackBuffer) {
|
||||
alert('O áudio (sample) desta pista ainda não foi carregado.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Calcular a duração do pattern
|
||||
const steps = activePattern.steps;
|
||||
const totalSteps = steps.length;
|
||||
const secondsPerStep = getSecondsPerStep(); //
|
||||
const duration = totalSteps * secondsPerStep;
|
||||
|
||||
if (duration <= 0) {
|
||||
alert("Pattern está vazio ou com duração zero.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 3. Usar Tone.Offline para renderizar o áudio
|
||||
const audioBuffer = await Tone.Offline(async (offlineCtx) => {
|
||||
|
||||
const gainNode = new Tone.Gain(Tone.gainToDb(activeTrack.volume)).connect(offlineCtx.destination);
|
||||
const pannerNode = new Tone.Panner(activeTrack.pan).connect(gainNode); //
|
||||
|
||||
const now = offlineCtx.currentTime;
|
||||
|
||||
// --- INÍCIO DA CORREÇÃO (que estava na versão anterior) ---
|
||||
const offlineBuffer = offlineCtx.createBuffer(
|
||||
trackBuffer.numberOfChannels,
|
||||
trackBuffer.length,
|
||||
trackBuffer.sampleRate
|
||||
);
|
||||
|
||||
for (let i = 0; i < trackBuffer.numberOfChannels; i++) {
|
||||
offlineBuffer.copyToChannel(trackBuffer.getChannelData(i), i);
|
||||
}
|
||||
// --- FIM DA CORREÇÃO ---
|
||||
|
||||
// Agendar todas as notas (steps)
|
||||
steps.forEach((isActive, index) => {
|
||||
if (isActive) {
|
||||
const time = now + (index * secondsPerStep);
|
||||
const source = new Tone.BufferSource(offlineBuffer).connect(pannerNode);
|
||||
source.start(time);
|
||||
}
|
||||
});
|
||||
|
||||
}, duration);
|
||||
|
||||
// 4. Áudio renderizado. Agora, adicione-o ao editor de áudio.
|
||||
|
||||
addAudioTrackLane(); //
|
||||
const newTrack = appState.audio.tracks[appState.audio.tracks.length - 1]; //
|
||||
const newTrackId = newTrack.id;
|
||||
|
||||
const clipName = `${activeTrack.name}_(${activePattern.name})`;
|
||||
|
||||
addAudioClipToTimeline(null, newTrackId, 0, clipName, audioBuffer); //
|
||||
|
||||
console.log("Pattern renderizado com sucesso!");
|
||||
|
||||
} catch (err) {
|
||||
console.error("Erro ao renderizar o pattern:", err);
|
||||
alert(`Erro ao renderizar pattern: ${err.message}`);
|
||||
}
|
||||
}
|
||||
@@ -1,97 +1,148 @@
|
||||
// js/pattern_state.js
|
||||
import * as Tone from "https://esm.sh/tone";
|
||||
|
||||
import { appState } from "../state.js";
|
||||
import { DEFAULT_VOLUME, DEFAULT_PAN } from "../config.js";
|
||||
import { getAudioContext, getMainGainNode } from "../audio.js";
|
||||
import { renderPatternEditor } from "./pattern_ui.js";
|
||||
import { getMainGainNode } from "../audio.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: [] });
|
||||
// Limpa players/buffers existentes
|
||||
appState.pattern.tracks.forEach(track => {
|
||||
try { track.player?.dispose(); } catch {}
|
||||
try { track.buffer?.dispose?.(); } catch {}
|
||||
});
|
||||
|
||||
// Reseta estado do editor de pattern
|
||||
appState.pattern.tracks = [];
|
||||
appState.pattern.activeTrackId = null;
|
||||
appState.pattern.activePatternIndex = 0;
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
// Descartar player/buffer anteriores com segurança
|
||||
try { track.player?.dispose(); } catch {}
|
||||
track.player = null;
|
||||
try { track.buffer?.dispose?.(); } catch {}
|
||||
track.buffer = null;
|
||||
|
||||
// Garante nós de volume/pan (Opção B: Volume em dB)
|
||||
if (!track.volumeNode) {
|
||||
track.volumeNode = new Tone.Volume(
|
||||
track.volume === 0 ? -Infinity : Tone.gainToDb(track.volume)
|
||||
);
|
||||
} else {
|
||||
track.volumeNode.volume.value =
|
||||
track.volume === 0 ? -Infinity : Tone.gainToDb(track.volume);
|
||||
}
|
||||
|
||||
if (!track.pannerNode) {
|
||||
track.pannerNode = new Tone.Panner(track.pan ?? 0);
|
||||
} else {
|
||||
track.pannerNode.pan.value = track.pan ?? 0;
|
||||
}
|
||||
|
||||
// Encadeia: Volume(dB) -> Panner -> Master
|
||||
try { track.volumeNode.disconnect(); } catch {}
|
||||
try { track.pannerNode.disconnect(); } catch {}
|
||||
track.volumeNode.connect(track.pannerNode);
|
||||
track.pannerNode.connect(getMainGainNode());
|
||||
|
||||
// Cria e carrega o Player
|
||||
const player = new Tone.Player({ url: track.samplePath, autostart: false });
|
||||
await player.load(track.samplePath); // garante buffer carregado
|
||||
|
||||
// Conecta o player ao volumeNode
|
||||
player.connect(track.volumeNode);
|
||||
|
||||
// Buffer separado (se você usar waveform em outro lugar)
|
||||
const buffer = new Tone.Buffer();
|
||||
await buffer.load(track.samplePath);
|
||||
|
||||
// Atribuições finais
|
||||
track.player = player;
|
||||
track.buffer = buffer;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar sample:', track.samplePath);
|
||||
console.error(`Falha ao carregar áudio para a trilha "${track.name}":`, error);
|
||||
try { track.player?.dispose(); } catch {}
|
||||
try { track.buffer?.dispose?.(); } catch {}
|
||||
track.player = null;
|
||||
track.buffer = null;
|
||||
}
|
||||
return track;
|
||||
}
|
||||
|
||||
export function addTrackToState() {
|
||||
const mainGainNode = getMainGainNode();
|
||||
const totalSteps = getTotalSteps();
|
||||
const referenceTrack = patternState.tracks[0];
|
||||
const referenceTrack = appState.pattern.tracks[0];
|
||||
|
||||
const newTrack = {
|
||||
id: Date.now(),
|
||||
id: Date.now() + Math.random(),
|
||||
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 }))
|
||||
player: null,
|
||||
buffer: 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: new Tone.Gain(Tone.gainToDb(DEFAULT_VOLUME)),
|
||||
// Opção B: controlar volume em dB
|
||||
volumeNode: new Tone.Volume(Tone.gainToDb(DEFAULT_VOLUME)),
|
||||
pannerNode: new Tone.Panner(DEFAULT_PAN),
|
||||
};
|
||||
|
||||
newTrack.gainNode.chain(newTrack.pannerNode, mainGainNode);
|
||||
// Cadeia de áudio nova
|
||||
newTrack.volumeNode.connect(newTrack.pannerNode);
|
||||
newTrack.pannerNode.connect(getMainGainNode());
|
||||
|
||||
patternState.tracks.push(newTrack);
|
||||
renderPatternEditor();
|
||||
appState.pattern.tracks.push(newTrack);
|
||||
appState.pattern.activeTrackId = newTrack.id;
|
||||
}
|
||||
|
||||
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();
|
||||
if (appState.pattern.tracks.length > 0) {
|
||||
const trackToRemove = appState.pattern.tracks[appState.pattern.tracks.length - 1];
|
||||
|
||||
try { trackToRemove.player?.dispose(); } catch {}
|
||||
try { trackToRemove.buffer?.dispose?.(); } catch {}
|
||||
try { trackToRemove.pannerNode?.disconnect(); } catch {}
|
||||
try { trackToRemove.volumeNode?.disconnect(); } catch {}
|
||||
|
||||
appState.pattern.tracks.pop();
|
||||
if (appState.pattern.activeTrackId === trackToRemove.id) {
|
||||
appState.pattern.activeTrackId = appState.pattern.tracks[0]?.id ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTrackSample(trackId, samplePath) {
|
||||
const track = patternState.tracks.find((t) => t.id == trackId);
|
||||
export async function updateTrackSample(trackIndex, samplePath) {
|
||||
const track = appState.pattern.tracks[trackIndex];
|
||||
if (track) {
|
||||
track.samplePath = samplePath;
|
||||
track.name = samplePath.split("/").pop();
|
||||
await loadAudioForTrack(track); // Carrega o novo player
|
||||
renderPatternEditor();
|
||||
|
||||
// (re)carrega e reconecta corretamente o player nesta trilha
|
||||
await loadAudioForTrack(track);
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleStepState(trackId, stepIndex) {
|
||||
const track = patternState.tracks.find((t) => t.id == trackId);
|
||||
export function toggleStepState(trackIndex, stepIndex) {
|
||||
const track = appState.pattern.tracks[trackIndex];
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,171 +1,218 @@
|
||||
// js/pattern_ui.js
|
||||
import { appState } from "../state.js";
|
||||
import {
|
||||
toggleStepState,
|
||||
updateTrackSample
|
||||
updateTrackSample
|
||||
} from "./pattern_state.js";
|
||||
import { playSample, stopPlayback } from "./pattern_audio.js"; // Será criado no próximo passo
|
||||
import { playSample, stopPlayback } from "./pattern_audio.js";
|
||||
import { getTotalSteps } from "../utils.js";
|
||||
import { sendAction } from '../socket.js';
|
||||
import { initializeAudioContext } from '../audio.js';
|
||||
|
||||
// Função principal de renderização para o editor de patterns
|
||||
export function renderPatternEditor() {
|
||||
const trackContainer = document.getElementById("track-container");
|
||||
trackContainer.innerHTML = "";
|
||||
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;
|
||||
// (V7) Adicionado 'trackIndex'
|
||||
appState.pattern.tracks.forEach((trackData, trackIndex) => {
|
||||
const trackLane = document.createElement("div");
|
||||
trackLane.className = "track-lane";
|
||||
trackLane.dataset.trackIndex = trackIndex; // (V7) Usando índice
|
||||
|
||||
if (trackData.id === appState.pattern.activeTrackId) {
|
||||
trackLane.classList.add('active-track');
|
||||
}
|
||||
if (trackData.id === appState.pattern.activeTrackId) {
|
||||
trackLane.classList.add('active-track');
|
||||
}
|
||||
|
||||
trackLane.innerHTML = `
|
||||
<div class="track-info">
|
||||
<i class="fa-solid fa-gear"></i>
|
||||
<div class="track-mute"></div>
|
||||
<span class="track-name">${trackData.name}</span>
|
||||
</div>
|
||||
<div class="track-controls">
|
||||
<div class="knob-container">
|
||||
<div class="knob" data-control="volume" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
|
||||
<span>VOL</span>
|
||||
</div>
|
||||
<div class="knob-container">
|
||||
<div class="knob" data-control="pan" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
|
||||
<span>PAN</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-sequencer-wrapper"></div>
|
||||
`;
|
||||
trackLane.innerHTML = `
|
||||
<div class="track-info">
|
||||
<i class="fa-solid fa-gear"></i>
|
||||
<div class="track-mute"></div>
|
||||
<span class="track-name">${trackData.name}</span>
|
||||
</div>
|
||||
<div class="track-controls">
|
||||
<div class="knob-container">
|
||||
<div class="knob" data-control="volume" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
|
||||
<span>VOL</span>
|
||||
</div>
|
||||
<div class="knob-container">
|
||||
<div class="knob" data-control="pan" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
|
||||
<span>PAN</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-sequencer-wrapper"></div>
|
||||
`;
|
||||
|
||||
trackLane.addEventListener('click', () => {
|
||||
if (appState.pattern.activeTrackId === trackData.id) return;
|
||||
stopPlayback();
|
||||
appState.pattern.activeTrackId = trackData.id;
|
||||
document.querySelectorAll('.track-lane').forEach(lane => lane.classList.remove('active-track'));
|
||||
trackLane.classList.add('active-track');
|
||||
updateGlobalPatternSelector();
|
||||
redrawSequencer();
|
||||
});
|
||||
// (Listener de clique da track é local, sem mudanças)
|
||||
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);
|
||||
}
|
||||
});
|
||||
trackLane.addEventListener("dragover", (e) => { e.preventDefault(); trackLane.classList.add("drag-over"); });
|
||||
trackLane.addEventListener("dragleave", () => trackLane.classList.remove("drag-over"));
|
||||
|
||||
trackContainer.appendChild(trackLane);
|
||||
// A lógica dos knobs precisará ser reimplementada ou movida para um arquivo de componentes
|
||||
});
|
||||
|
||||
updateGlobalPatternSelector();
|
||||
redrawSequencer();
|
||||
// (V9) Listener de "drop" (arrastar) agora usa 'sendAction'
|
||||
trackLane.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
trackLane.classList.remove("drag-over");
|
||||
const filePath = e.dataTransfer.getData("text/plain");
|
||||
|
||||
if (filePath) {
|
||||
sendAction({
|
||||
type: 'SET_TRACK_SAMPLE',
|
||||
trackIndex: trackIndex,
|
||||
filePath: filePath
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
trackContainer.appendChild(trackLane);
|
||||
});
|
||||
|
||||
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);
|
||||
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 trackIndex = parseInt(parentTrackElement.dataset.trackIndex, 10); // (V7)
|
||||
// ... dentro da função redrawSequencer() ...
|
||||
|
||||
if (!trackData || !trackData.patterns || trackData.patterns.length === 0) {
|
||||
sequencerContainer.innerHTML = ""; return;
|
||||
}
|
||||
const trackData = appState.pattern.tracks[trackIndex];
|
||||
|
||||
const activePattern = trackData.patterns[appState.pattern.activePatternIndex];
|
||||
if (!activePattern) {
|
||||
sequencerContainer.innerHTML = ""; return;
|
||||
}
|
||||
const patternSteps = activePattern.steps;
|
||||
if (!trackData || !trackData.patterns || trackData.patterns.length === 0) {
|
||||
sequencerContainer.innerHTML = ""; return;
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
// --- CORRIJA ESTAS DUAS LINHAS ---
|
||||
// ANTES:
|
||||
// const activePatternIndex = appState.pattern.activePatternIndex;
|
||||
// const activePattern = trackData.patterns[activePatternIndex];
|
||||
//
|
||||
// DEPOIS:
|
||||
const activePatternIndex = trackData.activePatternIndex;
|
||||
const activePattern = trackData.patterns[activePatternIndex];
|
||||
|
||||
stepElement.addEventListener("click", () => {
|
||||
toggleStepState(trackData.id, i);
|
||||
stepElement.classList.toggle("active");
|
||||
if (trackData && trackData.samplePath) {
|
||||
playSample(trackData.samplePath, trackData.id);
|
||||
}
|
||||
});
|
||||
if (!activePattern) {
|
||||
sequencerContainer.innerHTML = ""; return;
|
||||
}
|
||||
// ... resto da função ...
|
||||
const patternSteps = activePattern.steps;
|
||||
|
||||
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");
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
stepElement.addEventListener("click", () => {
|
||||
initializeAudioContext(); // (V8)
|
||||
|
||||
const currentState = activePattern.steps[i] || false;
|
||||
const isActive = !currentState;
|
||||
|
||||
sendAction({ // (V7)
|
||||
type: 'TOGGLE_NOTE',
|
||||
trackIndex: trackIndex,
|
||||
patternIndex: activePatternIndex,
|
||||
stepIndex: i,
|
||||
isActive: isActive
|
||||
});
|
||||
|
||||
if (isActive && 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 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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// (V7) Função de UI "cirúrgica"
|
||||
export function updateStepUI(trackIndex, patternIndex, stepIndex, isActive) {
|
||||
if (patternIndex !== appState.pattern.activePatternIndex) {
|
||||
return;
|
||||
}
|
||||
const trackElement = document.querySelector(`.track-lane[data-track-index="${trackIndex}"]`);
|
||||
if (!trackElement) return;
|
||||
const stepWrapper = trackElement.querySelector(
|
||||
`.step-sequencer .step-wrapper:nth-child(${stepIndex + 1})`
|
||||
);
|
||||
if (!stepWrapper) return;
|
||||
const stepElement = stepWrapper.querySelector(".step");
|
||||
if (!stepElement) return;
|
||||
stepElement.classList.toggle("active", isActive);
|
||||
}
|
||||
@@ -145,7 +145,7 @@ async function _startRecording() {
|
||||
} catch (err) {
|
||||
console.error("Erro ao iniciar a gravação:", err);
|
||||
appState.global.isRecording = false; //
|
||||
_updateRecordButtonUI(false); //
|
||||
_updateRecordButtonUI(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ async function _stopRecording() {
|
||||
} catch (err) {
|
||||
console.error("Erro ao parar a gravação:", err);
|
||||
appState.global.isRecording = false; //
|
||||
_updateRecordButtonUI(false); //
|
||||
_updateRecordButtonUI(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,12 +215,9 @@ function _processRecording(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(blobUrl, targetTrackId, 0, clipName, null); //
|
||||
|
||||
// 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(); //
|
||||
// addAudioClipToTimeline já chama o render
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1330
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"start": "node server.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"pino": "^8.21.0",
|
||||
"socket.io": "^4.8.1",
|
||||
"tone": "^15.1.22"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
// server.js - VERSÃO 6 (ACK + Clock Sync + Logs Dinâmicos + Persistência Autoritativa)
|
||||
|
||||
// =========================
|
||||
// IMPORTS PRINCIPAIS
|
||||
// =========================
|
||||
const express = require("express");
|
||||
const { createServer } = require("http");
|
||||
const { Server } = require("socket.io");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const pino = require("pino");
|
||||
|
||||
// -------------------------
|
||||
// LOGGER DINÂMICO (GERENCIADOR)
|
||||
// -------------------------
|
||||
|
||||
// Vamos manter um mapa de loggers ativos, um para cada sala
|
||||
const loggersByRoom = new Map();
|
||||
|
||||
// Função para gerar um timestamp formatado para nome de arquivo
|
||||
function getTimestampForFile() {
|
||||
const d = new Date();
|
||||
const date = d.toISOString().split("T")[0]; // YYYY-MM-DD
|
||||
// HH-MM-SS (formato 24h, fuso local)
|
||||
const time = d.toTimeString().split(" ")[0].replace(/:/g, "-");
|
||||
return `${date}_${time}`; // ex: 2025-10-26_12-30-05
|
||||
}
|
||||
|
||||
// Função principal: Pega o logger de uma sala ou cria um novo
|
||||
function getActionLoggerForRoom(roomName) {
|
||||
// 1. Se já criamos um logger para esta sala, reutilize-o
|
||||
if (loggersByRoom.has(roomName)) {
|
||||
return loggersByRoom.get(roomName);
|
||||
}
|
||||
|
||||
// 2. Se for a primeira vez, crie um novo logger
|
||||
const timestamp = getTimestampForFile();
|
||||
|
||||
// Limpa o nome da sala para evitar caracteres inválidos no nome do arquivo
|
||||
const safeRoomName = roomName.replace(/[^a-z0-9_-]/gi, "_");
|
||||
|
||||
const fileName = `${timestamp}_${safeRoomName}.log`;
|
||||
const filePath = path.join(process.cwd(), "data", fileName);
|
||||
|
||||
console.log(`[Logger] Novo log de sessão iniciado: ${filePath}`);
|
||||
|
||||
// Garante que a pasta 'data' exista
|
||||
fs.mkdirSync(path.join(process.cwd(), "data"), { recursive: true });
|
||||
|
||||
// Cria a instância do pino para este arquivo específico
|
||||
const newLogger = pino(pino.destination(filePath));
|
||||
|
||||
// Armazena no mapa para reutilização
|
||||
loggersByRoom.set(roomName, newLogger);
|
||||
|
||||
return newLogger;
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// Configuração do Servidor
|
||||
// -------------------------
|
||||
const app = express();
|
||||
const httpServer = createServer(app);
|
||||
const PORT = process.env.PORT || 33007;
|
||||
|
||||
// -------------------------
|
||||
// Persistência por sala
|
||||
// -------------------------
|
||||
/*
|
||||
Estrutura na memória:
|
||||
roomStates = {
|
||||
[roomName]: {
|
||||
projectXml: '<xml...>' | undefined,
|
||||
audio: { tracks: [], clips: [] },
|
||||
seq: number,
|
||||
tokensSeen: Set<string> // idempotência
|
||||
}
|
||||
}
|
||||
*/
|
||||
const roomStates = {}; // compat com V4 (já existia), ampliado para incluir audio/seq
|
||||
|
||||
function dataFile(roomName) {
|
||||
return path.join(process.cwd(), "data", `${roomName}.json`);
|
||||
}
|
||||
|
||||
function ensureRoom(roomName) {
|
||||
if (roomStates[roomName]) return roomStates[roomName];
|
||||
|
||||
// tenta carregar do disco
|
||||
try {
|
||||
const p = dataFile(roomName);
|
||||
if (fs.existsSync(p)) {
|
||||
const j = JSON.parse(fs.readFileSync(p, "utf8"));
|
||||
roomStates[roomName] = {
|
||||
projectXml: j.projectXml || null,
|
||||
audio: j.audio || { tracks: [], clips: [] },
|
||||
seq: j.seq || 0,
|
||||
tokensSeen: new Set(),
|
||||
};
|
||||
return roomStates[roomName];
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[persist] falha ao carregar estado da sala ${roomName}:`, e);
|
||||
}
|
||||
|
||||
roomStates[roomName] = {
|
||||
projectXml: null,
|
||||
audio: { tracks: [], clips: [] },
|
||||
seq: 0,
|
||||
tokensSeen: new Set(),
|
||||
};
|
||||
return roomStates[roomName];
|
||||
}
|
||||
|
||||
function saveRoom(roomName) {
|
||||
const r = ensureRoom(roomName);
|
||||
try {
|
||||
fs.mkdirSync(path.join(process.cwd(), "data"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
dataFile(roomName),
|
||||
JSON.stringify({ projectXml: r.projectXml, audio: r.audio, seq: r.seq })
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(`[persist] falha ao salvar estado da sala ${roomName}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// Server / IO
|
||||
// -------------------------
|
||||
const io = new Server(httpServer, {
|
||||
cors: {
|
||||
origin: [
|
||||
"https://alice.ufsj.edu.br",
|
||||
"http://127.0.0.1:33007",
|
||||
"http://localhost:33007",
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
],
|
||||
methods: ["GET", "POST"],
|
||||
credentials: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log("Backend V6 (Logs Dinâmicos) iniciado. Aguardando conexões...");
|
||||
|
||||
// util de log das salas deste socket
|
||||
function logMyRooms(socket, prefix = "SALAS") {
|
||||
const myRooms = Array.from(socket.rooms || []);
|
||||
console.log(`[${prefix}] socket ${socket.id} está em:`, myRooms);
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
// REDUCER autoritativo do editor de áudio
|
||||
// -------------------------
|
||||
function basenameNoExt(p) {
|
||||
if (!p) return "";
|
||||
const base = String(p).split(/[\\/]/).pop();
|
||||
return base.replace(/\.[^/.]+$/, "");
|
||||
}
|
||||
|
||||
function applyAuthoritativeAction(roomName, action) {
|
||||
const room = ensureRoom(roomName);
|
||||
const state = room.audio;
|
||||
|
||||
// idempotência por token (se fornecido)
|
||||
if (action.__token && room.tokensSeen.has(action.__token)) {
|
||||
return null;
|
||||
}
|
||||
if (action.__token) room.tokensSeen.add(action.__token);
|
||||
|
||||
let mutated = false;
|
||||
|
||||
switch (action.type) {
|
||||
case "ADD_AUDIO_LANE": {
|
||||
// Usa o trackId enviado pelo cliente, se existir
|
||||
const id = action.trackId || generateUniqueId("track_server");
|
||||
if (!state.tracks.find((t) => t.id === id)) {
|
||||
state.tracks.push({
|
||||
id,
|
||||
name: `Pista de Áudio ${state.tracks.length + 1}`,
|
||||
});
|
||||
mutated = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "ADD_AUDIO_CLIP": {
|
||||
const { clipId, filePath, trackId, startTimeInSeconds, name } = action;
|
||||
|
||||
// Blindagem do Servidor (Evitar faixas fantasma)
|
||||
if (
|
||||
!trackId ||
|
||||
clipId == null ||
|
||||
startTimeInSeconds == null ||
|
||||
isNaN(startTimeInSeconds)
|
||||
) {
|
||||
console.warn(
|
||||
`[Server] Ação ADD_AUDIO_CLIP rejeitada (dados inválidos):`,
|
||||
action
|
||||
);
|
||||
return null; // Rejeita a ação, não faz broadcast
|
||||
}
|
||||
|
||||
if (!state.tracks.find((t) => t.id === trackId)) {
|
||||
state.tracks.push({
|
||||
id: trackId,
|
||||
name: `Pista de Áudio ${state.tracks.length + 1}`,
|
||||
});
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
if (!state.clips.find((c) => String(c.id) === String(clipId))) {
|
||||
state.clips.push({
|
||||
id: clipId,
|
||||
trackId,
|
||||
name: name || basenameNoExt(filePath) || "sample",
|
||||
sourcePath: filePath || null, // ideal: URL pública
|
||||
startTimeInSeconds,
|
||||
durationInSeconds: action.durationInSeconds || 0,
|
||||
offset: action.offset || 0,
|
||||
pitch: action.pitch || 0,
|
||||
volume: action.volume ?? 1,
|
||||
pan: action.pan ?? 0,
|
||||
originalDuration: action.originalDuration || 0,
|
||||
});
|
||||
mutated = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "UPDATE_AUDIO_CLIP": {
|
||||
// =================================================================
|
||||
// 👇 INÍCIO DA CORREÇÃO (Blindagem do Servidor para Bug 4)
|
||||
// =================================================================
|
||||
if (!action.clipId || !action.props) {
|
||||
console.warn(
|
||||
`[Server] Ação UPDATE_AUDIO_CLIP rejeitada (dados base inválidos):`,
|
||||
action
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const { trackId, startTimeInSeconds } = action.props;
|
||||
if (
|
||||
trackId !== undefined &&
|
||||
(trackId == null || (typeof trackId === "number" && isNaN(trackId)))
|
||||
) {
|
||||
console.warn(
|
||||
`[Server] Ação UPDATE_AUDIO_CLIP rejeitada (trackId inválido):`,
|
||||
action
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
startTimeInSeconds !== undefined &&
|
||||
(startTimeInSeconds == null || isNaN(startTimeInSeconds))
|
||||
) {
|
||||
console.warn(
|
||||
`[Server] Ação UPDATE_AUDIO_CLIP rejeitada (startTimeInSeconds inválido):`,
|
||||
action
|
||||
);
|
||||
return null;
|
||||
}
|
||||
// =================================================================
|
||||
// 👆 FIM DA CORREÇÃO
|
||||
// =================================================================
|
||||
|
||||
const c = state.clips.find((x) => String(x.id) === String(action.clipId));
|
||||
if (c && action.props && typeof action.props === "object") {
|
||||
Object.assign(c, action.props);
|
||||
mutated = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "REMOVE_AUDIO_CLIP": {
|
||||
const i = state.clips.findIndex(
|
||||
(x) => String(x.id) === String(action.clipId)
|
||||
);
|
||||
if (i >= 0) {
|
||||
state.clips.splice(i, 1);
|
||||
mutated = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
// outras ações não são da persistência do editor de áudio
|
||||
mutated = false;
|
||||
}
|
||||
|
||||
if (!mutated) return null;
|
||||
|
||||
room.seq += 1; // numeração autoritativa
|
||||
saveRoom(roomName); // persistência em disco
|
||||
return { ...action, __seq: room.seq }; // devolve ação com seq para broadcast
|
||||
}
|
||||
|
||||
io.on("connection", (socket) => {
|
||||
console.log(`Novo usuário conectado: ${socket.id}`);
|
||||
|
||||
// =======================================
|
||||
// JOIN ROOM
|
||||
// =======================================
|
||||
socket.on("join_room", async (data) => {
|
||||
const { roomName, userName } = data || {};
|
||||
if (!roomName) {
|
||||
console.warn(`join_room inválido de ${socket.id} (sem roomName)`);
|
||||
return;
|
||||
}
|
||||
|
||||
await socket.join(roomName);
|
||||
console.log(
|
||||
`Usuário ${userName || "(sem nome)"} (${
|
||||
socket.id
|
||||
}) entrou na sala: ${roomName}`
|
||||
);
|
||||
logMyRooms(socket, "JOIN");
|
||||
|
||||
// envia estado salvo do projeto (XML) — compat V4
|
||||
const room = ensureRoom(roomName);
|
||||
if (room.projectXml) {
|
||||
socket.emit("load_project_state", room.projectXml);
|
||||
console.log(
|
||||
`Estado XML enviado para ${userName || socket.id} na sala ${roomName}`
|
||||
);
|
||||
} else {
|
||||
console.log(`Sala ${roomName} sem XML salvo ainda.`);
|
||||
}
|
||||
|
||||
// envia snapshot autoritativo do editor de áudio
|
||||
socket.emit("action_broadcast", {
|
||||
action: {
|
||||
type: "AUDIO_SNAPSHOT",
|
||||
snapshot: room.audio,
|
||||
__seq: room.seq,
|
||||
__target: socket.id,
|
||||
},
|
||||
});
|
||||
|
||||
socket
|
||||
.to(roomName)
|
||||
.emit("feedback", `Usuário ${userName || socket.id} entrou na sala.`);
|
||||
});
|
||||
|
||||
// =======================================
|
||||
// CLOCK SYNC
|
||||
// =======================================
|
||||
socket.on("what_time_is_it", (_data, cb) => {
|
||||
cb && cb({ serverNowMs: Date.now() });
|
||||
});
|
||||
|
||||
// =======================================
|
||||
// BROADCAST COM ACK + PERSISTÊNCIA
|
||||
// =======================================
|
||||
socket.on("broadcast_action", async (payload, cb) => {
|
||||
const { roomName, action } = payload || {};
|
||||
if (!roomName || !action) {
|
||||
cb && cb({ ok: false, error: "invalid_payload" });
|
||||
console.warn(
|
||||
`[broadcast_action] payload inválido de ${socket.id}:`,
|
||||
payload
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 👇 *** NOSSO NOVO LOG DINÂMICO *** 👇
|
||||
try {
|
||||
// 1. Pega o logger específico para ESTA sala
|
||||
const roomLogger = getActionLoggerForRoom(roomName);
|
||||
|
||||
// 2. Loga a ação (não precisamos mais salvar roomName, já está no nome do arq.)
|
||||
roomLogger.info(
|
||||
{
|
||||
timestamp: Date.now(),
|
||||
socketId: socket.id,
|
||||
action: action,
|
||||
},
|
||||
"action_received"
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(`[Logger] Falha ao logar ação para sala ${roomName}:`, e);
|
||||
}
|
||||
// *** FIM DO LOG ***
|
||||
|
||||
// Confirma recebimento imediatamente
|
||||
cb && cb({ ok: true, token: action.__token });
|
||||
|
||||
// Garante que o emissor está na sala (caso o join tenha falhado/atrasado)
|
||||
if (!socket.rooms.has(roomName)) {
|
||||
console.warn(
|
||||
`[broadcast_action] ${socket.id} NÃO estava na sala ${roomName}. Forçando join...`
|
||||
);
|
||||
await socket.join(roomName);
|
||||
logMyRooms(socket, "FORCED_JOIN");
|
||||
}
|
||||
|
||||
// Carrega estado da sala
|
||||
const room = ensureRoom(roomName);
|
||||
|
||||
// Persiste estado se for LOAD_PROJECT (compat)
|
||||
if (action.type === "LOAD_PROJECT" && action.xml) {
|
||||
room.projectXml = action.xml;
|
||||
saveRoom(roomName);
|
||||
console.log(
|
||||
`[broadcast_action] Estado da sala ${roomName} atualizado (LOAD_PROJECT).`
|
||||
);
|
||||
}
|
||||
|
||||
// Aplica ações autoritativas do editor de áudio
|
||||
const maybeApplied = applyAuthoritativeAction(roomName, action);
|
||||
|
||||
// Debug: quem está na sala neste momento?
|
||||
const socketsInRoom = await io.in(roomName).fetchSockets();
|
||||
const idsInRoom = socketsInRoom.map((s) => s.id);
|
||||
console.log(
|
||||
`[broadcast_action] ${action.type} (token=${action.__token}) para sala "${roomName}" com ${idsInRoom.length} cliente(s):`,
|
||||
idsInRoom
|
||||
);
|
||||
|
||||
// Se foi uma ação do editor de áudio e foi aplicada, rebroadcast com __seq
|
||||
if (maybeApplied) {
|
||||
io.to(roomName).emit("action_broadcast", { action: maybeApplied });
|
||||
return;
|
||||
}
|
||||
|
||||
// Caso contrário, apenas repercute a ação como antes (sem __seq)
|
||||
io.to(roomName).emit("action_broadcast", { action });
|
||||
});
|
||||
|
||||
// =======================================
|
||||
// Retrocompat (daw_action)
|
||||
// =======================================
|
||||
socket.on("daw_action", async (data) => {
|
||||
const { room, action } = data || {};
|
||||
if (!room || !action) return;
|
||||
|
||||
console.warn(
|
||||
"[DEPRECATION] Recebido daw_action. Migre para broadcast_action com ACK."
|
||||
);
|
||||
|
||||
// (Opcional, mas recomendado) Adicionando log dinâmico aqui também
|
||||
try {
|
||||
const roomLogger = getActionLoggerForRoom(room);
|
||||
roomLogger.info(
|
||||
{
|
||||
timestamp: Date.now(),
|
||||
socketId: socket.id,
|
||||
action: action,
|
||||
},
|
||||
"action_received (deprecated daw_action)"
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(`[Logger] Falha ao logar daw_action para sala ${room}:`, e);
|
||||
}
|
||||
|
||||
if (!socket.rooms.has(room)) {
|
||||
console.warn(
|
||||
`[daw_action] ${socket.id} NÃO estava na sala ${room}. Forçando join...`
|
||||
);
|
||||
await socket.join(room);
|
||||
logMyRooms(socket, "FORCED_JOIN(daw)");
|
||||
}
|
||||
|
||||
const r = ensureRoom(room);
|
||||
if (action.type === "LOAD_PROJECT" && action.xml) {
|
||||
r.projectXml = action.xml;
|
||||
saveRoom(room);
|
||||
console.log(
|
||||
`[daw_action] Estado da sala ${room} atualizado (LOAD_PROJECT).`
|
||||
);
|
||||
}
|
||||
|
||||
const socketsInRoom = await io.in(room).fetchSockets();
|
||||
const idsInRoom = socketsInRoom.map((s) => s.id);
|
||||
console.log(
|
||||
`[daw_action] ${action.type} para sala "${room}" (${idsInRoom.length} cliente(s)):`,
|
||||
idsInRoom
|
||||
);
|
||||
|
||||
io.in(room).emit("action_broadcast", { action });
|
||||
});
|
||||
|
||||
// =======================================
|
||||
// Re-sync explícito (quando cliente detectar buraco de seq)
|
||||
// =======================================
|
||||
socket.on("audio_resync", ({ roomName, lastSeq }) => {
|
||||
const room = ensureRoom(roomName);
|
||||
// caminho simples: envia snapshot atual (estado autoritativo)
|
||||
socket.emit("action_broadcast", {
|
||||
action: {
|
||||
type: "AUDIO_SNAPSHOT",
|
||||
snapshot: room.audio,
|
||||
__seq: room.seq,
|
||||
__target: socket.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
console.log(`Usuário desconectado: ${socket.id}`);
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/", (req, res) => {
|
||||
res.send(
|
||||
"Servidor Backend V6 (Logs Dinâmicos) da DAW colaborativa está no ar!"
|
||||
);
|
||||
});
|
||||
|
||||
httpServer.listen(PORT, () => {
|
||||
console.log(`Servidor escutando na porta http://localhost:${PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,805 @@
|
||||
// js/socket.js — V5.5 (Toast para Sync Mode)
|
||||
|
||||
// -------------------relómm------------------------------------------------------
|
||||
// IMPORTS & STATE
|
||||
// -----------------------------------------------------------------------------
|
||||
import { appState, resetProjectState } from "./state.js";
|
||||
import {
|
||||
addTrackToState,
|
||||
removeLastTrackFromState,
|
||||
updateTrackSample,
|
||||
} from "./pattern/pattern_state.js";
|
||||
import {
|
||||
addAudioTrackLane,
|
||||
removeAudioClip,
|
||||
addAudioClipToTimeline,
|
||||
updateAudioClipProperties,
|
||||
sliceAudioClip,
|
||||
getAudioSnapshot, // 👈 novo
|
||||
applyAudioSnapshot, // 👈 novo
|
||||
} from "./audio/audio_state.js";
|
||||
|
||||
import {
|
||||
togglePlayback,
|
||||
stopPlayback,
|
||||
rewindPlayback,
|
||||
} from "./pattern/pattern_audio.js";
|
||||
|
||||
import {
|
||||
startAudioEditorPlayback,
|
||||
stopAudioEditorPlayback,
|
||||
updateTransportLoop,
|
||||
seekAudioEditor, // 👈 Adicionado
|
||||
restartAudioEditorIfPlaying, // 👈 Adicionado
|
||||
} from "./audio/audio_audio.js";
|
||||
|
||||
import { parseMmpContent } from "./file.js";
|
||||
import { renderAll, showToast } from "./ui.js"; // showToast()
|
||||
import { updateStepUI, renderPatternEditor } from "./pattern/pattern_ui.js";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Gera um ID único otimista (ex: "track_1678886401000_abc123")
|
||||
// -----------------------------------------------------------------------------
|
||||
function generateUniqueId(prefix = "item") {
|
||||
return `${prefix}_${Date.now()}_${Math.random()
|
||||
.toString(36)
|
||||
.substring(2, 9)}`;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// CONFIGURAÇÃO DO SOCKET.IO
|
||||
// -----------------------------------------------------------------------------
|
||||
const socket = io("http://localhost:33007", {
|
||||
transports: ["websocket", "polling"],
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
let USER_NAME = `Alicer-${Math.floor(Math.random() * 9999)}`;
|
||||
let currentRoom = null;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// CLOCK SYNC — Sincroniza relógio cliente-servidor
|
||||
// -----------------------------------------------------------------------------
|
||||
let serverOffsetMs = 0;
|
||||
let rttMs = 0;
|
||||
|
||||
function sampleServerTime() {
|
||||
return new Promise((resolve) => {
|
||||
const t0 = Date.now();
|
||||
socket.emit("what_time_is_it", null, (reply) => {
|
||||
const t1 = Date.now();
|
||||
const rtt = t1 - t0;
|
||||
const oneWay = rtt / 2;
|
||||
const serverNow =
|
||||
reply && typeof reply === "object" ? reply.serverNowMs : reply;
|
||||
const offset = serverNow - (t0 + oneWay);
|
||||
resolve({ offset, rtt });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ewma(prev, next, alpha = 0.3) {
|
||||
if (prev === null || prev === undefined) return next;
|
||||
return prev * (1 - alpha) + next * alpha;
|
||||
}
|
||||
|
||||
async function syncServerTime(iterations = 5) {
|
||||
let off = null;
|
||||
let rtt = null;
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
try {
|
||||
const s = await sampleServerTime();
|
||||
off = ewma(off, s.offset);
|
||||
rtt = ewma(rtt, s.rtt);
|
||||
} catch {}
|
||||
}
|
||||
if (off != null) serverOffsetMs = off;
|
||||
if (rtt != null) rttMs = rtt;
|
||||
}
|
||||
|
||||
function delayFromServerTimeMs(scheduleAtServerMs) {
|
||||
const localNow = Date.now();
|
||||
const serverNowEst = localNow + serverOffsetMs;
|
||||
return Math.max(0, scheduleAtServerMs - serverNowEst);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// ESTADO DE TOKENS / ACK / FALLBACK
|
||||
// -----------------------------------------------------------------------------
|
||||
let lastActionTimeout = null;
|
||||
let lastBroadcastTimeout = null;
|
||||
let pendingToken = null;
|
||||
let lastActionToken = 0;
|
||||
const processedTokens = new Set();
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// FUNÇÕES AUXILIARES
|
||||
// -----------------------------------------------------------------------------
|
||||
export function setUserName(name) {
|
||||
USER_NAME = name;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// CONEXÃO / JOIN / LOGS
|
||||
// -----------------------------------------------------------------------------
|
||||
socket.on("connect", () => {
|
||||
console.log(`Conectado ao servidor com ID: ${socket.id}`);
|
||||
showToast("✅ Conectado ao servidor", "success");
|
||||
if (USER_NAME.startsWith("Alicer-")) {
|
||||
USER_NAME = `Alicer-${socket.id.substring(0, 4)}`;
|
||||
}
|
||||
const urlRoom = new URLSearchParams(window.location.search).get("room");
|
||||
currentRoom = urlRoom || null;
|
||||
|
||||
if (currentRoom) {
|
||||
console.log(`Modo Online. Sala detectada: ${currentRoom}`);
|
||||
showToast(`🎧 Conectado à sala ${currentRoom}`, "info");
|
||||
// Mostra o botão se estiver online
|
||||
const syncModeBtn = document.getElementById("sync-mode-btn");
|
||||
//if (syncModeBtn) syncModeBtn.style.display = ""; // Garante visibilidade
|
||||
} else {
|
||||
console.log("Modo Local. Conectado, mas não em uma sala.");
|
||||
showToast("🔌 Modo local (fora de sala)", "warning");
|
||||
// Esconde se offline
|
||||
const syncModeBtn = document.getElementById("sync-mode-btn");
|
||||
//if (syncModeBtn) syncModeBtn.style.display = "none";
|
||||
}
|
||||
|
||||
syncServerTime();
|
||||
setInterval(syncServerTime, 10000);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// DETECÇÃO DE AD BLOCK
|
||||
// -----------------------------------------------------------------------------
|
||||
socket.on("connect_error", (err) => {
|
||||
console.error("Falha crítica na conexão do Socket:", err.message);
|
||||
alert(
|
||||
"🚧 FALHA NA CONEXÃO 🚧\n\n❌Não foi possível conectar ao servidor em tempo real. ❌\n\n😅 Causa provável: Um Ad Blocker ou Firewall está bloqueando a conexão.\n\n😎 Por favor, desative seu Ad Blocker para este site e recarregue a página."
|
||||
);
|
||||
showToast(
|
||||
"❌ Falha grave de conexão. Desativa o Ad Blocker? 😅",
|
||||
"error",
|
||||
10000
|
||||
);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// RECEBER ESTADO SALVO DA SALA
|
||||
// -----------------------------------------------------------------------------
|
||||
socket.on("load_project_state", async (projectXml) => {
|
||||
console.log("Recebendo estado salvo da sala...");
|
||||
showToast("🔄 Recebendo estado atual da sala...", "info", 4000);
|
||||
if (isLoadingProject) return;
|
||||
isLoadingProject = true;
|
||||
try {
|
||||
await parseMmpContent(projectXml);
|
||||
renderAll();
|
||||
showToast("🎵 Projeto carregado com sucesso", "success");
|
||||
} catch (e) {
|
||||
console.error("Erro ao carregar projeto:", e);
|
||||
showToast("❌ Erro ao carregar projeto", "error");
|
||||
}
|
||||
isLoadingProject = false;
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// ENTRAR NA SALA (Join)
|
||||
// -----------------------------------------------------------------------------
|
||||
export function joinRoom() {
|
||||
const urlRoom = new URLSearchParams(window.location.search).get("room");
|
||||
currentRoom = urlRoom || currentRoom;
|
||||
if (currentRoom) {
|
||||
console.log(`Entrando na sala: ${currentRoom} como ${USER_NAME}`);
|
||||
showToast(`🚪 Entrando na sala ${currentRoom}`, "info");
|
||||
socket.emit("join_room", { roomName: currentRoom, userName: USER_NAME });
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const hasAudio =
|
||||
(appState.audio?.clips?.length || 0) > 0 ||
|
||||
(appState.audio?.tracks?.length || 0) > 0;
|
||||
if (!hasAudio && currentRoom) {
|
||||
sendAction({ type: "AUDIO_SNAPSHOT_REQUEST" });
|
||||
}
|
||||
} catch {}
|
||||
}, 800);
|
||||
} else {
|
||||
console.warn("joinRoom() chamado, mas nenhuma sala encontrada na URL.");
|
||||
showToast("⚠️ Nenhuma sala encontrada na URL", "warning");
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// ENVIAR AÇÃO COM ACK + AGENDAMENTO DE TRANSPORTE
|
||||
// -----------------------------------------------------------------------------
|
||||
export function sendAction(action) {
|
||||
const inRoom = Boolean(currentRoom);
|
||||
|
||||
// (Blindagem de ID/Validação)
|
||||
if (action.type === "ADD_AUDIO_LANE" && !action.trackId) {
|
||||
action.trackId = generateUniqueId("track");
|
||||
console.log(`[SOCKET] ADD_AUDIO_LANE ID gerado: ${action.trackId}`);
|
||||
}
|
||||
if (action.type === "ADD_AUDIO_CLIP") {
|
||||
if (!action.clipId) {
|
||||
action.clipId = generateUniqueId("clip");
|
||||
}
|
||||
if (
|
||||
!action.trackId ||
|
||||
action.startTimeInSeconds == null ||
|
||||
isNaN(action.startTimeInSeconds)
|
||||
) {
|
||||
console.error("[SOCKET] ADD_AUDIO_CLIP bloqueada:", action);
|
||||
showToast("❌ Erro clip (inválido)", "error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (action.type === "UPDATE_AUDIO_CLIP") {
|
||||
if (!action.clipId || !action.props) {
|
||||
console.error("[SOCKET] UPDATE_AUDIO_CLIP bloqueada (base):", action);
|
||||
return;
|
||||
}
|
||||
const { trackId, startTimeInSeconds } = action.props;
|
||||
if (
|
||||
trackId !== undefined &&
|
||||
(trackId == null || (typeof trackId === "number" && isNaN(trackId)))
|
||||
) {
|
||||
console.error("[SOCKET] UPDATE_AUDIO_CLIP bloqueada (trackId):", action);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
startTimeInSeconds !== undefined &&
|
||||
(startTimeInSeconds == null || isNaN(startTimeInSeconds))
|
||||
) {
|
||||
console.error(
|
||||
"[SOCKET] UPDATE_AUDIO_CLIP bloqueada (startTime):",
|
||||
action
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const token = (++lastActionToken).toString();
|
||||
action.__token = token;
|
||||
action.__senderId = socket.id;
|
||||
action.__senderName = USER_NAME;
|
||||
|
||||
// =================================================================
|
||||
// 👇 INÍCIO DA CORREÇÃO (Expandir `isTransport` para SET_SYNC_MODE)
|
||||
// =================================================================
|
||||
const isTransport =
|
||||
action.type === "TOGGLE_PLAYBACK" ||
|
||||
action.type === "STOP_PLAYBACK" ||
|
||||
action.type === "REWIND_PLAYBACK" ||
|
||||
action.type === "START_AUDIO_PLAYBACK" ||
|
||||
action.type === "STOP_AUDIO_PLAYBACK" ||
|
||||
action.type === "SET_LOOP_STATE" ||
|
||||
action.type === "SET_SEEK_TIME" ||
|
||||
action.type === "SET_SYNC_MODE"; // 👈 Adicionado
|
||||
// =================================================================
|
||||
// 👆 FIM DA CORREÇÃO
|
||||
// =================================================================
|
||||
|
||||
if (inRoom && isTransport) {
|
||||
const leadTimeMs = 200;
|
||||
const serverNowEst = Date.now() + serverOffsetMs;
|
||||
if (
|
||||
action.type === "TOGGLE_PLAYBACK" ||
|
||||
action.type === "START_AUDIO_PLAYBACK"
|
||||
) {
|
||||
action.scheduleAtServerMs = Math.round(serverNowEst + leadTimeMs);
|
||||
}
|
||||
}
|
||||
|
||||
// (Lógica Global/Local)
|
||||
if (!inRoom || (isTransport && appState.global.syncMode === "local")) {
|
||||
console.log("[SOCKET] (local) executando ação:", action.type);
|
||||
handleActionBroadcast(action);
|
||||
return;
|
||||
}
|
||||
if (isTransport && appState.global.syncMode === "global") {
|
||||
action.__syncMode = "global";
|
||||
}
|
||||
|
||||
console.log(
|
||||
"[SOCKET] Enviando broadcast_action:",
|
||||
action.type,
|
||||
"para",
|
||||
currentRoom,
|
||||
"token=",
|
||||
token
|
||||
);
|
||||
|
||||
socket
|
||||
.compress(false)
|
||||
.emit("broadcast_action", { roomName: currentRoom, action }, (ack) => {
|
||||
if (ack && ack.ok && ack.token === token) {
|
||||
if (lastActionTimeout) {
|
||||
clearTimeout(lastActionTimeout);
|
||||
lastActionTimeout = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (lastActionTimeout) clearTimeout(lastActionTimeout);
|
||||
lastActionTimeout = setTimeout(() => {
|
||||
console.warn("[SOCKET] ACK não recebido:", action.type, token);
|
||||
showToast(`⚠️ ACK (${action.type})`, "warning");
|
||||
}, 500);
|
||||
|
||||
if (lastBroadcastTimeout) clearTimeout(lastBroadcastTimeout);
|
||||
pendingToken = token;
|
||||
lastBroadcastTimeout = setTimeout(() => {
|
||||
if (processedTokens.has(token)) return;
|
||||
console.warn("[SOCKET] Eco não recebido, fallback:", action.type, token);
|
||||
showToast(`⚠️ Eco (${action.type}), fallback`, "warning");
|
||||
processedTokens.add(token);
|
||||
handleActionBroadcast(action);
|
||||
}, 900);
|
||||
}
|
||||
|
||||
// HELPERS POP-UPS
|
||||
function basenameNoExt(path) {
|
||||
if (!path) return "";
|
||||
const base = String(path).split(/[\\/]/).pop();
|
||||
return base.replace(/\.[^/.]+$/, "");
|
||||
}
|
||||
function trackLabel(track, idx) {
|
||||
const name =
|
||||
track?.name ||
|
||||
track?.label ||
|
||||
track?.sampleName ||
|
||||
track?.sample?.displayName;
|
||||
return name ? `(Faixa ${idx + 1})` : `Faixa ${idx + 1}`;
|
||||
}
|
||||
function actorOf(action) {
|
||||
const n = action.__senderName || action.userName;
|
||||
if (n && typeof n === "string") return n;
|
||||
const sid = action.__senderId ? String(action.__senderId) : "";
|
||||
return `Alicer-${sid.slice(0, 4) || "????"}`;
|
||||
}
|
||||
function instrumentLabel(track, tIdx) {
|
||||
const name =
|
||||
track?.name ||
|
||||
track?.label ||
|
||||
track?.sampleName ||
|
||||
track?.sample?.displayName ||
|
||||
track?.filePath ||
|
||||
track?.sampleFile ||
|
||||
track?.samplePath;
|
||||
if (name && typeof name === "string") {
|
||||
const base = name.split(/[\\/]/).pop();
|
||||
return base.replace(/\.[^/.]+$/, "");
|
||||
}
|
||||
return `Faixa ${tIdx + 1}`;
|
||||
}
|
||||
|
||||
let rerenderScheduled = false;
|
||||
function schedulePatternRerender() {
|
||||
if (rerenderScheduled) return;
|
||||
rerenderScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
renderPatternEditor();
|
||||
rerenderScheduled = false;
|
||||
});
|
||||
}
|
||||
|
||||
// RECEBER BROADCAST
|
||||
socket.on("feedback", (msg) => {
|
||||
console.log("[Servidor]", msg);
|
||||
showToast(msg, "info");
|
||||
});
|
||||
|
||||
socket.on("action_broadcast", (payload) => {
|
||||
const action = payload && payload.type ? payload : payload?.action || payload;
|
||||
if (action && action.__token) {
|
||||
processedTokens.add(action.__token);
|
||||
if (lastBroadcastTimeout && pendingToken === action.__token) {
|
||||
clearTimeout(lastBroadcastTimeout);
|
||||
lastBroadcastTimeout = null;
|
||||
pendingToken = null;
|
||||
}
|
||||
if (lastActionTimeout) {
|
||||
clearTimeout(lastActionTimeout);
|
||||
lastActionTimeout = null;
|
||||
}
|
||||
}
|
||||
handleActionBroadcast(action);
|
||||
});
|
||||
|
||||
// PROCESSAR AÇÕES RECEBIDAS
|
||||
let isLoadingProject = false;
|
||||
async function handleActionBroadcast(action) {
|
||||
if (!action || !action.type) return;
|
||||
if (
|
||||
action.type !== "LOAD_PROJECT" &&
|
||||
action.type !== "RESET_PROJECT" &&
|
||||
isLoadingProject
|
||||
) {
|
||||
console.warn(`[AÇÃO IGNORADA] ${action.type} (carregando).`);
|
||||
return;
|
||||
}
|
||||
|
||||
// (Filtro Global/Local)
|
||||
const isTransport =
|
||||
action.type === "TOGGLE_PLAYBACK" ||
|
||||
action.type === "STOP_PLAYBACK" ||
|
||||
action.type === "REWIND_PLAYBACK" ||
|
||||
action.type === "START_AUDIO_PLAYBACK" ||
|
||||
action.type === "STOP_AUDIO_PLAYBACK" ||
|
||||
action.type === "SET_LOOP_STATE" ||
|
||||
action.type === "SET_SEEK_TIME" ||
|
||||
action.type === "SET_SYNC_MODE"; // 👈 Adicionado
|
||||
const isFromSelf = action.__senderId === socket.id;
|
||||
if (isTransport && !isFromSelf) {
|
||||
if (appState.global.syncMode === "local") {
|
||||
console.log(`[SOCKET] (ignorado) ${action.type}, modo local.`);
|
||||
return;
|
||||
}
|
||||
if (action.__syncMode !== "global") {
|
||||
console.log(`[SOCKET] (ignorado) ${action.type}, remetente não global.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleAtServerMs = action.scheduleAtServerMs ?? null;
|
||||
const delayMs = scheduleAtServerMs
|
||||
? delayFromServerTimeMs(scheduleAtServerMs)
|
||||
: 0;
|
||||
|
||||
switch (action.type) {
|
||||
// Transporte Principal
|
||||
case "TOGGLE_PLAYBACK": {
|
||||
setTimeout(togglePlayback, delayMs);
|
||||
const who = actorOf(action);
|
||||
showToast(`▶ ${who} Play bases`, "info");
|
||||
break;
|
||||
}
|
||||
case "STOP_PLAYBACK": {
|
||||
setTimeout(stopPlayback, delayMs);
|
||||
const who = actorOf(action);
|
||||
showToast(`⏹ ${who} Stop bases`, "info");
|
||||
break;
|
||||
}
|
||||
case "REWIND_PLAYBACK":
|
||||
setTimeout(rewindPlayback, delayMs);
|
||||
break;
|
||||
|
||||
// Transporte Áudio Editor
|
||||
case "START_AUDIO_PLAYBACK":
|
||||
if (action.loopState) {
|
||||
appState.global.isLoopActive = action.loopState.isLoopActive;
|
||||
appState.global.loopStartTime = action.loopState.loopStartTime;
|
||||
appState.global.loopEndTime = action.loopState.loopEndTime;
|
||||
const btn = document.getElementById("audio-editor-loop-btn");
|
||||
if (btn) {
|
||||
btn.classList.toggle("active", appState.global.isLoopActive);
|
||||
}
|
||||
updateTransportLoop();
|
||||
try {
|
||||
const area = document.getElementById("loop-region");
|
||||
if (area)
|
||||
area.classList.toggle("visible", appState.global.isLoopActive);
|
||||
} catch (e) {}
|
||||
}
|
||||
const seekTime = action.seekTime ?? appState.audio.audioEditorSeekTime;
|
||||
setTimeout(() => startAudioEditorPlayback(seekTime), delayMs);
|
||||
break;
|
||||
case "STOP_AUDIO_PLAYBACK":
|
||||
setTimeout(
|
||||
() => stopAudioEditorPlayback(action.rewind || false),
|
||||
delayMs
|
||||
);
|
||||
break;
|
||||
|
||||
// =================================================================
|
||||
// 👇 INÍCIO DA CORREÇÃO (Handlers Sincronia de Loop/Seek/SyncMode)
|
||||
// =================================================================
|
||||
case "SET_LOOP_STATE": {
|
||||
const changed =
|
||||
appState.global.isLoopActive !== !!action.isLoopActive ||
|
||||
appState.global.loopStartTime !== action.loopStartTime ||
|
||||
appState.global.loopEndTime !== action.loopEndTime;
|
||||
|
||||
if (changed) {
|
||||
appState.global.isLoopActive = !!action.isLoopActive;
|
||||
appState.global.loopStartTime = action.loopStartTime;
|
||||
appState.global.loopEndTime = action.loopEndTime;
|
||||
|
||||
const btn = document.getElementById("audio-editor-loop-btn");
|
||||
if (btn) {
|
||||
btn.classList.toggle("active", appState.global.isLoopActive);
|
||||
}
|
||||
|
||||
updateTransportLoop();
|
||||
restartAudioEditorIfPlaying();
|
||||
renderAll();
|
||||
|
||||
if (!isFromSelf) {
|
||||
const who = actorOf(action);
|
||||
showToast(`🔁 ${who} alterou o loop.`, "info");
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "SET_SEEK_TIME": {
|
||||
// Evita aplicar seek se o tempo for muito próximo para não causar "tremidas"
|
||||
if (
|
||||
Math.abs((appState.audio.audioEditorSeekTime || 0) - action.seekTime) >
|
||||
0.05
|
||||
) {
|
||||
seekAudioEditor(action.seekTime);
|
||||
if (!isFromSelf) {
|
||||
const who = actorOf(action);
|
||||
showToast(`⏯️ ${who} moveu a agulha.`, "info");
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "SET_SYNC_MODE": {
|
||||
const newMode = action.mode === "local" ? "local" : "global";
|
||||
const changed = appState.global.syncMode !== newMode;
|
||||
if (changed) {
|
||||
appState.global.syncMode = newMode;
|
||||
const btn = document.getElementById("sync-mode-btn");
|
||||
if (btn) {
|
||||
btn.classList.toggle("active", newMode === "global");
|
||||
btn.textContent = newMode === "global" ? "Global" : "Local";
|
||||
}
|
||||
// Mostra o Toast AQUI, após a mudança ser aplicada
|
||||
const who = actorOf(action);
|
||||
const modeText = newMode === "global" ? "Global 🌐" : "Local 🏠";
|
||||
showToast(`${who} mudou modo para ${modeText}`, "info");
|
||||
}
|
||||
break;
|
||||
}
|
||||
// =================================================================
|
||||
// 👆 FIM DA CORREÇÃO
|
||||
// =================================================================
|
||||
|
||||
// Estado Global
|
||||
case "LOAD_PROJECT":
|
||||
isLoadingProject = true;
|
||||
showToast("📂 Carregando...", "info");
|
||||
try {
|
||||
await parseMmpContent(action.xml);
|
||||
renderAll();
|
||||
showToast("🎶 Projeto sync", "success");
|
||||
} catch (e) {
|
||||
console.error("Erro LOAD_PROJECT:", e);
|
||||
showToast("❌ Erro projeto", "error");
|
||||
}
|
||||
isLoadingProject = false;
|
||||
break;
|
||||
case "RESET_PROJECT":
|
||||
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;
|
||||
renderAll();
|
||||
const who = actorOf(action);
|
||||
showToast(`🧹 Reset por ${who}`, "warning");
|
||||
break;
|
||||
|
||||
// Configs
|
||||
case "SET_BPM": {
|
||||
document.getElementById("bpm-input").value = action.value;
|
||||
renderAll();
|
||||
const who = actorOf(action);
|
||||
showToast(`🕰 ${who} BPM ${action.value}`, "info");
|
||||
break;
|
||||
}
|
||||
case "SET_BARS": {
|
||||
document.getElementById("bars-input").value = action.value;
|
||||
renderAll();
|
||||
const who = actorOf(action);
|
||||
showToast(`🕰 ${who} Compasso add`, "info");
|
||||
break;
|
||||
}
|
||||
case "SET_TIMESIG_A":
|
||||
document.getElementById("compasso-a-input").value = action.value;
|
||||
renderAll();
|
||||
showToast("Compasso alt", "info");
|
||||
break;
|
||||
case "SET_TIMESIG_B":
|
||||
document.getElementById("compasso-b-input").value = action.value;
|
||||
renderAll();
|
||||
showToast("Compasso alt", "info");
|
||||
break;
|
||||
|
||||
// Tracks
|
||||
case "ADD_TRACK": {
|
||||
addTrackToState();
|
||||
renderPatternEditor();
|
||||
const who = actorOf(action);
|
||||
showToast(`🥁 Faixa add por ${who}`, "info");
|
||||
break;
|
||||
}
|
||||
case "REMOVE_LAST_TRACK": {
|
||||
removeLastTrackFromState();
|
||||
renderPatternEditor();
|
||||
const who = actorOf(action);
|
||||
showToast(`❌ Faixa remov. por ${who}`, "warning");
|
||||
break;
|
||||
}
|
||||
case "ADD_AUDIO_LANE": {
|
||||
const id = action.trackId;
|
||||
if (!id) {
|
||||
console.warn("ADD_AUDIO_LANE sem ID.");
|
||||
break;
|
||||
}
|
||||
const exists = appState.audio.tracks.some((t) => t.id === id);
|
||||
if (exists) {
|
||||
console.log(`ADD_AUDIO_LANE ${id} já existe.`);
|
||||
break;
|
||||
}
|
||||
appState.audio.tracks.push({
|
||||
id: id,
|
||||
name: `Pista ${appState.audio.tracks.length + 1}`,
|
||||
});
|
||||
renderAll();
|
||||
const who = actorOf(action);
|
||||
showToast(`🎧 Pista add por ${who}`, "info");
|
||||
break;
|
||||
}
|
||||
case "REMOVE_AUDIO_CLIP":
|
||||
if (removeAudioClip(action.clipId)) {
|
||||
appState.global.selectedClipId = null;
|
||||
renderAll();
|
||||
const who = actorOf(action);
|
||||
showToast(`🎚️ Clip remov. por ${who}`, "info");
|
||||
}
|
||||
break;
|
||||
|
||||
// Notes
|
||||
case "TOGGLE_NOTE": {
|
||||
const {
|
||||
trackIndex: ti,
|
||||
patternIndex: pi,
|
||||
stepIndex: si,
|
||||
isActive,
|
||||
} = action;
|
||||
const t = appState.pattern.tracks[ti];
|
||||
if (t) {
|
||||
t.patterns[pi] = t.patterns[pi] || { steps: [] };
|
||||
t.patterns[pi].steps[si] = isActive;
|
||||
try {
|
||||
updateStepUI(ti, pi, si, isActive);
|
||||
} catch {}
|
||||
if (!isFromSelf) {
|
||||
schedulePatternRerender();
|
||||
}
|
||||
}
|
||||
const who = actorOf(action);
|
||||
const v = isActive ? "+" : "-";
|
||||
showToast(`🎯 ${who} ${v} nota ${ti + 1}.${pi + 1}.${si + 1}`, "info");
|
||||
break;
|
||||
}
|
||||
|
||||
// Samples
|
||||
case "SET_TRACK_SAMPLE": {
|
||||
const ti = action.trackIndex;
|
||||
const t = appState.pattern.tracks[ti];
|
||||
if (t) {
|
||||
try {
|
||||
await updateTrackSample(ti, action.filePath);
|
||||
renderPatternEditor();
|
||||
const who = actorOf(action);
|
||||
const sn = basenameNoExt(action.filePath) || "?";
|
||||
const tl = trackLabel(t, ti);
|
||||
showToast(`🔊 ${who} trocou ${sn} em ${tl}`, "success");
|
||||
} catch (err) {
|
||||
console.error("Erro SET_TRACK_SAMPLE:", err);
|
||||
showToast("❌ Erro sample", "error");
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Snapshots
|
||||
case "AUDIO_SNAPSHOT_REQUEST": {
|
||||
const clips = appState.audio?.clips?.length || 0,
|
||||
tracks = appState.audio?.tracks?.length || 0;
|
||||
const iHave = clips > 0 || tracks > 0;
|
||||
if (!iHave || isFromSelf) break;
|
||||
if (!window.__lastSnapshotSentAt) window.__lastSnapshotSentAt = 0;
|
||||
const now = Date.now();
|
||||
if (now - window.__lastSnapshotSentAt < 1500) break;
|
||||
window.__lastSnapshotSentAt = now;
|
||||
try {
|
||||
const snap = getAudioSnapshot();
|
||||
sendAction({
|
||||
type: "AUDIO_SNAPSHOT",
|
||||
snapshot: snap,
|
||||
__target: action.__senderId,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("Erro AUDIO_SNAPSHOT_REQUEST:", e);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "AUDIO_SNAPSHOT": {
|
||||
if (action.__target && action.__target !== socket.id) break;
|
||||
const hasClips = (appState.audio?.clips?.length || 0) > 0;
|
||||
if (hasClips) break;
|
||||
try {
|
||||
await applyAudioSnapshot(action.snapshot);
|
||||
renderAll();
|
||||
const who = actorOf(action);
|
||||
showToast(`🔁 Sync áudio por ${who}`, "success");
|
||||
} catch (e) {
|
||||
console.error("Erro AUDIO_SNAPSHOT:", e);
|
||||
showToast("❌ Erro sync áudio", "error");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Clip Sync
|
||||
case "ADD_AUDIO_CLIP": {
|
||||
try {
|
||||
const { filePath, trackId, startTimeInSeconds, clipId, name } = action;
|
||||
if (
|
||||
appState.audio?.clips?.some((c) => String(c.id) === String(clipId))
|
||||
) {
|
||||
break;
|
||||
}
|
||||
const trackExists = appState.audio?.tracks?.some(
|
||||
(t) => t.id === trackId
|
||||
);
|
||||
if (!trackExists) {
|
||||
console.warn(
|
||||
`ADD_AUDIO_CLIP Pista ${trackId} não existe, criando...`
|
||||
);
|
||||
appState.audio.tracks.push({
|
||||
id: trackId,
|
||||
name: `Pista ${appState.audio.tracks.length + 1}`,
|
||||
});
|
||||
}
|
||||
addAudioClipToTimeline(
|
||||
filePath,
|
||||
trackId,
|
||||
startTimeInSeconds,
|
||||
clipId,
|
||||
name
|
||||
);
|
||||
renderAll();
|
||||
const who = actorOf(action);
|
||||
const track = appState.audio?.tracks?.find((t) => t.id === trackId);
|
||||
const pista = track?.name || `Pista ${trackId}`;
|
||||
showToast(
|
||||
`🎧 ${who} add “${(name || filePath || "")
|
||||
.split(/[\\/]/)
|
||||
.pop()}” em ${pista}`,
|
||||
"success"
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Erro ADD_AUDIO_CLIP:", e);
|
||||
showToast("❌ Erro add clip", "error");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "UPDATE_AUDIO_CLIP": {
|
||||
try {
|
||||
if (action.props?.__operation === "slice") {
|
||||
sliceAudioClip(action.clipId, action.props.sliceTimeInTimeline);
|
||||
} else {
|
||||
updateAudioClipProperties(action.clipId, action.props || {});
|
||||
}
|
||||
renderAll();
|
||||
const who = actorOf(action);
|
||||
showToast(`✂️ Clip ${action.clipId} att por ${who}`, "info");
|
||||
} catch (e) {
|
||||
console.error("Erro UPDATE_AUDIO_CLIP:", e);
|
||||
showToast("❌ Erro att clip", "error");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.warn("Ação desconhecida:", action.type);
|
||||
}
|
||||
}
|
||||
|
||||
// EXPORTS
|
||||
export { socket };
|
||||
@@ -1,66 +1,71 @@
|
||||
// js/state.js
|
||||
import { patternState, initializePatternState } from './pattern/pattern_state.js';
|
||||
import { initializePatternState } from './pattern/pattern_state.js';
|
||||
import { audioState, initializeAudioState } from './audio/audio_state.js';
|
||||
import { DEFAULT_VOLUME, DEFAULT_PAN } from "./config.js";
|
||||
|
||||
// Estado global da aplicação
|
||||
const 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,
|
||||
|
||||
// --- ADICIONADO PARA A ÁREA DE LOOP ---
|
||||
isLoopActive: false,
|
||||
loopStartTime: 0,
|
||||
loopEndTime: 8,
|
||||
|
||||
// --- ADICIONADO PARA O MODO DE REDIMENSIONAMENTO ---
|
||||
resizeMode: 'trim', // Pode ser 'trim' (Modo 2) ou 'stretch' (Modo 1)
|
||||
selectedClipId: null,
|
||||
|
||||
// --- RECORDING ---
|
||||
isRecording: false,
|
||||
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,
|
||||
resizeMode: 'trim',
|
||||
selectedClipId: null,
|
||||
isRecording: false,
|
||||
clipboard: null,
|
||||
lastRulerClickTime: 0,
|
||||
};
|
||||
|
||||
// --- ADICIONE ESTE BLOCO ---
|
||||
// Define o ESTADO INICIAL para o pattern module
|
||||
const patternState = {
|
||||
tracks: [],
|
||||
activeTrackId: null,
|
||||
activePatternIndex: 0,
|
||||
};
|
||||
// --- FIM DA ADIÇÃO ---
|
||||
|
||||
// Combina todos os estados em um único objeto namespaced
|
||||
export let appState = {
|
||||
global: globalState,
|
||||
pattern: patternState,
|
||||
audio: audioState,
|
||||
global: globalState,
|
||||
pattern: patternState, // <-- AGORA 'patternState' está definido
|
||||
audio: audioState,
|
||||
};
|
||||
|
||||
// Função para resetar o projeto para o estado inicial
|
||||
export function resetProjectState() {
|
||||
initializePatternState();
|
||||
initializeAudioState();
|
||||
initializePatternState(); // Esta função vai MODIFICAR appState.pattern
|
||||
initializeAudioState();
|
||||
|
||||
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,
|
||||
resizeMode: 'trim', // Reseta para o modo 'trim'
|
||||
selectedClipId: null,
|
||||
|
||||
// --- RECORDING ---
|
||||
isRecording: false,
|
||||
});
|
||||
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,
|
||||
resizeMode: 'trim',
|
||||
selectedClipId: null,
|
||||
isRecording: false,
|
||||
clipboard: null,
|
||||
lastRulerClickTime: 0,
|
||||
});
|
||||
}
|
||||
@@ -153,4 +153,68 @@ export async function showOpenProjectModal() {
|
||||
export function closeOpenProjectModal() {
|
||||
const openProjectModal = document.getElementById("open-project-modal");
|
||||
openProjectModal.classList.remove("visible");
|
||||
}
|
||||
}
|
||||
|
||||
// ui.js — sistema global de avisos / toasts
|
||||
export function showToast(message, type = "info", duration = 3000) {
|
||||
// cria o contêiner se não existir
|
||||
let container = document.getElementById("toast-container");
|
||||
if (!container) {
|
||||
container = document.createElement("div");
|
||||
container.id = "toast-container";
|
||||
Object.assign(container.style, {
|
||||
position: "fixed",
|
||||
bottom: "20px",
|
||||
right: "20px",
|
||||
zIndex: 9999,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-end",
|
||||
gap: "8px",
|
||||
});
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
// cria o toast
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
Object.assign(toast.style, {
|
||||
padding: "10px 16px",
|
||||
borderRadius: "6px",
|
||||
color: "#fff",
|
||||
fontFamily: "sans-serif",
|
||||
fontSize: "14px",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.25)",
|
||||
opacity: "0",
|
||||
transform: "translateY(10px)",
|
||||
transition: "all 0.3s ease",
|
||||
maxWidth: "260px",
|
||||
textAlign: "left",
|
||||
wordWrap: "break-word",
|
||||
});
|
||||
|
||||
// cores por tipo
|
||||
const colors = {
|
||||
info: "#3498db",
|
||||
success: "#2ecc71",
|
||||
warning: "#f1c40f",
|
||||
error: "#e74c3c",
|
||||
};
|
||||
toast.style.backgroundColor = colors[type] || colors.info;
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
// animação de entrada
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = "1";
|
||||
toast.style.transform = "translateY(0)";
|
||||
}, 50);
|
||||
|
||||
// remover após tempo
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = "0";
|
||||
toast.style.transform = "translateY(10px)";
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, duration);
|
||||
}
|
||||
Reference in new issue
Block a user