sync mode on

This commit is contained in:
JotaChina committed 2025-10-26 16:43:01 -03:00
1 parent 1818932244
commit 1174b328e5
415 files changed
+44294 -1182

No files matched your search

+46
View File
@@ -186,6 +186,52 @@ body.sidebar-hidden .sample-browser {
border: 1px solid var(--border-color-light);
}
/* Estilo para o novo menu de contexto da régua */
#ruler-context-menu {
display: none;
position: fixed;
z-index: 10000;
background-color: var(--background-dark);
border: 1px solid var(--border-color-dark);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
font-size: 0.8rem;
padding: 5px 0;
min-width: 150px;
}
#ruler-context-menu > div {
padding: 6px 15px;
cursor: pointer;
color: var(--text-light);
}
#ruler-context-menu > div:hover {
background-color: var(--accent-blue);
color: #fff;
}
/* Estilo para clipes "recortados" (cut) */
.timeline-clip.cut {
opacity: 0.5;
border-style: dashed;
}
/* =============================================== */
/* SALA COMPARTILHADA
/* =============================================== */
#create-room-btn {
width: auto; /* Permite que o botão se ajuste ao texto */
padding-left: 12px;
padding-right: 16px;
color: var(--text-light); /* Ou a cor que preferir */
}
#create-room-btn:hover {
background-color: var(--accent-color); /* Destaque ao passar o mouse */
color: var(--text-dark);
}
/* =============================================== */
/* EDITOR DE BASES (BEAT EDITOR / STEP SEQUENCER)
/* =============================================== */
+7 -2
View File
@@ -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;
}
+147 -83
View File
@@ -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();
});
}
+144 -29
View File
@@ -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
-1
View File
@@ -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)
+35 -8
View File
@@ -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
View File
@@ -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();
});
+46 -21
View File
@@ -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}`);
}
}
+101 -50
View File
@@ -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];
}
}
}
}
+187 -140
View File
@@ -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);
}
+4 -7
View File
@@ -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
}
/**
File diff suppressed because it is too large. Load diff
+19
View File
@@ -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"
}
}
+514
View File
@@ -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}`);
});
+805
View File
@@ -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 };
+55 -50
View File
@@ -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,
    });
}
+65 -1
View File
@@ -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);
}