diff --git a/assets/css/style.css b/assets/css/style.css
index c136f74..be2cbea 100644
--- a/assets/css/style.css
+++ b/assets/css/style.css
@@ -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)
/* =============================================== */
diff --git a/assets/js/creations/audio.js b/assets/js/creations/audio.js
index 4dfed83..2cdee54 100644
--- a/assets/js/creations/audio.js
+++ b/assets/js/creations/audio.js
@@ -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;
}
diff --git a/assets/js/creations/audio/audio_audio.js b/assets/js/creations/audio/audio_audio.js
index 0c8e8e9..a92d980 100644
--- a/assets/js/creations/audio/audio_audio.js
+++ b/assets/js/creations/audio/audio_audio.js
@@ -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;
- }
-}
\ No newline at end of file
+ if (newCallbacks.onClipScheduled) {
+ callbacks.onClipScheduled = newCallbacks.onClipScheduled;
+ }
+ if (newCallbacks.onClipPlayed) {
+ callbacks.onClipPlayed = newCallbacks.onClipPlayed;
+ }
+}
diff --git a/assets/js/creations/audio/audio_clipboard.js b/assets/js/creations/audio/audio_clipboard.js
new file mode 100644
index 0000000..20d0b1b
--- /dev/null
+++ b/assets/js/creations/audio/audio_clipboard.js
@@ -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();
+ });
+}
\ No newline at end of file
diff --git a/assets/js/creations/audio/audio_state.js b/assets/js/creations/audio/audio_state.js
index 5cb416d..4fe2301 100644
--- a/assets/js/creations/audio/audio_state.js
+++ b/assets/js/creations/audio/audio_state.js
@@ -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;
-}
\ No newline at end of file
+}
diff --git a/assets/js/creations/audio/audio_ui.js b/assets/js/creations/audio/audio_ui.js
index 3ae0072..71b50b7 100644
--- a/assets/js/creations/audio/audio_ui.js
+++ b/assets/js/creations/audio/audio_ui.js
@@ -1,615 +1,819 @@
// js/audio/audio_ui.js
import { appState } from "../state.js";
-import {
- addAudioClipToTimeline,
- updateAudioClipProperties,
- sliceAudioClip,
+import {
+ addAudioClipToTimeline,
+ updateAudioClipProperties,
+ sliceAudioClip,
+ removeAudioClip,
} from "./audio_state.js";
-import { seekAudioEditor, restartAudioEditorIfPlaying, updateTransportLoop } from "./audio_audio.js";
+import {
+ seekAudioEditor,
+ restartAudioEditorIfPlaying,
+ updateTransportLoop,
+} from "./audio_audio.js";
import { drawWaveform } from "../waveform.js";
import { PIXELS_PER_BAR, PIXELS_PER_STEP, ZOOM_LEVELS } from "../config.js";
-import { getPixelsPerSecond, quantizeTime, getBeatsPerBar, getSecondsPerStep } from "../utils.js";
+import {
+ getPixelsPerSecond,
+ quantizeTime,
+ getBeatsPerBar,
+ getSecondsPerStep,
+} from "../utils.js";
+import { sendAction } from "../socket.js";
export function renderAudioEditor() {
- const audioEditor = document.querySelector('.audio-editor');
- const existingTrackContainer = document.getElementById('audio-track-container');
- if (!audioEditor || !existingTrackContainer) return;
+ const audioEditor = document.querySelector(".audio-editor");
+ const existingTrackContainer = document.getElementById(
+ "audio-track-container"
+ );
+ if (!audioEditor || !existingTrackContainer) return;
- // --- CRIAÇÃO E RENDERIZAÇÃO DA RÉGUA ---
- let rulerWrapper = audioEditor.querySelector('.ruler-wrapper');
- if (!rulerWrapper) {
- rulerWrapper = document.createElement('div');
- rulerWrapper.className = 'ruler-wrapper';
- rulerWrapper.innerHTML = `
+ // --- CRIAÇÃO E RENDERIZAÇÃO DA RÉGUA ---
+ let rulerWrapper = audioEditor.querySelector(".ruler-wrapper");
+ if (!rulerWrapper) {
+ rulerWrapper = document.createElement("div");
+ rulerWrapper.className = "ruler-wrapper";
+ rulerWrapper.innerHTML = `
`;
- audioEditor.insertBefore(rulerWrapper, existingTrackContainer);
+ audioEditor.insertBefore(rulerWrapper, existingTrackContainer);
+ }
+
+ const ruler = rulerWrapper.querySelector(".timeline-ruler");
+ ruler.innerHTML = "";
+
+ const pixelsPerSecond = getPixelsPerSecond();
+
+ let maxTime = appState.global.loopEndTime;
+ appState.audio.clips.forEach((clip) => {
+ const endTime =
+ (clip.startTimeInSeconds || 0) + (clip.durationInSeconds || 0);
+ if (endTime > maxTime) maxTime = endTime;
+ });
+
+ const containerWidth = existingTrackContainer.offsetWidth;
+ const contentWidth = maxTime * pixelsPerSecond;
+ const totalWidth = Math.max(contentWidth, containerWidth, 2000);
+
+ ruler.style.width = `${totalWidth}px`;
+
+ const zoomFactor = ZOOM_LEVELS[appState.global.zoomLevelIndex];
+ const beatsPerBar = getBeatsPerBar();
+ const stepWidthPx = PIXELS_PER_STEP * zoomFactor;
+ const beatWidthPx = stepWidthPx * 4;
+ const barWidthPx = beatWidthPx * beatsPerBar;
+
+ if (barWidthPx > 0) {
+ const numberOfBars = Math.ceil(totalWidth / barWidthPx);
+ for (let i = 1; i <= numberOfBars; i++) {
+ const marker = document.createElement("div");
+ marker.className = "ruler-marker";
+ marker.textContent = i;
+ marker.style.left = `${(i - 1) * barWidthPx}px`;
+ ruler.appendChild(marker);
+ }
+ }
+
+ const loopRegion = document.createElement("div");
+ loopRegion.id = "loop-region";
+ loopRegion.style.left = `${
+ appState.global.loopStartTime * pixelsPerSecond
+ }px`;
+ loopRegion.style.width = `${
+ (appState.global.loopEndTime - appState.global.loopStartTime) *
+ pixelsPerSecond
+ }px`;
+ loopRegion.innerHTML = ``;
+ loopRegion.classList.toggle("visible", appState.global.isLoopActive);
+ ruler.appendChild(loopRegion);
+
+ // --- LISTENER DA RÉGUA (MODIFICADO para enviar Ações de Loop/Seek) ---
+ const newRuler = ruler.cloneNode(true);
+ ruler.parentNode.replaceChild(newRuler, ruler);
+
+ newRuler.addEventListener("mousedown", (e) => {
+ // Esconde menus
+ document.getElementById("timeline-context-menu").style.display = "none";
+ document.getElementById("ruler-context-menu").style.display = "none";
+
+ const currentPixelsPerSecond = getPixelsPerSecond();
+ const loopHandle = e.target.closest(".loop-handle");
+ const loopRegionBody = e.target.closest("#loop-region:not(.loop-handle)");
+
+ // Drag Handle Loop
+ if (loopHandle) {
+ e.preventDefault();
+ e.stopPropagation();
+ const handleType = loopHandle.classList.contains("left")
+ ? "left"
+ : "right";
+ const initialMouseX = e.clientX;
+ const initialStart = appState.global.loopStartTime;
+ const initialEnd = appState.global.loopEndTime;
+ const onMouseMove = (moveEvent) => {
+ const deltaX = moveEvent.clientX - initialMouseX;
+ const deltaTime = deltaX / currentPixelsPerSecond;
+ let newStart = appState.global.loopStartTime;
+ let newEnd = appState.global.loopEndTime;
+ if (handleType === "left") {
+ newStart = Math.max(0, initialStart + deltaTime);
+ newStart = Math.min(newStart, appState.global.loopEndTime - 0.1);
+ appState.global.loopStartTime = newStart;
+ } else {
+ newEnd = Math.max(
+ appState.global.loopStartTime + 0.1,
+ initialEnd + deltaTime
+ );
+ appState.global.loopEndTime = newEnd;
+ }
+ updateTransportLoop();
+ const loopRegionEl = newRuler.querySelector("#loop-region");
+ if (loopRegionEl) {
+ loopRegionEl.style.left = `${newStart * currentPixelsPerSecond}px`;
+ loopRegionEl.style.width = `${
+ (newEnd - newStart) * currentPixelsPerSecond
+ }px`;
+ }
+ };
+ const onMouseUp = () => {
+ document.removeEventListener("mousemove", onMouseMove);
+ document.removeEventListener("mouseup", onMouseUp);
+ // =================================================================
+ // 👇 INÍCIO DA CORREÇÃO (Sincronia de Loop Drag Handle)
+ // =================================================================
+ sendAction({
+ type: "SET_LOOP_STATE",
+ isLoopActive: appState.global.isLoopActive,
+ loopStartTime: appState.global.loopStartTime,
+ loopEndTime: appState.global.loopEndTime,
+ });
+ // renderAudioEditor(); // Removido
+ // =================================================================
+ // 👆 FIM DA CORREÇÃO
+ };
+ document.addEventListener("mousemove", onMouseMove);
+ document.addEventListener("mouseup", onMouseUp);
+ return;
}
- const ruler = rulerWrapper.querySelector('.timeline-ruler');
- ruler.innerHTML = '';
-
- const pixelsPerSecond = getPixelsPerSecond();
-
- let maxTime = appState.global.loopEndTime;
- appState.audio.clips.forEach(clip => {
- const endTime = (clip.startTimeInSeconds || 0) + (clip.durationInSeconds || 0);
- if (endTime > maxTime) maxTime = endTime;
- });
-
- const containerWidth = existingTrackContainer.offsetWidth;
- const contentWidth = maxTime * pixelsPerSecond;
- const totalWidth = Math.max(contentWidth, containerWidth, 2000);
-
- ruler.style.width = `${totalWidth}px`;
-
- const zoomFactor = ZOOM_LEVELS[appState.global.zoomLevelIndex];
- const beatsPerBar = getBeatsPerBar();
- const stepWidthPx = PIXELS_PER_STEP * zoomFactor;
- const beatWidthPx = stepWidthPx * 4;
- const barWidthPx = beatWidthPx * beatsPerBar;
-
- if (barWidthPx > 0) {
- const numberOfBars = Math.ceil(totalWidth / barWidthPx);
- for (let i = 1; i <= numberOfBars; i++) {
- const marker = document.createElement('div');
- marker.className = 'ruler-marker';
- marker.textContent = i;
- marker.style.left = `${(i - 1) * barWidthPx}px`;
- ruler.appendChild(marker);
- }
+ // Drag Body Loop
+ if (loopRegionBody) {
+ e.preventDefault();
+ e.stopPropagation();
+ const initialMouseX = e.clientX;
+ const initialStart = appState.global.loopStartTime;
+ const initialEnd = appState.global.loopEndTime;
+ const initialDuration = initialEnd - initialStart;
+ const onMouseMove = (moveEvent) => {
+ const deltaX = moveEvent.clientX - initialMouseX;
+ const deltaTime = deltaX / currentPixelsPerSecond;
+ let newStart = Math.max(0, initialStart + deltaTime);
+ let newEnd = newStart + initialDuration;
+ appState.global.loopStartTime = newStart;
+ appState.global.loopEndTime = newEnd;
+ updateTransportLoop();
+ const loopRegionEl = newRuler.querySelector("#loop-region");
+ if (loopRegionEl)
+ loopRegionEl.style.left = `${newStart * currentPixelsPerSecond}px`;
+ };
+ const onMouseUp = () => {
+ document.removeEventListener("mousemove", onMouseMove);
+ document.removeEventListener("mouseup", onMouseUp);
+ // =================================================================
+ // 👇 INÍCIO DA CORREÇÃO (Sincronia de Loop Drag Body)
+ // =================================================================
+ sendAction({
+ type: "SET_LOOP_STATE",
+ isLoopActive: appState.global.isLoopActive,
+ loopStartTime: appState.global.loopStartTime,
+ loopEndTime: appState.global.loopEndTime,
+ });
+ // renderAudioEditor(); // Removido
+ // =================================================================
+ // 👆 FIM DA CORREÇÃO
+ };
+ document.addEventListener("mousemove", onMouseMove);
+ document.addEventListener("mouseup", onMouseUp);
+ return;
}
- const loopRegion = document.createElement('div');
- loopRegion.id = 'loop-region';
- loopRegion.style.left = `${appState.global.loopStartTime * pixelsPerSecond}px`;
- loopRegion.style.width = `${(appState.global.loopEndTime - appState.global.loopStartTime) * pixelsPerSecond}px`;
- loopRegion.innerHTML = ``;
- loopRegion.classList.toggle("visible", appState.global.isLoopActive);
- ruler.appendChild(loopRegion);
+ // Seek na Régua
+ e.preventDefault();
+ const handleSeek = (event) => {
+ const rect = newRuler.getBoundingClientRect();
+ const scrollLeft = newRuler.scrollLeft;
+ const clickX = event.clientX - rect.left;
+ const absoluteX = clickX + scrollLeft;
+ const newTime = absoluteX / currentPixelsPerSecond;
+ // =================================================================
+ // 👇 INÍCIO DA CORREÇÃO (Sincronia de Seek na Régua)
+ // =================================================================
+ sendAction({ type: "SET_SEEK_TIME", seekTime: newTime });
+ // seekAudioEditor(newTime); // 👈 Substituído
+ // =================================================================
+ // 👆 FIM DA CORREÇÃO
+ };
+ handleSeek(e); // Aplica no mousedown
+ const onMouseMoveSeek = (moveEvent) => handleSeek(moveEvent);
+ const onMouseUpSeek = () => {
+ document.removeEventListener("mousemove", onMouseMoveSeek);
+ document.removeEventListener("mouseup", onMouseUpSeek);
+ };
+ document.addEventListener("mousemove", onMouseMoveSeek);
+ document.addEventListener("mouseup", onMouseUpSeek);
+ });
- // --- LISTENER DA RÉGUA (sem alterações) ---
- ruler.addEventListener('mousedown', (e) => {
- const currentPixelsPerSecond = getPixelsPerSecond();
- const loopHandle = e.target.closest('.loop-handle');
- const loopRegionBody = e.target.closest('#loop-region:not(.loop-handle)');
+ // Menu Contexto Régua (sem alterações)
+ newRuler.addEventListener("contextmenu", (e) => {
+ e.preventDefault();
+ document.getElementById("timeline-context-menu").style.display = "none";
+ const menu = document.getElementById("ruler-context-menu");
+ const currentPixelsPerSecond = getPixelsPerSecond();
+ const rect = newRuler.getBoundingClientRect();
+ const scrollLeft = newRuler.scrollLeft;
+ const clickX = e.clientX - rect.left;
+ const absoluteX = clickX + scrollLeft;
+ const clickTime = absoluteX / currentPixelsPerSecond;
+ appState.global.lastRulerClickTime = clickTime;
+ menu.style.display = "block";
+ menu.style.left = `${e.clientX}px`;
+ menu.style.top = `${e.clientY}px`;
+ });
- if (loopHandle) { /* ... lógica de loop ... */
- e.preventDefault(); e.stopPropagation();
- const handleType = loopHandle.classList.contains('left') ? 'left' : 'right';
- const initialMouseX = e.clientX;
- const initialStart = appState.global.loopStartTime;
- const initialEnd = appState.global.loopEndTime;
+ // Recriação Container Pistas (sem alterações)
+ const newTrackContainer = existingTrackContainer.cloneNode(false);
+ audioEditor.replaceChild(newTrackContainer, existingTrackContainer);
- const onMouseMove = (moveEvent) => {
- const deltaX = moveEvent.clientX - initialMouseX;
- const deltaTime = deltaX / currentPixelsPerSecond;
- let newStart = appState.global.loopStartTime;
- let newEnd = appState.global.loopEndTime;
-
- if (handleType === 'left') {
- newStart = Math.max(0, initialStart + deltaTime);
- newStart = Math.min(newStart, appState.global.loopEndTime - 0.1);
- appState.global.loopStartTime = newStart;
- } else {
- newEnd = Math.max(appState.global.loopStartTime + 0.1, initialEnd + deltaTime);
- appState.global.loopEndTime = newEnd;
- }
-
- updateTransportLoop();
- loopRegion.style.left = `${newStart * currentPixelsPerSecond}px`;
- loopRegion.style.width = `${(newEnd - newStart) * currentPixelsPerSecond}px`;
- };
-
- const onMouseUp = () => {
- document.removeEventListener('mousemove', onMouseMove);
- document.removeEventListener('mouseup', onMouseUp);
- renderAudioEditor();
- };
- document.addEventListener('mousemove', onMouseMove);
- document.addEventListener('mouseup', onMouseUp);
- return;
- }
- if (loopRegionBody) { /* ... lógica de mover loop ... */
- e.preventDefault(); e.stopPropagation();
- const initialMouseX = e.clientX;
- const initialStart = appState.global.loopStartTime;
- const initialEnd = appState.global.loopEndTime;
- const initialDuration = initialEnd - initialStart;
-
- const onMouseMove = (moveEvent) => {
- const deltaX = moveEvent.clientX - initialMouseX;
- const deltaTime = deltaX / currentPixelsPerSecond;
- let newStart = Math.max(0, initialStart + deltaTime);
- let newEnd = newStart + initialDuration;
- appState.global.loopStartTime = newStart;
- appState.global.loopEndTime = newEnd;
- updateTransportLoop();
- loopRegion.style.left = `${newStart * currentPixelsPerSecond}px`;
- };
-
- const onMouseUp = () => {
- document.removeEventListener('mousemove', onMouseMove);
- document.removeEventListener('mouseup', onMouseUp);
- renderAudioEditor();
- };
- document.addEventListener('mousemove', onMouseMove);
- document.addEventListener('mouseup', onMouseUp);
- return;
- }
-
- e.preventDefault();
- const handleSeek = (event) => { /* ... lógica de seek ... */
- const rect = ruler.getBoundingClientRect();
- const scrollLeft = ruler.scrollLeft;
- const clickX = event.clientX - rect.left;
- const absoluteX = clickX + scrollLeft;
- const newTime = absoluteX / currentPixelsPerSecond;
- seekAudioEditor(newTime);
- };
- handleSeek(e);
- const onMouseMoveSeek = (moveEvent) => handleSeek(moveEvent);
- const onMouseUpSeek = () => { document.removeEventListener('mousemove', onMouseMoveSeek); document.removeEventListener('mouseup', onMouseUpSeek); };
- document.addEventListener('mousemove', onMouseMoveSeek);
- document.addEventListener('mouseup', onMouseUpSeek);
- });
-
- // --- RECRIAÇÃO DO CONTAINER DE PISTAS (sem alterações) ---
- const newTrackContainer = existingTrackContainer.cloneNode(false);
- audioEditor.replaceChild(newTrackContainer, existingTrackContainer);
-
- if (appState.audio.tracks.length === 0) {
- appState.audio.tracks.push({ id: Date.now(), name: "Pista de Áudio 1" });
- }
-
- // --- RENDERIZAÇÃO DAS PISTAS INDIVIDUAIS (sem alterações) ---
- appState.audio.tracks.forEach(trackData => {
- const audioTrackLane = document.createElement('div');
- audioTrackLane.className = 'audio-track-lane';
- audioTrackLane.dataset.trackId = trackData.id;
- audioTrackLane.innerHTML = `
+ // Render Pistas (sem alterações)
+ appState.audio.tracks.forEach((trackData) => {
+ const audioTrackLane = document.createElement("div");
+ audioTrackLane.className = "audio-track-lane";
+ audioTrackLane.dataset.trackId = trackData.id;
+ audioTrackLane.innerHTML = `
- `;
- newTrackContainer.appendChild(audioTrackLane);
-
- const timelineContainer = audioTrackLane.querySelector('.timeline-container');
-
- timelineContainer.addEventListener("dragover", (e) => { e.preventDefault(); audioTrackLane.classList.add('drag-over'); });
- timelineContainer.addEventListener("dragleave", () => audioTrackLane.classList.remove('drag-over'));
-
- timelineContainer.addEventListener("drop", (e) => {
- e.preventDefault();
- audioTrackLane.classList.remove('drag-over');
- const filePath = e.dataTransfer.getData("text/plain");
- if (!filePath) return;
- const rect = timelineContainer.getBoundingClientRect();
- const dropX = e.clientX - rect.left + timelineContainer.scrollLeft;
- let startTimeInSeconds = dropX / pixelsPerSecond;
- startTimeInSeconds = quantizeTime(startTimeInSeconds);
- addAudioClipToTimeline(filePath, trackData.id, startTimeInSeconds);
+
+ `;
+ newTrackContainer.appendChild(audioTrackLane);
+ const timelineContainer = audioTrackLane.querySelector(
+ ".timeline-container"
+ );
+ timelineContainer.addEventListener("dragover", (e) => {
+ e.preventDefault();
+ audioTrackLane.classList.add("drag-over");
+ });
+ timelineContainer.addEventListener("dragleave", () =>
+ audioTrackLane.classList.remove("drag-over")
+ );
+ timelineContainer.addEventListener("drop", (e) => {
+ e.preventDefault();
+ audioTrackLane.classList.remove("drag-over");
+ const filePath = e.dataTransfer.getData("text/plain");
+ if (!filePath) return;
+ const rect = timelineContainer.getBoundingClientRect();
+ const dropX = e.clientX - rect.left + timelineContainer.scrollLeft;
+ let startTimeInSeconds = dropX / pixelsPerSecond;
+ startTimeInSeconds = quantizeTime(startTimeInSeconds);
+ if (
+ !trackData.id ||
+ startTimeInSeconds == null ||
+ isNaN(startTimeInSeconds)
+ ) {
+ console.error("Drop inválido. Ignorando.", {
+ id: trackData.id,
+ time: startTimeInSeconds,
});
-
- const grid = timelineContainer.querySelector('.spectrogram-view-grid');
- grid.style.setProperty('--step-width', `${stepWidthPx}px`);
- grid.style.setProperty('--beat-width', `${beatWidthPx}px`);
- grid.style.setProperty('--bar-width', `${barWidthPx}px`);
- });
-
- // --- RENDERIZAÇÃO DOS CLIPS (COM MODIFICAÇÃO PARA SELEÇÃO) ---
- appState.audio.clips.forEach(clip => {
- const parentGrid = newTrackContainer.querySelector(`.audio-track-lane[data-track-id="${clip.trackId}"] .spectrogram-view-grid`);
- if (!parentGrid) return;
-
- const clipElement = document.createElement('div');
- clipElement.className = 'timeline-clip';
- clipElement.dataset.clipId = clip.id;
-
- // --- INÍCIO DA MODIFICAÇÃO ---
- // Adiciona a classe 'selected' se o ID corresponder ao estado global
- if (clip.id === appState.global.selectedClipId) {
- clipElement.classList.add('selected');
- }
- // --- FIM DA MODIFICAÇÃO ---
-
- clipElement.style.left = `${(clip.startTimeInSeconds || 0) * pixelsPerSecond}px`;
- clipElement.style.width = `${(clip.durationInSeconds || 0) * pixelsPerSecond}px`;
-
- let pitchStr = clip.pitch > 0 ? `+${clip.pitch.toFixed(1)}` : `${clip.pitch.toFixed(1)}`;
- if (clip.pitch === 0) pitchStr = '';
- clipElement.innerHTML = `
-
- ${clip.name} ${pitchStr}
-
-
- `;
- parentGrid.appendChild(clipElement);
-
- if (clip.buffer) {
- const canvas = clipElement.querySelector('.waveform-canvas-clip');
- const canvasWidth = (clip.durationInSeconds || 0) * pixelsPerSecond;
- if (canvasWidth > 0) {
- canvas.width = canvasWidth;
- canvas.height = 40;
- const audioBuffer = clip.buffer;
-
- // --- INÍCIO DA CORREÇÃO ---
- // Verifica se o clipe está esticado (pitch != 0) ou aparado (pitch == 0).
- const isStretched = clip.pitch !== 0;
-
- // Se for 'stretch', devemos usar a duração original do buffer como fonte.
- // Se for 'trim', usamos a duração e offset atuais do clipe.
- const sourceOffset = isStretched ? 0 : (clip.offset || 0);
- const sourceDuration = isStretched ? clip.originalDuration : clip.durationInSeconds;
-
- // Chama drawWaveform para desenhar o segmento-fonte (source)
- // dentro do canvas (que já tem a largura de destino).
- drawWaveform(canvas, audioBuffer, 'var(--accent-green)', sourceOffset, sourceDuration);
- // --- FIM DA CORREÇÃO ---
- }
- }
-
- clipElement.addEventListener('wheel', (e) => {
- e.preventDefault();
- const clipToUpdate = appState.audio.clips.find(c => c.id == clipElement.dataset.clipId);
- if (!clipToUpdate) return;
- const direction = e.deltaY < 0 ? 1 : -1;
- let newPitch = clipToUpdate.pitch + direction;
- newPitch = Math.max(-24, Math.min(24, newPitch));
- updateAudioClipProperties(clipToUpdate.id, { pitch: newPitch });
- renderAudioEditor();
- restartAudioEditorIfPlaying();
+ return;
+ }
+ const clipId =
+ crypto?.randomUUID?.() ||
+ `clip_${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
+ addAudioClipToTimeline(
+ filePath,
+ trackData.id,
+ startTimeInSeconds,
+ clipId
+ );
+ try {
+ sendAction({
+ type: "ADD_AUDIO_CLIP",
+ filePath,
+ trackId: trackData.id,
+ startTimeInSeconds,
+ clipId,
+ name: String(filePath).split(/[\\/]/).pop(),
});
+ } catch (err) {
+ console.warn("[SYNC] Falha ao emitir ADD_AUDIO_CLIP", err);
+ }
});
-
- // --- SINCRONIZAÇÃO DE SCROLL (sem alterações) ---
- newTrackContainer.addEventListener('scroll', () => {
- const scrollPos = newTrackContainer.scrollLeft;
- if (ruler.scrollLeft !== scrollPos) {
- ruler.scrollLeft = scrollPos;
- }
- });
+ const grid = timelineContainer.querySelector(".spectrogram-view-grid");
+ grid.style.setProperty("--step-width", `${stepWidthPx}px`);
+ grid.style.setProperty("--beat-width", `${beatWidthPx}px`);
+ grid.style.setProperty("--bar-width", `${barWidthPx}px`);
+ });
- // --- EVENT LISTENER PRINCIPAL (COM MODIFICAÇÃO PARA SELEÇÃO) ---
- newTrackContainer.addEventListener('mousedown', (e) => {
- // --- INÍCIO DA MODIFICAÇÃO ---
- // Esconde o menu de contexto se estiver aberto
- const menu = document.getElementById('timeline-context-menu');
- if (menu) menu.style.display = 'none';
-
- const clipElement = e.target.closest('.timeline-clip');
-
- // Desseleciona se clicar fora de um clipe (e não for clique direito)
- if (!clipElement && e.button !== 2) {
- if (appState.global.selectedClipId) {
- appState.global.selectedClipId = null;
-
- // Remove a classe de todos os clipes (para resposta visual imediata)
- newTrackContainer.querySelectorAll('.timeline-clip.selected').forEach(c => {
- c.classList.remove('selected');
- });
- }
- }
- // --- FIM DA MODIFICAÇÃO ---
-
- const currentPixelsPerSecond = getPixelsPerSecond();
- const handle = e.target.closest('.clip-resize-handle');
- // const clipElement = e.target.closest('.timeline-clip'); // <-- Já definido acima
-
- if (appState.global.sliceToolActive && clipElement) {
- e.preventDefault();
- e.stopPropagation();
- const clipId = clipElement.dataset.clipId;
- const timelineContainer = clipElement.closest('.timeline-container');
- const rect = timelineContainer.getBoundingClientRect();
- const clickX = e.clientX - rect.left;
- const absoluteX = clickX + timelineContainer.scrollLeft;
- let sliceTimeInTimeline = absoluteX / currentPixelsPerSecond;
- sliceTimeInTimeline = quantizeTime(sliceTimeInTimeline);
- sliceAudioClip(clipId, sliceTimeInTimeline);
- renderAudioEditor();
- return;
- }
-
- // --- CORREÇÃO: LÓGICA DE REDIMENSIONAMENTO DIVIDIDA ---
- if (handle) {
- e.preventDefault();
- e.stopPropagation();
-
- const clipId = clipElement.dataset.clipId;
- const clip = appState.audio.clips.find(c => c.id == clipId);
- if (!clip || !clip.buffer) return;
-
- const handleType = handle.classList.contains('left') ? 'left' : 'right';
- const initialMouseX = e.clientX;
- const secondsPerStep = getSecondsPerStep();
-
- const initialLeftPx = clipElement.offsetLeft;
- const initialWidthPx = clipElement.offsetWidth;
-
- const initialStartTime = clip.startTimeInSeconds;
- const initialDuration = clip.durationInSeconds;
- const initialOffset = clip.offset || 0;
- const initialPitch = clip.pitch || 0;
- const initialOriginalDuration = clip.originalDuration || clip.buffer.duration;
-
- // O tempo "zero" absoluto do buffer (seu início)
- const bufferStartTime = initialStartTime - initialOffset;
-
- const onMouseMove = (moveEvent) => {
- const deltaX = moveEvent.clientX - initialMouseX;
-
- // --- MODO 2: TRIMMING ---
- if (appState.global.resizeMode === 'trim') {
- if (handleType === 'right') {
- let newWidthPx = initialWidthPx + deltaX;
- let newDuration = newWidthPx / currentPixelsPerSecond;
- let newEndTime = quantizeTime(initialStartTime + newDuration);
-
- newEndTime = Math.max(initialStartTime + secondsPerStep, newEndTime);
-
- const maxEndTime = bufferStartTime + initialOriginalDuration;
- newEndTime = Math.min(newEndTime, maxEndTime);
-
- clipElement.style.width = `${(newEndTime - initialStartTime) * currentPixelsPerSecond}px`;
-
- } else if (handleType === 'left') {
- let newLeftPx = initialLeftPx + deltaX;
- let newStartTime = newLeftPx / currentPixelsPerSecond;
- newStartTime = quantizeTime(newStartTime);
-
- const minStartTime = (initialStartTime + initialDuration) - secondsPerStep;
- newStartTime = Math.min(newStartTime, minStartTime);
-
- newStartTime = Math.max(bufferStartTime, newStartTime);
-
- const newLeftFinalPx = newStartTime * currentPixelsPerSecond;
- const newWidthFinalPx = ((initialStartTime + initialDuration) - newStartTime) * currentPixelsPerSecond;
- clipElement.style.left = `${newLeftFinalPx}px`;
- clipElement.style.width = `${newWidthFinalPx}px`;
- }
- }
- // --- MODO 1: STRETCHING ---
- else if (appState.global.resizeMode === 'stretch') {
- if (handleType === 'right') {
- let newWidthPx = initialWidthPx + deltaX;
- let newDuration = newWidthPx / currentPixelsPerSecond;
- let newEndTime = quantizeTime(initialStartTime + newDuration);
-
- newEndTime = Math.max(initialStartTime + secondsPerStep, newEndTime);
-
- clipElement.style.width = `${(newEndTime - initialStartTime) * currentPixelsPerSecond}px`;
-
- } else if (handleType === 'left') {
- let newLeftPx = initialLeftPx + deltaX;
- let newStartTime = newLeftPx / currentPixelsPerSecond;
- newStartTime = quantizeTime(newStartTime);
-
- const minStartTime = (initialStartTime + initialDuration) - secondsPerStep;
- newStartTime = Math.min(newStartTime, minStartTime);
-
- const newLeftFinalPx = newStartTime * currentPixelsPerSecond;
- const newWidthFinalPx = ((initialStartTime + initialDuration) - newStartTime) * currentPixelsPerSecond;
- clipElement.style.left = `${newLeftFinalPx}px`;
- clipElement.style.width = `${newWidthFinalPx}px`;
- }
- }
- };
-
- const onMouseUp = (upEvent) => {
- document.removeEventListener('mousemove', onMouseMove);
- document.removeEventListener('mouseup', onMouseUp);
-
- const finalLeftPx = clipElement.offsetLeft;
- const finalWidthPx = clipElement.offsetWidth;
-
- const newStartTime = finalLeftPx / currentPixelsPerSecond;
- const newDuration = finalWidthPx / currentPixelsPerSecond;
-
- // --- MODO 2: TRIMMING ---
- if (appState.global.resizeMode === 'trim') {
- const newOffset = newStartTime - bufferStartTime;
-
- if (handleType === 'right') {
- updateAudioClipProperties(clipId, {
- durationInSeconds: newDuration,
- pitch: 0 // Reseta o pitch
- });
- } else if (handleType === 'left') {
- updateAudioClipProperties(clipId, {
- startTimeInSeconds: newStartTime,
- durationInSeconds: newDuration,
- offset: newOffset,
- pitch: 0 // Reseta o pitch
- });
- }
- }
- // --- MODO 1: STRETCHING ---
- else if (appState.global.resizeMode === 'stretch') {
- // Calcula o novo pitch baseado na mudança de duração
- // Usa a duração *do buffer* (originalDuration) como base
- const newPlaybackRate = initialOriginalDuration / newDuration;
- const newPitch = 12 * Math.log2(newPlaybackRate);
-
- if (handleType === 'right') {
- updateAudioClipProperties(clipId, {
- durationInSeconds: newDuration,
- pitch: newPitch,
- offset: 0 // Stretch sempre reseta o offset
- });
- } else if (handleType === 'left') {
- updateAudioClipProperties(clipId, {
- startTimeInSeconds: newStartTime,
- durationInSeconds: newDuration,
- pitch: newPitch,
- offset: 0 // Stretch sempre reseta o offset
- });
- }
- }
-
- restartAudioEditorIfPlaying();
- renderAudioEditor();
- };
-
- document.addEventListener('mousemove', onMouseMove);
- document.addEventListener('mouseup', onMouseUp);
- return;
- }
- // --- FIM DA CORREÇÃO ---
-
-
- if (clipElement) {
- // --- INÍCIO DA MODIFICAÇÃO (SELEÇÃO NO DRAG) ---
- const clipId = clipElement.dataset.clipId;
- // Se o clipe clicado não for o já selecionado, atualiza a seleção
- if (appState.global.selectedClipId !== clipId) {
- appState.global.selectedClipId = clipId; // Define o estado global
-
- // Atualiza visualmente
- newTrackContainer.querySelectorAll('.timeline-clip.selected').forEach(c => {
- c.classList.remove('selected');
- });
- clipElement.classList.add('selected');
- }
- // --- FIM DA MODIFICAÇÃO ---
-
- // Lógica de 'drag' (sem alterações)
- e.preventDefault();
- // const clipId = clipElement.dataset.clipId; // <-- Já definido acima
- const clickOffsetInClip = e.clientX - clipElement.getBoundingClientRect().left;
- clipElement.classList.add('dragging');
- let lastOverLane = clipElement.closest('.audio-track-lane');
- const onMouseMove = (moveEvent) => {
- const deltaX = moveEvent.clientX - e.clientX;
- clipElement.style.transform = `translateX(${deltaX}px)`;
- const overElement = document.elementFromPoint(moveEvent.clientX, moveEvent.clientY);
- const overLane = overElement ? overElement.closest('.audio-track-lane') : null;
- if (overLane && overLane !== lastOverLane) {
- if(lastOverLane) lastOverLane.classList.remove('drag-over');
- overLane.classList.add('drag-over');
- lastOverLane = overLane;
- }
- };
- const onMouseUp = (upEvent) => {
- clipElement.classList.remove('dragging');
- if (lastOverLane) lastOverLane.classList.remove('drag-over');
- clipElement.style.transform = '';
- document.removeEventListener('mousemove', onMouseMove);
- document.removeEventListener('mouseup', onMouseUp);
- const finalLane = lastOverLane;
- if (!finalLane) return;
- const newTrackId = finalLane.dataset.trackId;
- const timelineContainer = finalLane.querySelector('.timeline-container');
- const wrapperRect = timelineContainer.getBoundingClientRect();
- const newLeftPx = (upEvent.clientX - wrapperRect.left) - clickOffsetInClip + timelineContainer.scrollLeft;
-
- const constrainedLeftPx = Math.max(0, newLeftPx);
- let newStartTime = constrainedLeftPx / currentPixelsPerSecond;
- newStartTime = quantizeTime(newStartTime);
-
- updateAudioClipProperties(clipId, { trackId: Number(newTrackId), startTimeInSeconds: newStartTime });
- renderAudioEditor();
- };
- document.addEventListener('mousemove', onMouseMove);
- document.addEventListener('mouseup', onMouseUp);
- return;
- }
-
- const timelineContainer = e.target.closest('.timeline-container');
- if (timelineContainer) {
- // Lógica de 'seek' (sem alterações)
- e.preventDefault();
- const handleSeek = (event) => {
- const rect = timelineContainer.getBoundingClientRect();
- const scrollLeft = timelineContainer.scrollLeft;
- const clickX = event.clientX - rect.left;
- const absoluteX = clickX + scrollLeft;
- const newTime = absoluteX / currentPixelsPerSecond;
- seekAudioEditor(newTime);
- };
- handleSeek(e);
- const onMouseMoveSeek = (moveEvent) => handleSeek(moveEvent);
- const onMouseUpSeek = () => { document.removeEventListener('mousemove', onMouseMoveSeek); document.removeEventListener('mouseup', onMouseUpSeek); };
- document.addEventListener('mousemove', onMouseMoveSeek);
- document.addEventListener('mouseup', onMouseUpSeek);
- }
- });
-
- // --- LISTENER ADICIONADO (Menu de Contexto) ---
- newTrackContainer.addEventListener('contextmenu', (e) => {
- e.preventDefault(); // Impede o menu de contexto padrão do navegador
- const menu = document.getElementById('timeline-context-menu');
- if (!menu) return;
-
- const clipElement = e.target.closest('.timeline-clip');
-
- if (clipElement) {
- // 1. Seleciona o clipe clicado (define o estado)
- const clipId = clipElement.dataset.clipId;
- if (appState.global.selectedClipId !== clipId) {
- appState.global.selectedClipId = clipId;
-
- // Atualiza visualmente
- newTrackContainer.querySelectorAll('.timeline-clip.selected').forEach(c => {
- c.classList.remove('selected');
- });
- clipElement.classList.add('selected');
- }
-
- // 2. Posiciona e mostra o menu
- menu.style.display = 'block';
- menu.style.left = `${e.clientX}px`;
- menu.style.top = `${e.clientY}px`;
- } else {
- // Esconde o menu se clicar com o botão direito fora de um clipe
- menu.style.display = 'none';
- }
- });
- // --- FIM DO LISTENER ADICIONADO ---
-}
-
-// --- Funções de UI (sem alterações) ---
-
-export function updateAudioEditorUI() {
- const playBtn = document.getElementById('audio-editor-play-btn');
- if (!playBtn) return;
- if (appState.global.isAudioEditorPlaying) {
- playBtn.classList.remove('fa-play');
- playBtn.classList.add('fa-pause');
- } else {
- playBtn.classList.remove('fa-pause');
- playBtn.classList.add('fa-play');
+ // Render Clips (sem alterações)
+ appState.audio.clips.forEach((clip) => {
+ const parentGrid = newTrackContainer.querySelector(
+ `.audio-track-lane[data-track-id="${clip.trackId}"] .spectrogram-view-grid`
+ );
+ if (!parentGrid) return;
+ const clipElement = document.createElement("div");
+ clipElement.className = "timeline-clip";
+ clipElement.dataset.clipId = clip.id;
+ if (clip.id === appState.global.selectedClipId)
+ clipElement.classList.add("selected");
+ if (appState.global.clipboard?.cutSourceId === clip.id)
+ clipElement.classList.add("cut");
+ clipElement.style.left = `${
+ (clip.startTimeInSeconds || 0) * pixelsPerSecond
+ }px`;
+ clipElement.style.width = `${
+ (clip.durationInSeconds || 0) * pixelsPerSecond
+ }px`;
+ let pitchStr =
+ clip.pitch > 0 ? `+${clip.pitch.toFixed(1)}` : `${clip.pitch.toFixed(1)}`;
+ if (clip.pitch === 0) pitchStr = "";
+ clipElement.innerHTML = ` ${clip.name} ${pitchStr} `;
+ parentGrid.appendChild(clipElement);
+ if (clip.buffer) {
+ const canvas = clipElement.querySelector(".waveform-canvas-clip");
+ const canvasWidth = (clip.durationInSeconds || 0) * pixelsPerSecond;
+ if (canvasWidth > 0) {
+ canvas.width = canvasWidth;
+ canvas.height = 40;
+ const audioBuffer = clip.buffer;
+ const isStretched = clip.pitch !== 0;
+ const sourceOffset = isStretched ? 0 : clip.offset || 0;
+ const sourceDuration = isStretched
+ ? clip.originalDuration
+ : clip.durationInSeconds;
+ drawWaveform(
+ canvas,
+ audioBuffer,
+ "var(--accent-green)",
+ sourceOffset,
+ sourceDuration
+ );
+ }
}
+ clipElement.addEventListener("wheel", (e) => {
+ e.preventDefault();
+ const clipToUpdate = appState.audio.clips.find(
+ (c) => c.id == clipElement.dataset.clipId
+ );
+ if (!clipToUpdate) return;
+ const direction = e.deltaY < 0 ? 1 : -1;
+ let newPitch = clipToUpdate.pitch + direction;
+ newPitch = Math.max(-24, Math.min(24, newPitch));
+ updateAudioClipProperties(clipToUpdate.id, { pitch: newPitch });
+ try {
+ sendAction({
+ type: "UPDATE_AUDIO_CLIP",
+ clipId: clipToUpdate.id,
+ props: { pitch: newPitch },
+ });
+ } catch (err) {
+ console.warn("[SYNC] Falha ao emitir UPDATE_AUDIO_CLIP (wheel)", err);
+ }
+ renderAudioEditor();
+ restartAudioEditorIfPlaying();
+ });
+ });
+
+ // Sync Scroll (sem alterações)
+ newTrackContainer.addEventListener("scroll", () => {
+ const scrollPos = newTrackContainer.scrollLeft;
+ const mainRuler = document.querySelector(".timeline-ruler");
+ if (mainRuler && mainRuler.scrollLeft !== scrollPos) {
+ mainRuler.scrollLeft = scrollPos;
+ }
+ });
+
+ // Event Listener Principal (mousedown no container de pistas)
+ newTrackContainer.addEventListener("mousedown", (e) => {
+ // Esconde menus
+ document.getElementById("timeline-context-menu").style.display = "none";
+ document.getElementById("ruler-context-menu").style.display = "none";
+ const clipElement = e.target.closest(".timeline-clip");
+ // Desseleciona se clicar fora
+ if (!clipElement && e.button !== 2) {
+ if (appState.global.selectedClipId) {
+ appState.global.selectedClipId = null;
+ newTrackContainer
+ .querySelectorAll(".timeline-clip.selected")
+ .forEach((c) => c.classList.remove("selected"));
+ }
+ }
+
+ const currentPixelsPerSecond = getPixelsPerSecond();
+ const handle = e.target.closest(".clip-resize-handle");
+
+ // Slice Tool
+ if (appState.global.sliceToolActive && clipElement) {
+ e.preventDefault();
+ e.stopPropagation();
+ const clipId = clipElement.dataset.clipId;
+ const timelineContainer = clipElement.closest(".timeline-container");
+ const rect = timelineContainer.getBoundingClientRect();
+ const clickX = e.clientX - rect.left;
+ const absoluteX = clickX + timelineContainer.scrollLeft;
+ let sliceTimeInTimeline = absoluteX / currentPixelsPerSecond;
+ sliceTimeInTimeline = quantizeTime(sliceTimeInTimeline);
+ sliceAudioClip(clipId, sliceTimeInTimeline);
+ try {
+ sendAction({
+ type: "UPDATE_AUDIO_CLIP",
+ clipId,
+ props: { __operation: "slice", sliceTimeInTimeline },
+ });
+ } catch (err) {
+ console.warn("[SYNC] Falha ao emitir UPDATE_AUDIO_CLIP (slice)", err);
+ }
+ renderAudioEditor();
+ return;
+ }
+
+ // Resize Handle
+ if (handle) {
+ e.preventDefault();
+ e.stopPropagation();
+ const clipId = clipElement.dataset.clipId;
+ const clip = appState.audio.clips.find((c) => c.id == clipId);
+ if (!clip || !clip.buffer) return;
+ const handleType = handle.classList.contains("left") ? "left" : "right";
+ const initialMouseX = e.clientX;
+ const secondsPerStep = getSecondsPerStep();
+ const initialLeftPx = clipElement.offsetLeft;
+ const initialWidthPx = clipElement.offsetWidth;
+ const initialStartTime = clip.startTimeInSeconds;
+ const initialDuration = clip.durationInSeconds;
+ const initialOffset = clip.offset || 0;
+ const initialOriginalDuration =
+ clip.originalDuration || clip.buffer.duration;
+ const bufferStartTime = initialStartTime - initialOffset;
+ const onMouseMove = (moveEvent) => {
+ const deltaX = moveEvent.clientX - initialMouseX;
+ // Trim Mode
+ if (appState.global.resizeMode === "trim") {
+ if (handleType === "right") {
+ let newWidthPx = initialWidthPx + deltaX;
+ let newDuration = newWidthPx / currentPixelsPerSecond;
+ let newEndTime = quantizeTime(initialStartTime + newDuration);
+ newEndTime = Math.max(
+ initialStartTime + secondsPerStep,
+ newEndTime
+ );
+ const maxEndTime = bufferStartTime + initialOriginalDuration;
+ newEndTime = Math.min(newEndTime, maxEndTime);
+ clipElement.style.width = `${
+ (newEndTime - initialStartTime) * currentPixelsPerSecond
+ }px`;
+ } else if (handleType === "left") {
+ let newLeftPx = initialLeftPx + deltaX;
+ let newStartTime = newLeftPx / currentPixelsPerSecond;
+ newStartTime = quantizeTime(newStartTime);
+ const minStartTime =
+ initialStartTime + initialDuration - secondsPerStep;
+ newStartTime = Math.min(newStartTime, minStartTime);
+ newStartTime = Math.max(bufferStartTime, newStartTime);
+ const newLeftFinalPx = newStartTime * currentPixelsPerSecond;
+ const newWidthFinalPx =
+ (initialStartTime + initialDuration - newStartTime) *
+ currentPixelsPerSecond;
+ clipElement.style.left = `${newLeftFinalPx}px`;
+ clipElement.style.width = `${newWidthFinalPx}px`;
+ }
+ }
+ // Stretch Mode
+ else if (appState.global.resizeMode === "stretch") {
+ if (handleType === "right") {
+ let newWidthPx = initialWidthPx + deltaX;
+ let newDuration = newWidthPx / currentPixelsPerSecond;
+ let newEndTime = quantizeTime(initialStartTime + newDuration);
+ newEndTime = Math.max(
+ initialStartTime + secondsPerStep,
+ newEndTime
+ );
+ clipElement.style.width = `${
+ (newEndTime - initialStartTime) * currentPixelsPerSecond
+ }px`;
+ } else if (handleType === "left") {
+ let newLeftPx = initialLeftPx + deltaX;
+ let newStartTime = newLeftPx / currentPixelsPerSecond;
+ newStartTime = quantizeTime(newStartTime);
+ const minStartTime =
+ initialStartTime + initialDuration - secondsPerStep;
+ newStartTime = Math.min(newStartTime, minStartTime);
+ const newLeftFinalPx = newStartTime * currentPixelsPerSecond;
+ const newWidthFinalPx =
+ (initialStartTime + initialDuration - newStartTime) *
+ currentPixelsPerSecond;
+ clipElement.style.left = `${newLeftFinalPx}px`;
+ clipElement.style.width = `${newWidthFinalPx}px`;
+ }
+ }
+ };
+ const onMouseUp = (upEvent) => {
+ document.removeEventListener("mousemove", onMouseMove);
+ document.removeEventListener("mouseup", onMouseUp);
+ const finalLeftPx = clipElement.offsetLeft;
+ const finalWidthPx = clipElement.offsetWidth;
+ const newStartTime = finalLeftPx / currentPixelsPerSecond;
+ const newDuration = finalWidthPx / currentPixelsPerSecond;
+ // Trim Mode
+ if (appState.global.resizeMode === "trim") {
+ const newOffset = newStartTime - bufferStartTime;
+ if (handleType === "right") {
+ updateAudioClipProperties(clipId, {
+ durationInSeconds: newDuration,
+ pitch: 0,
+ });
+ try {
+ sendAction({
+ type: "UPDATE_AUDIO_CLIP",
+ clipId,
+ props: { durationInSeconds: newDuration, pitch: 0 },
+ });
+ } catch (err) {
+ console.warn(
+ "[SYNC] Falha ao emitir UPDATE_AUDIO_CLIP (trim-right)",
+ err
+ );
+ }
+ } else if (handleType === "left") {
+ updateAudioClipProperties(clipId, {
+ startTimeInSeconds: newStartTime,
+ durationInSeconds: newDuration,
+ offset: newOffset,
+ pitch: 0,
+ });
+ try {
+ sendAction({
+ type: "UPDATE_AUDIO_CLIP",
+ clipId,
+ props: {
+ startTimeInSeconds: newStartTime,
+ durationInSeconds: newDuration,
+ offset: newOffset,
+ pitch: 0,
+ },
+ });
+ } catch (err) {
+ console.warn(
+ "[SYNC] Falha ao emitir UPDATE_AUDIO_CLIP (trim-left)",
+ err
+ );
+ }
+ }
+ }
+ // Stretch Mode
+ else if (appState.global.resizeMode === "stretch") {
+ const newPlaybackRate = initialOriginalDuration / newDuration;
+ const newPitch = 12 * Math.log2(newPlaybackRate);
+ if (handleType === "right") {
+ updateAudioClipProperties(clipId, {
+ durationInSeconds: newDuration,
+ pitch: newPitch,
+ offset: 0,
+ });
+ try {
+ sendAction({
+ type: "UPDATE_AUDIO_CLIP",
+ clipId,
+ props: {
+ durationInSeconds: newDuration,
+ pitch: newPitch,
+ offset: 0,
+ },
+ });
+ } catch (err) {
+ console.warn(
+ "[SYNC] Falha ao emitir UPDATE_AUDIO_CLIP (stretch-right)",
+ err
+ );
+ }
+ } else if (handleType === "left") {
+ updateAudioClipProperties(clipId, {
+ startTimeInSeconds: newStartTime,
+ durationInSeconds: newDuration,
+ pitch: newPitch,
+ offset: 0,
+ });
+ try {
+ sendAction({
+ type: "UPDATE_AUDIO_CLIP",
+ clipId,
+ props: {
+ startTimeInSeconds: newStartTime,
+ durationInSeconds: newDuration,
+ pitch: newPitch,
+ offset: 0,
+ },
+ });
+ } catch (err) {
+ console.warn(
+ "[SYNC] Falha ao emitir UPDATE_AUDIO_CLIP (stretch-left)",
+ err
+ );
+ }
+ }
+ }
+ restartAudioEditorIfPlaying();
+ renderAudioEditor();
+ };
+ document.addEventListener("mousemove", onMouseMove);
+ document.addEventListener("mouseup", onMouseUp);
+ return;
+ }
+
+ // Drag Clip
+ if (clipElement) {
+ const clipId = clipElement.dataset.clipId;
+ if (appState.global.selectedClipId !== clipId) {
+ appState.global.selectedClipId = clipId;
+ newTrackContainer
+ .querySelectorAll(".timeline-clip.selected")
+ .forEach((c) => c.classList.remove("selected"));
+ clipElement.classList.add("selected");
+ }
+ e.preventDefault();
+ const clickOffsetInClip =
+ e.clientX - clipElement.getBoundingClientRect().left;
+ clipElement.classList.add("dragging");
+ let lastOverLane = clipElement.closest(".audio-track-lane");
+ const onMouseMove = (moveEvent) => {
+ const deltaX = moveEvent.clientX - e.clientX;
+ clipElement.style.transform = `translateX(${deltaX}px)`;
+ const overElement = document.elementFromPoint(
+ moveEvent.clientX,
+ moveEvent.clientY
+ );
+ const overLane = overElement
+ ? overElement.closest(".audio-track-lane")
+ : null;
+ if (overLane && overLane !== lastOverLane) {
+ if (lastOverLane) lastOverLane.classList.remove("drag-over");
+ overLane.classList.add("drag-over");
+ lastOverLane = overLane;
+ }
+ };
+ const onMouseUp = (upEvent) => {
+ clipElement.classList.remove("dragging");
+ if (lastOverLane) lastOverLane.classList.remove("drag-over");
+ clipElement.style.transform = "";
+ document.removeEventListener("mousemove", onMouseMove);
+ document.removeEventListener("mouseup", onMouseUp);
+ const finalLane = lastOverLane;
+ if (!finalLane) return;
+ const newTrackId = finalLane.dataset.trackId; // (é uma string)
+ const timelineContainer = finalLane.querySelector(
+ ".timeline-container"
+ );
+ const wrapperRect = timelineContainer.getBoundingClientRect();
+ const newLeftPx =
+ upEvent.clientX -
+ wrapperRect.left -
+ clickOffsetInClip +
+ timelineContainer.scrollLeft;
+ const constrainedLeftPx = Math.max(0, newLeftPx);
+ let newStartTime = constrainedLeftPx / currentPixelsPerSecond;
+ newStartTime = quantizeTime(newStartTime);
+ // (Correção Bug 4 - remove Number())
+ updateAudioClipProperties(clipId, {
+ trackId: newTrackId,
+ startTimeInSeconds: newStartTime,
+ });
+ try {
+ sendAction({
+ type: "UPDATE_AUDIO_CLIP",
+ clipId,
+ props: { trackId: newTrackId, startTimeInSeconds: newStartTime },
+ });
+ } catch (err) {
+ console.warn("[SYNC] Falha ao emitir UPDATE_AUDIO_CLIP (move)", err);
+ }
+ renderAudioEditor();
+ };
+ document.addEventListener("mousemove", onMouseMove);
+ document.addEventListener("mouseup", onMouseUp);
+ return;
+ }
+
+ // Seek na Pista
+ const timelineContainer = e.target.closest(".timeline-container");
+ if (timelineContainer) {
+ e.preventDefault();
+ const handleSeek = (event) => {
+ const rect = timelineContainer.getBoundingClientRect();
+ const scrollLeft = timelineContainer.scrollLeft;
+ const clickX = event.clientX - rect.left;
+ const absoluteX = clickX + scrollLeft;
+ const newTime = absoluteX / currentPixelsPerSecond;
+ // =================================================================
+ // 👇 INÍCIO DA CORREÇÃO (Sincronia de Seek na Pista)
+ // =================================================================
+ sendAction({ type: "SET_SEEK_TIME", seekTime: newTime });
+ // seekAudioEditor(newTime); // 👈 Substituído
+ // =================================================================
+ // 👆 FIM DA CORREÇÃO
+ };
+ handleSeek(e); // Aplica no mousedown
+ const onMouseMoveSeek = (moveEvent) => handleSeek(moveEvent);
+ const onMouseUpSeek = () => {
+ document.removeEventListener("mousemove", onMouseMoveSeek);
+ document.removeEventListener("mouseup", onMouseUpSeek);
+ };
+ document.addEventListener("mousemove", onMouseMoveSeek);
+ document.addEventListener("mouseup", onMouseUpSeek);
+ }
+ });
+
+ // Menu Contexto Pista (sem alterações)
+ newTrackContainer.addEventListener("contextmenu", (e) => {
+ e.preventDefault();
+ document.getElementById("ruler-context-menu").style.display = "none";
+ const menu = document.getElementById("timeline-context-menu");
+ if (!menu) return;
+ const clipElement = e.target.closest(".timeline-clip");
+ const copyItem = document.getElementById("copy-clip");
+ const cutItem = document.getElementById("cut-clip");
+ const pasteItem = document.getElementById("paste-clip");
+ const deleteItem = document.getElementById("delete-clip");
+ const canPaste = appState.global.clipboard?.type === "audio";
+ pasteItem.style.display = canPaste ? "block" : "none";
+ if (clipElement) {
+ const clipId = clipElement.dataset.clipId;
+ if (appState.global.selectedClipId !== clipId) {
+ appState.global.selectedClipId = clipId;
+ newTrackContainer
+ .querySelectorAll(".timeline-clip.selected")
+ .forEach((c) => c.classList.remove("selected"));
+ clipElement.classList.add("selected");
+ }
+ copyItem.style.display = "block";
+ cutItem.style.display = "block";
+ deleteItem.style.display = "block";
+ menu.style.display = "block";
+ menu.style.left = `${e.clientX}px`;
+ menu.style.top = `${e.clientY}px`;
+ if (!deleteItem.__synced) {
+ deleteItem.__synced = true;
+ deleteItem.addEventListener("click", () => {
+ const id = appState.global.selectedClipId;
+ if (!id) return;
+ const ok = removeAudioClip(id);
+ try {
+ sendAction({ type: "REMOVE_AUDIO_CLIP", clipId: id });
+ } catch (err) {
+ console.warn("[SYNC] Falha ao emitir REMOVE_AUDIO_CLIP", err);
+ }
+ if (ok) renderAudioEditor();
+ menu.style.display = "none";
+ });
+ }
+ } else {
+ copyItem.style.display = "none";
+ cutItem.style.display = "none";
+ deleteItem.style.display = "none";
+ if (canPaste) {
+ menu.style.display = "block";
+ menu.style.left = `${e.clientX}px`;
+ menu.style.top = `${e.clientY}px`;
+ } else {
+ menu.style.display = "none";
+ }
+ }
+ });
}
+// Funções de UI (sem alterações)
+export function updateAudioEditorUI() {
+ const playBtn = document.getElementById("audio-editor-play-btn");
+ if (!playBtn) return;
+ if (appState.global.isAudioEditorPlaying) {
+ playBtn.classList.remove("fa-play");
+ playBtn.classList.add("fa-pause");
+ } else {
+ playBtn.classList.remove("fa-pause");
+ playBtn.classList.add("fa-play");
+ }
+}
export function updatePlayheadVisual(pixels) {
- document.querySelectorAll('.audio-track-lane .playhead').forEach(ph => {
- ph.style.left = `${pixels}px`;
- });
+ document.querySelectorAll(".audio-track-lane .playhead").forEach((ph) => {
+ ph.style.left = `${pixels}px`;
+ });
}
-
export function resetPlayheadVisual() {
- document.querySelectorAll('.audio-track-lane .playhead').forEach(ph => {
- ph.style.left = '0px';
- });
-}
\ No newline at end of file
+ document.querySelectorAll(".audio-track-lane .playhead").forEach((ph) => {
+ ph.style.left = "0px";
+ });
+}
diff --git a/assets/js/creations/config.js b/assets/js/creations/config.js
index f264d34..b7a5a2c 100644
--- a/assets/js/creations/config.js
+++ b/assets/js/creations/config.js
@@ -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)
diff --git a/assets/js/creations/file.js b/assets/js/creations/file.js
index 2c41b63..79acc0b 100644
--- a/assets/js/creations/file.js
+++ b/assets/js/creations/file.js
@@ -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);
-}
\ No newline at end of file
+}
diff --git a/assets/js/creations/main.js b/assets/js/creations/main.js
index 93b5c92..cc80184 100644
--- a/assets/js/creations/main.js
+++ b/assets/js/creations/main.js
@@ -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
-});
\ No newline at end of file
+ updateToolButtons();
+});
diff --git a/assets/js/creations/pattern/pattern_audio.js b/assets/js/creations/pattern/pattern_audio.js
index 3eca9f6..7135637 100644
--- a/assets/js/creations/pattern/pattern_audio.js
+++ b/assets/js/creations/pattern/pattern_audio.js
@@ -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();
}
-}
\ No newline at end of file
+}
diff --git a/assets/js/creations/pattern/pattern_bounce.js b/assets/js/creations/pattern/pattern_bounce.js
new file mode 100644
index 0000000..7634779
--- /dev/null
+++ b/assets/js/creations/pattern/pattern_bounce.js
@@ -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}`);
+ }
+}
\ No newline at end of file
diff --git a/assets/js/creations/pattern/pattern_state.js b/assets/js/creations/pattern/pattern_state.js
index c584c6d..df65e21 100644
--- a/assets/js/creations/pattern/pattern_state.js
+++ b/assets/js/creations/pattern/pattern_state.js
@@ -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];
}
}
-}
\ No newline at end of file
+}
diff --git a/assets/js/creations/pattern/pattern_ui.js b/assets/js/creations/pattern/pattern_ui.js
index 746e474..caf54f0 100644
--- a/assets/js/creations/pattern/pattern_ui.js
+++ b/assets/js/creations/pattern/pattern_ui.js
@@ -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 = `
-
-
-
-
${trackData.name}
-
-
-
- `;
+ trackLane.innerHTML = `
+
+
+
+
${trackData.name}
+
+
+
+ `;
- 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);
}
\ No newline at end of file
diff --git a/assets/js/creations/recording.js b/assets/js/creations/recording.js
index 83868c7..04a31d6 100644
--- a/assets/js/creations/recording.js
+++ b/assets/js/creations/recording.js
@@ -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
}
/**
diff --git a/assets/js/creations/server/package-lock.json b/assets/js/creations/server/package-lock.json
new file mode 100644
index 0000000..8cffb3b
--- /dev/null
+++ b/assets/js/creations/server/package-lock.json
@@ -0,0 +1,1330 @@
+{
+ "name": "server",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "server",
+ "version": "1.0.0",
+ "license": "ISC",
+ "dependencies": {
+ "express": "^5.1.0",
+ "pino": "^8.21.0",
+ "socket.io": "^4.8.1",
+ "tone": "^15.1.22"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
+ "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@socket.io/component-emitter": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
+ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="
+ },
+ "node_modules/@types/cors": {
+ "version": "2.8.19",
+ "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
+ "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "24.9.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
+ "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
+ "dependencies": {
+ "undici-types": "~7.16.0"
+ }
+ },
+ "node_modules/abort-controller": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+ "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+ "dependencies": {
+ "event-target-shim": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=6.5"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/atomic-sleep": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
+ "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/automation-events": {
+ "version": "7.1.13",
+ "resolved": "https://registry.npmjs.org/automation-events/-/automation-events-7.1.13.tgz",
+ "integrity": "sha512-1Hay5TQPzxsskSqPTH3YXyzE9Iirz82zZDse2vr3+kOR7Sc7om17qIEPsESchlNX0EgKxANwR40i2g/O3GM1Tw==",
+ "dependencies": {
+ "@babel/runtime": "^7.28.4",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=18.2.0"
+ }
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/base64id": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
+ "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
+ "engines": {
+ "node": "^4.5.0 || >= 5.9"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
+ "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^1.0.5",
+ "debug": "^4.4.0",
+ "http-errors": "^2.0.0",
+ "iconv-lite": "^0.6.3",
+ "on-finished": "^2.4.1",
+ "qs": "^6.14.0",
+ "raw-body": "^3.0.0",
+ "type-is": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
+ "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/cors": {
+ "version": "2.8.5",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
+ "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/engine.io": {
+ "version": "6.6.4",
+ "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz",
+ "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==",
+ "dependencies": {
+ "@types/cors": "^2.8.12",
+ "@types/node": ">=10.0.0",
+ "accepts": "~1.3.4",
+ "base64id": "2.0.0",
+ "cookie": "~0.7.2",
+ "cors": "~2.8.5",
+ "debug": "~4.3.1",
+ "engine.io-parser": "~5.2.1",
+ "ws": "~8.17.1"
+ },
+ "engines": {
+ "node": ">=10.2.0"
+ }
+ },
+ "node_modules/engine.io-parser": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
+ "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/engine.io/node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/engine.io/node_modules/debug": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/engine.io/node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/engine.io/node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/engine.io/node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/event-target-shim": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+ "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/events": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
+ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
+ "engines": {
+ "node": ">=0.8.x"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
+ "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.0",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/fast-redact": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz",
+ "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
+ "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
+ "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
+ "dependencies": {
+ "depd": "2.0.0",
+ "inherits": "2.0.4",
+ "setprototypeof": "1.2.0",
+ "statuses": "2.0.1",
+ "toidentifier": "1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/http-errors/node_modules/statuses": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
+ "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
+ "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
+ },
+ "node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-exit-leak-free": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
+ "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
+ "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/pino": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/pino/-/pino-8.21.0.tgz",
+ "integrity": "sha512-ip4qdzjkAyDDZklUaZkcRFb2iA118H9SgRh8yzTkSQK8HilsOJF7rSY8HoW5+I0M46AZgX/pxbprf2vvzQCE0Q==",
+ "dependencies": {
+ "atomic-sleep": "^1.0.0",
+ "fast-redact": "^3.1.1",
+ "on-exit-leak-free": "^2.1.0",
+ "pino-abstract-transport": "^1.2.0",
+ "pino-std-serializers": "^6.0.0",
+ "process-warning": "^3.0.0",
+ "quick-format-unescaped": "^4.0.3",
+ "real-require": "^0.2.0",
+ "safe-stable-stringify": "^2.3.1",
+ "sonic-boom": "^3.7.0",
+ "thread-stream": "^2.6.0"
+ },
+ "bin": {
+ "pino": "bin.js"
+ }
+ },
+ "node_modules/pino-abstract-transport": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-1.2.0.tgz",
+ "integrity": "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==",
+ "dependencies": {
+ "readable-stream": "^4.0.0",
+ "split2": "^4.0.0"
+ }
+ },
+ "node_modules/pino-std-serializers": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-6.2.2.tgz",
+ "integrity": "sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA=="
+ },
+ "node_modules/process": {
+ "version": "0.11.10",
+ "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
+ "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
+ "node_modules/process-warning": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz",
+ "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ=="
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
+ "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/quick-format-unescaped": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
+ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz",
+ "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==",
+ "dependencies": {
+ "bytes": "3.1.2",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.7.0",
+ "unpipe": "1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/raw-body/node_modules/iconv-lite": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
+ "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
+ "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
+ "dependencies": {
+ "abort-controller": "^3.0.0",
+ "buffer": "^6.0.3",
+ "events": "^3.3.0",
+ "process": "^0.11.10",
+ "string_decoder": "^1.3.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/real-require": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
+ "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
+ "engines": {
+ "node": ">= 12.13.0"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/safe-stable-stringify": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
+ "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
+ },
+ "node_modules/send": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
+ "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
+ "dependencies": {
+ "debug": "^4.3.5",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "mime-types": "^3.0.1",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
+ "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/socket.io": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
+ "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
+ "dependencies": {
+ "accepts": "~1.3.4",
+ "base64id": "~2.0.0",
+ "cors": "~2.8.5",
+ "debug": "~4.3.2",
+ "engine.io": "~6.6.0",
+ "socket.io-adapter": "~2.5.2",
+ "socket.io-parser": "~4.2.4"
+ },
+ "engines": {
+ "node": ">=10.2.0"
+ }
+ },
+ "node_modules/socket.io-adapter": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz",
+ "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==",
+ "dependencies": {
+ "debug": "~4.3.4",
+ "ws": "~8.17.1"
+ }
+ },
+ "node_modules/socket.io-adapter/node_modules/debug": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/socket.io-parser": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
+ "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
+ "dependencies": {
+ "@socket.io/component-emitter": "~3.1.0",
+ "debug": "~4.3.1"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/socket.io-parser/node_modules/debug": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/socket.io/node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/socket.io/node_modules/debug": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/socket.io/node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/socket.io/node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/socket.io/node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/sonic-boom": {
+ "version": "3.8.1",
+ "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-3.8.1.tgz",
+ "integrity": "sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==",
+ "dependencies": {
+ "atomic-sleep": "^1.0.0"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/standardized-audio-context": {
+ "version": "25.3.77",
+ "resolved": "https://registry.npmjs.org/standardized-audio-context/-/standardized-audio-context-25.3.77.tgz",
+ "integrity": "sha512-Ki9zNz6pKcC5Pi+QPjPyVsD9GwJIJWgryji0XL9cAJXMGyn+dPOf6Qik1AHei0+UNVcc4BOCa0hWLBzlwqsW/A==",
+ "dependencies": {
+ "@babel/runtime": "^7.25.6",
+ "automation-events": "^7.0.9",
+ "tslib": "^2.7.0"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/thread-stream": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.7.0.tgz",
+ "integrity": "sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw==",
+ "dependencies": {
+ "real-require": "^0.2.0"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/tone": {
+ "version": "15.1.22",
+ "resolved": "https://registry.npmjs.org/tone/-/tone-15.1.22.tgz",
+ "integrity": "sha512-TCScAGD4sLsama5DjvTUXlLDXSqPealhL64nsdV1hhr6frPWve0DeSo63AKnSJwgfg55fhvxj0iPPRwPN5o0ag==",
+ "dependencies": {
+ "standardized-audio-context": "^25.3.70",
+ "tslib": "^2.3.1"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
+ },
+ "node_modules/type-is": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
+ "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+ "dependencies": {
+ "content-type": "^1.0.5",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
+ },
+ "node_modules/ws": {
+ "version": "8.17.1",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
+ "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/assets/js/creations/server/package.json b/assets/js/creations/server/package.json
new file mode 100644
index 0000000..bd8b8b7
--- /dev/null
+++ b/assets/js/creations/server/package.json
@@ -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"
+ }
+}
diff --git a/assets/js/creations/server/server.js b/assets/js/creations/server/server.js
new file mode 100644
index 0000000..d8e62cf
--- /dev/null
+++ b/assets/js/creations/server/server.js
@@ -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: '' | undefined,
+ audio: { tracks: [], clips: [] },
+ seq: number,
+ tokensSeen: Set // 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}`);
+});
diff --git a/assets/js/creations/socket.js b/assets/js/creations/socket.js
new file mode 100644
index 0000000..ea5b174
--- /dev/null
+++ b/assets/js/creations/socket.js
@@ -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 };
diff --git a/assets/js/creations/state.js b/assets/js/creations/state.js
index 0ec3a24..090d4de 100644
--- a/assets/js/creations/state.js
+++ b/assets/js/creations/state.js
@@ -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,
+ });
}
\ No newline at end of file
diff --git a/assets/js/creations/ui.js b/assets/js/creations/ui.js
index 4533a60..8acc632 100644
--- a/assets/js/creations/ui.js
+++ b/assets/js/creations/ui.js
@@ -153,4 +153,68 @@ export async function showOpenProjectModal() {
export function closeOpenProjectModal() {
const openProjectModal = document.getElementById("open-project-modal");
openProjectModal.classList.remove("visible");
-}
\ No newline at end of file
+}
+
+// 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);
+}
diff --git a/creation.html b/creation.html
index 60c0fd6..d342b57 100644
--- a/creation.html
+++ b/creation.html
@@ -68,7 +68,7 @@
background-color: transparent;
}
-
+