v1.0.13 - botao de reset funcional para limpar as salas

This commit is contained in:
JotaChina 2025-11-15 15:36:39 -03:00
parent 2153a9d50c
commit 8cfa40a42d
13 changed files with 1633 additions and 533 deletions

View File

@ -1,7 +1,15 @@
// js/audio/audio_audio.js // js/audio/audio_audio.js
import { appState } from "../state.js"; import { appState } from "../state.js";
import { updateAudioEditorUI, updatePlayheadVisual, resetPlayheadVisual } from "./audio_ui.js"; import {
import { initializeAudioContext, getAudioContext, getMainGainNode } from "../audio.js"; updateAudioEditorUI,
updatePlayheadVisual,
resetPlayheadVisual,
} from "./audio_ui.js";
import {
initializeAudioContext,
getAudioContext,
getMainGainNode,
} from "../audio.js";
import { getPixelsPerSecond } from "../utils.js"; import { getPixelsPerSecond } from "../utils.js";
// 🔊 ADIÇÃO: usar a MESMA instância do Tone que o projeto usa // 🔊 ADIÇÃO: usar a MESMA instância do Tone que o projeto usa
import * as Tone from "https://esm.sh/tone"; import * as Tone from "https://esm.sh/tone";
@ -41,9 +49,15 @@ function _getBpm() {
const bpmInput = document.getElementById("bpm-input"); const bpmInput = document.getElementById("bpm-input");
return parseFloat(bpmInput.value) || 120; return parseFloat(bpmInput.value) || 120;
} }
function _getSecondsPerBeat() { return 60.0 / _getBpm(); } function _getSecondsPerBeat() {
function _convertBeatToSeconds(beat) { return beat * _getSecondsPerBeat(); } return 60.0 / _getBpm();
function _convertSecondsToBeat(seconds) { return seconds / _getSecondsPerBeat(); } }
function _convertBeatToSeconds(beat) {
return beat * _getSecondsPerBeat();
}
function _convertSecondsToBeat(seconds) {
return seconds / _getSecondsPerBeat();
}
// garante um único contexto — o rawContext do Tone // garante um único contexto — o rawContext do Tone
function _initContext() { function _initContext() {
@ -75,8 +89,14 @@ function _scheduleClip(clip, absolutePlayTime, durationSec) {
if (!toneBuf) return; if (!toneBuf) return;
// cadeia de ganho/pan por clipe (se já tiver no estado, use; aqui garantimos) // 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 gain =
const pan = clip.pannerNode instanceof Tone.Panner ? clip.pannerNode : new Tone.Panner(clip.pan ?? 0); 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) // conecta no destino principal (é um ToneAudioNode)
try { try {
@ -91,19 +111,35 @@ function _scheduleClip(clip, absolutePlayTime, durationSec) {
const player = new Tone.Player(toneBuf).sync().connect(gain); const player = new Tone.Player(toneBuf).sync().connect(gain);
// aplica pitch como rate (semitons → rate) // aplica pitch como rate (semitons → rate)
const rate = (clip.pitch && clip.pitch !== 0) ? Math.pow(2, clip.pitch / 12) : 1; const rate =
clip.pitch && clip.pitch !== 0 ? Math.pow(2, clip.pitch / 12) : 1;
player.playbackRate = rate; player.playbackRate = rate;
// calculamos o "when" no tempo do Transport: // calculamos o "when" no tempo do Transport:
// absolutePlayTime é em audioCtx.currentTime; o "zero" lógico foi quando demos play: // absolutePlayTime é em audioCtx.currentTime; o "zero" lógico foi quando demos play:
// logical = (now - startTime) + seek; => occurrence = (absolutePlayTime - startTime) + seek // logical = (now - startTime) + seek; => occurrence = (absolutePlayTime - startTime) + seek
const occurrenceInTransportSec = (absolutePlayTime - startTime) + (appState.audio.audioEditorSeekTime || 0); const occurrenceInTransportSec =
absolutePlayTime - startTime + (appState.audio.audioEditorSeekTime || 0);
const offset = clip.offsetInSeconds ?? clip.offset ?? 0; const offset = clip.offsetInSeconds ?? clip.offset ?? 0;
const dur = durationSec ?? toneBuf.duration; const dur = durationSec ?? toneBuf.duration;
// agenda // --- INÍCIO DA CORREÇÃO (BUG: RangeError) ---
player.start(occurrenceInTransportSec, offset, dur); // O log de erro (RangeError: Value must be within [0, Infinity])
// indica que um destes valores é um número negativo minúsculo
// (um bug de precisão de ponto flutuante).
// Usamos Math.max(0, ...) para "clampar" os valores e garantir
// que nunca sejam negativos.
const safeOccurrence = Math.max(0, occurrenceInTransportSec);
const safeOffset = Math.max(0, offset);
// Duração pode ser 'undefined', mas se existir, não pode ser negativa
const safeDur =
dur === undefined || dur === null ? undefined : Math.max(0, dur);
// --- FIM DA CORREÇÃO ---
// agenda (agora usando os valores seguros)
player.start(safeOccurrence, safeOffset, safeDur);
const eventId = nextEventId++; const eventId = nextEventId++;
scheduledNodes.set(eventId, { player, clipId: clip.id }); scheduledNodes.set(eventId, { player, clipId: clip.id });
@ -115,8 +151,12 @@ function _scheduleClip(clip, absolutePlayTime, durationSec) {
// quando parar naturalmente, limpamos runtime // quando parar naturalmente, limpamos runtime
player.onstop = () => { player.onstop = () => {
_handleClipEnd(eventId, clip.id); _handleClipEnd(eventId, clip.id);
try { player.unsync(); } catch {} try {
try { player.dispose(); } catch {} player.unsync();
} catch {}
try {
player.dispose();
} catch {}
}; };
} }
@ -125,7 +165,7 @@ function _handleClipEnd(eventId, clipId) {
runtimeClipState.delete(clipId); runtimeClipState.delete(clipId);
if (callbacks.onClipPlayed) { if (callbacks.onClipPlayed) {
const clip = appState.audio.clips.find(c => c.id == clipId); const clip = appState.audio.clips.find((c) => c.id == clipId);
if (clip) callbacks.onClipPlayed(clip); if (clip) callbacks.onClipPlayed(clip);
} }
} }
@ -134,7 +174,8 @@ function _schedulerTick() {
if (!isPlaying || !audioCtx) return; if (!isPlaying || !audioCtx) return;
const now = audioCtx.currentTime; const now = audioCtx.currentTime;
const logicalTime = (now - startTime) + (appState.audio.audioEditorSeekTime || 0); const logicalTime =
now - startTime + (appState.audio.audioEditorSeekTime || 0);
const scheduleWindowStartSec = logicalTime; const scheduleWindowStartSec = logicalTime;
const scheduleWindowEndSec = logicalTime + SCHEDULE_AHEAD_TIME_SEC; const scheduleWindowEndSec = logicalTime + SCHEDULE_AHEAD_TIME_SEC;
@ -145,19 +186,32 @@ function _schedulerTick() {
const clipStartTimeSec = clip.startTimeInSeconds; const clipStartTimeSec = clip.startTimeInSeconds;
const clipDurationSec = clip.durationInSeconds; const clipDurationSec = clip.durationInSeconds;
if (typeof clipStartTimeSec === 'undefined' || typeof clipDurationSec === 'undefined') continue; if (
typeof clipStartTimeSec === "undefined" ||
typeof clipDurationSec === "undefined"
)
continue;
let occurrenceStartTimeSec = clipStartTimeSec; let occurrenceStartTimeSec = clipStartTimeSec;
if (isLoopActive) { if (isLoopActive) {
const loopDuration = loopEndTimeSec - loopStartTimeSec; const loopDuration = loopEndTimeSec - loopStartTimeSec;
if (loopDuration <= 0) continue; if (loopDuration <= 0) continue;
if (occurrenceStartTimeSec < loopStartTimeSec && logicalTime >= loopStartTimeSec) { if (
const offsetFromLoopStart = (occurrenceStartTimeSec - loopStartTimeSec) % loopDuration; occurrenceStartTimeSec < loopStartTimeSec &&
occurrenceStartTimeSec = loopStartTimeSec + (offsetFromLoopStart < 0 ? offsetFromLoopStart + loopDuration : offsetFromLoopStart); logicalTime >= loopStartTimeSec
) {
const offsetFromLoopStart =
(occurrenceStartTimeSec - loopStartTimeSec) % loopDuration;
occurrenceStartTimeSec =
loopStartTimeSec +
(offsetFromLoopStart < 0
? offsetFromLoopStart + loopDuration
: offsetFromLoopStart);
} }
if (occurrenceStartTimeSec < logicalTime) { if (occurrenceStartTimeSec < logicalTime) {
const loopsMissed = Math.floor((logicalTime - occurrenceStartTimeSec) / loopDuration) + 1; const loopsMissed =
Math.floor((logicalTime - occurrenceStartTimeSec) / loopDuration) + 1;
occurrenceStartTimeSec += loopsMissed * loopDuration; occurrenceStartTimeSec += loopsMissed * loopDuration;
} }
} }
@ -166,7 +220,9 @@ function _schedulerTick() {
occurrenceStartTimeSec >= scheduleWindowStartSec && occurrenceStartTimeSec >= scheduleWindowStartSec &&
occurrenceStartTimeSec < scheduleWindowEndSec occurrenceStartTimeSec < scheduleWindowEndSec
) { ) {
const absolutePlayTime = startTime + (occurrenceStartTimeSec - (appState.audio.audioEditorSeekTime || 0)); const absolutePlayTime =
startTime +
(occurrenceStartTimeSec - (appState.audio.audioEditorSeekTime || 0));
_scheduleClip(clip, absolutePlayTime, clipDurationSec); _scheduleClip(clip, absolutePlayTime, clipDurationSec);
clipRuntime.isScheduled = true; clipRuntime.isScheduled = true;
runtimeClipState.set(clip.id, clipRuntime); runtimeClipState.set(clip.id, clipRuntime);
@ -181,12 +237,14 @@ function _animationLoop() {
return; return;
} }
const now = audioCtx.currentTime; const now = audioCtx.currentTime;
let newLogicalTime = (now - startTime) + (appState.audio.audioEditorSeekTime || 0); let newLogicalTime =
now - startTime + (appState.audio.audioEditorSeekTime || 0);
if (isLoopActive) { if (isLoopActive) {
if (newLogicalTime >= loopEndTimeSec) { if (newLogicalTime >= loopEndTimeSec) {
const loopDuration = loopEndTimeSec - loopStartTimeSec; const loopDuration = loopEndTimeSec - loopStartTimeSec;
newLogicalTime = loopStartTimeSec + ((newLogicalTime - loopStartTimeSec) % loopDuration); newLogicalTime =
loopStartTimeSec + ((newLogicalTime - loopStartTimeSec) % loopDuration);
startTime = now; startTime = now;
appState.audio.audioEditorSeekTime = newLogicalTime; appState.audio.audioEditorSeekTime = newLogicalTime;
} }
@ -196,7 +254,7 @@ function _animationLoop() {
if (!isLoopActive) { if (!isLoopActive) {
let maxTime = 0; let maxTime = 0;
appState.audio.clips.forEach(clip => { appState.audio.clips.forEach((clip) => {
const clipStartTime = clip.startTimeInSeconds || 0; const clipStartTime = clip.startTimeInSeconds || 0;
const clipDuration = clip.durationInSeconds || 0; const clipDuration = clip.durationInSeconds || 0;
const endTime = clipStartTime + clipDuration; const endTime = clipStartTime + clipDuration;
@ -225,20 +283,27 @@ export function updateTransportLoop() {
// parar e descartar players agendados // parar e descartar players agendados
scheduledNodes.forEach(({ player }) => { scheduledNodes.forEach(({ player }) => {
try { player.unsync(); } catch {} try {
try { player.stop(); } catch {} player.unsync();
try { player.dispose(); } catch {} } catch {}
try {
player.stop();
} catch {}
try {
player.dispose();
} catch {}
}); });
scheduledNodes.clear(); scheduledNodes.clear();
} }
export async function startAudioEditorPlayback(seekTime) { // 1. Aceita 'seekTime' como parâmetro export async function startAudioEditorPlayback(seekTime) {
// 1. Aceita 'seekTime' como parâmetro
if (isPlaying) return; if (isPlaying) return;
_initContext(); _initContext();
// garante contexto ativo do Tone (gesto do usuário já ocorreu antes) // garante contexto ativo do Tone (gesto do usuário já ocorreu antes)
await Tone.start(); await Tone.start();
if (audioCtx.state === 'suspended') { if (audioCtx.state === "suspended") {
await audioCtx.resume(); await audioCtx.resume();
} }
@ -253,17 +318,18 @@ export async function startAudioEditorPlayback(seekTime) { // 1. Aceita 'seekTim
// ================================================================= // =================================================================
// 1. Determine o tempo de início: // 1. Determine o tempo de início:
// Use o 'seekTime' recebido (da ação global) se for um número válido (>= 0). let timeToStart =
// Caso contrário, use o tempo de seek local atual. seekTime !== null && seekTime !== undefined && !isNaN(seekTime)
const timeToStart = (seekTime !== null && seekTime !== undefined && !isNaN(seekTime))
? seekTime ? seekTime
: (appState.audio.audioEditorSeekTime || 0); // 👈 Usa sua variável de estado : appState.audio.audioEditorSeekTime || 0; // 👈 Usa sua variável de estado
// 2. Atualize o estado global (para a agulha pular) // 2. Clampa o valor (parte da correção do RangeError)
// Isso garante que o estado local E o Tone estejam sincronizados. timeToStart = Math.max(0, timeToStart);
// 3. Atualize o estado global (para a agulha pular)
appState.audio.audioEditorSeekTime = timeToStart; appState.audio.audioEditorSeekTime = timeToStart;
// 3. Alinhe o Tone.Transport a esse tempo // 4. Alinhe o Tone.Transport a esse tempo
try { try {
Tone.Transport.seconds = timeToStart; // 👈 Usa o tempo sincronizado Tone.Transport.seconds = timeToStart; // 👈 Usa o tempo sincronizado
} catch {} } catch {}
@ -299,32 +365,43 @@ export function stopAudioEditorPlayback(rewind = false) {
console.log(`%cParando Playback... (Rewind: ${rewind})`, "color: #d9534f;"); console.log(`%cParando Playback... (Rewind: ${rewind})`, "color: #d9534f;");
// para o Transport (para Players .sync()) // para o Transport (para Players .sync())
try { Tone.Transport.stop(); } catch {} try {
Tone.Transport.stop();
} catch {}
clearInterval(schedulerIntervalId); clearInterval(schedulerIntervalId);
schedulerIntervalId = null; schedulerIntervalId = null;
cancelAnimationFrame(animationFrameId); cancelAnimationFrame(animationFrameId);
animationFrameId = null; animationFrameId = null;
appState.audio.audioEditorSeekTime = appState.audio.audioEditorLogicalTime || 0; appState.audio.audioEditorSeekTime =
appState.audio.audioEditorLogicalTime || 0;
appState.audio.audioEditorLogicalTime = 0; appState.audio.audioEditorLogicalTime = 0;
if (rewind) { if (rewind) {
appState.audio.audioEditorSeekTime = 0; appState.audio.audioEditorSeekTime = 0;
try { Tone.Transport.seconds = 0; } catch {} try {
Tone.Transport.seconds = 0;
} catch {}
} }
// parar e descartar players agendados // parar e descartar players agendados
scheduledNodes.forEach(({ player }) => { scheduledNodes.forEach(({ player }) => {
try { player.unsync(); } catch {} try {
try { player.stop(); } catch {} player.unsync();
try { player.dispose(); } catch {} } catch {}
try {
player.stop();
} catch {}
try {
player.dispose();
} catch {}
}); });
scheduledNodes.clear(); scheduledNodes.clear();
runtimeClipState.clear(); runtimeClipState.clear();
updateAudioEditorUI(); updateAudioEditorUI();
const playBtn = document.getElementById("audio-editor-play-btn"); 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) { if (rewind) {
resetPlayheadVisual(); resetPlayheadVisual();
@ -344,10 +421,15 @@ export function seekAudioEditor(newTime) {
stopAudioEditorPlayback(false); // Pausa stopAudioEditorPlayback(false); // Pausa
} }
// Clampa o novo tempo
newTime = Math.max(0, newTime);
appState.audio.audioEditorSeekTime = newTime; appState.audio.audioEditorSeekTime = newTime;
appState.audio.audioEditorLogicalTime = newTime; appState.audio.audioEditorLogicalTime = newTime;
try { Tone.Transport.seconds = newTime; } catch {} try {
Tone.Transport.seconds = newTime;
} catch {}
const pixelsPerSecond = getPixelsPerSecond(); const pixelsPerSecond = getPixelsPerSecond();
const newPositionPx = newTime * pixelsPerSecond; const newPositionPx = newTime * pixelsPerSecond;

View File

@ -21,22 +21,27 @@ export let audioState = {
export function getAudioSnapshot() { export function getAudioSnapshot() {
// Se seu estado “oficial” é audioState.* use ele; // Se seu estado “oficial” é audioState.* use ele;
// se for appState.audio.* troque abaixo. // se for appState.audio.* troque abaixo.
const tracks = (audioState.tracks || []).map(t => ({ const tracks = (audioState.tracks || []).map((t) => ({
id: t.id, name: t.name id: t.id,
name: t.name,
})); }));
const clips = (audioState.clips || []).map(c => ({ const clips = (audioState.clips || []).map((c) => ({
id: c.id, id: c.id,
trackId: c.trackId, trackId: c.trackId,
name: c.name, name: c.name,
sourcePath: c.sourcePath || null, // URL do asset (precisa ser acessível) sourcePath: c.sourcePath || null, // URL do asset (precisa ser acessível)
startTimeInSeconds: c.startTimeInSeconds || 0, startTimeInSeconds: c.startTimeInSeconds || 0,
durationInSeconds: c.durationInSeconds || (c.buffer?.duration || 0), durationInSeconds: c.durationInSeconds || c.buffer?.duration || 0,
offset: c.offset || 0, offset: c.offset || 0,
pitch: c.pitch || 0, pitch: c.pitch || 0,
volume: c.volume ?? 1, volume: c.volume ?? 1,
pan: c.pan ?? 0, pan: c.pan ?? 0,
originalDuration: c.originalDuration || (c.buffer?.duration || 0), originalDuration: c.originalDuration || c.buffer?.duration || 0,
// --- NOVA MODIFICAÇÃO (SNAPSHOT) ---
// Também enviamos os dados do pattern se existirem
patternData: c.patternData || null,
})); }));
return { tracks, clips }; return { tracks, clips };
@ -48,21 +53,34 @@ export async function applyAudioSnapshot(snapshot) {
// aplica trilhas (mantém ids/nome) // aplica trilhas (mantém ids/nome)
if (Array.isArray(snapshot.tracks) && snapshot.tracks.length) { if (Array.isArray(snapshot.tracks) && snapshot.tracks.length) {
audioState.tracks = snapshot.tracks.map(t => ({ id: t.id, name: t.name })); audioState.tracks = snapshot.tracks.map((t) => ({
id: t.id,
name: t.name,
}));
} }
// insere clipes usando os MESMOS ids do emissor (idempotente) // insere clipes usando os MESMOS ids do emissor (idempotente)
if (Array.isArray(snapshot.clips)) { if (Array.isArray(snapshot.clips)) {
for (const c of snapshot.clips) { for (const c of snapshot.clips) {
// evita duplicar se já existir (idempotência) // evita duplicar se já existir (idempotência)
if (audioState.clips.some(x => String(x.id) === String(c.id))) continue; 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) // usa a própria função de criação (agora ela aceita id e nome)
// assinatura: addAudioClipToTimeline(samplePath, trackId, start, clipId, name) // --- NOVA MODIFICAÇÃO (SNAPSHOT) ---
addAudioClipToTimeline(c.sourcePath, c.trackId, c.startTimeInSeconds, c.id, c.name); // Passamos o patternData para a função de criação
addAudioClipToTimeline(
c.sourcePath,
c.trackId,
c.startTimeInSeconds,
c.id,
c.name,
c.patternData // <-- Passa os dados do pattern
);
// aplica propriedades adicionais (dur/offset/pitch/vol/pan) no mesmo id // aplica propriedades adicionais (dur/offset/pitch/vol/pan) no mesmo id
const idx = audioState.clips.findIndex(x => String(x.id) === String(c.id)); const idx = audioState.clips.findIndex(
(x) => String(x.id) === String(c.id)
);
if (idx >= 0) { if (idx >= 0) {
const clip = audioState.clips[idx]; const clip = audioState.clips[idx];
clip.durationInSeconds = c.durationInSeconds; clip.durationInSeconds = c.durationInSeconds;
@ -72,6 +90,8 @@ export async function applyAudioSnapshot(snapshot) {
clip.pan = c.pan; clip.pan = c.pan;
clip.originalDuration = c.originalDuration; clip.originalDuration = c.originalDuration;
// (patternData já foi definido durante a criação acima)
// reflete nos nós Tone já criados // reflete nos nós Tone já criados
if (clip.gainNode) clip.gainNode.gain.value = clip.volume ?? 1; if (clip.gainNode) clip.gainNode.gain.value = clip.volume ?? 1;
if (clip.pannerNode) clip.pannerNode.pan.value = clip.pan ?? 0; if (clip.pannerNode) clip.pannerNode.pan.value = clip.pan ?? 0;
@ -83,9 +103,8 @@ export async function applyAudioSnapshot(snapshot) {
renderAudioEditor(); renderAudioEditor();
} }
export function initializeAudioState() { export function initializeAudioState() {
audioState.clips.forEach(clip => { audioState.clips.forEach((clip) => {
if (clip.pannerNode) clip.pannerNode.dispose(); if (clip.pannerNode) clip.pannerNode.dispose();
if (clip.gainNode) clip.gainNode.dispose(); if (clip.gainNode) clip.gainNode.dispose();
}); });
@ -108,13 +127,18 @@ export async function loadAudioForClip(clip) {
// Se já temos um buffer (do bounce ou colagem), não faz fetch // Se já temos um buffer (do bounce ou colagem), não faz fetch
if (clip.buffer) { if (clip.buffer) {
// Garante que as durações estão corretas // Garante que as durações estão corretas
if (clip.originalDuration === 0) clip.originalDuration = clip.buffer.duration; if (clip.originalDuration === 0)
if (clip.durationInSeconds === 0) clip.durationInSeconds = clip.buffer.duration; clip.originalDuration = clip.buffer.duration;
if (clip.durationInSeconds === 0)
clip.durationInSeconds = clip.buffer.duration;
return clip; return clip;
} }
// --- FIM DA ADIÇÃO --- // --- FIM DA ADIÇÃO ---
if (!clip.sourcePath) return clip; if (!clip.sourcePath || clip.sourcePath.startsWith("blob:")) {
// Se não há caminho ou se é um blob, não há nada para buscar.
return clip;
}
const audioCtx = getAudioContext(); const audioCtx = getAudioContext();
if (!audioCtx) { if (!audioCtx) {
@ -124,7 +148,8 @@ export async function loadAudioForClip(clip) {
try { try {
const response = await fetch(clip.sourcePath); const response = await fetch(clip.sourcePath);
if (!response.ok) throw new Error(`Falha ao buscar áudio: ${clip.sourcePath}`); if (!response.ok)
throw new Error(`Falha ao buscar áudio: ${clip.sourcePath}`);
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer); const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
@ -136,7 +161,6 @@ export async function loadAudioForClip(clip) {
} }
// Salva a duração real do buffer para cálculos de stretch // Salva a duração real do buffer para cálculos de stretch
clip.originalDuration = audioBuffer.duration; clip.originalDuration = audioBuffer.duration;
} catch (error) { } catch (error) {
console.error(`Falha ao carregar áudio para o clipe ${clip.name}:`, error); console.error(`Falha ao carregar áudio para o clipe ${clip.name}:`, error);
} }
@ -145,33 +169,72 @@ export async function loadAudioForClip(clip) {
// helper de id (fallback se o emissor não mandar) // helper de id (fallback se o emissor não mandar)
function genClipId() { function genClipId() {
return (crypto?.randomUUID?.() || `clip_${Date.now()}_${Math.floor(Math.random()*1e6)}`); return (
crypto?.randomUUID?.() ||
`clip_${Date.now()}_${Math.floor(Math.random() * 1e6)}`
);
} }
// --- FUNÇÃO MODIFICADA --- // --- FUNÇÃO MODIFICADA ---
// agora aceita clipId e clipName vindos do emissor; mantém compat com chamadas antigas // O 6º argumento (maybeBuffer) agora é tratado para aceitar (Buffer) OU (patternData)
export function addAudioClipToTimeline(samplePath, trackId = 1, startTime = 0, clipIdOrName = null, nameOrBuffer = null, maybeBuffer = null) { export function addAudioClipToTimeline(
// compat: se passaram (filePath, trackId, start, clipId) samplePath,
// mas versões antigas chamavam (filePath, trackId, start) ou (filePath, trackId, start, name, buffer) trackId = 1,
startTime = 0,
clipIdOrName = null,
nameOrBuffer = null,
maybeBuffer = null
) {
let incomingId = null; let incomingId = null;
let clipName = null; let clipName = null;
let existingBuffer = null; let existingBuffer = null;
let incomingPatternData = null; // <-- NOSSO NOVO DADO
// Função helper para checar se é um buffer (para evitar bugs)
// (Um Tone.ToneAudioBuffer tem a prop ._buffer que é o AudioBuffer)
const isBuffer = (obj) =>
obj &&
(obj instanceof AudioBuffer || (obj && obj._buffer instanceof AudioBuffer));
// heurística: se clipIdOrName parece um UUID/clip_ → é id, senão é nome // heurística: se clipIdOrName parece um UUID/clip_ → é id, senão é nome
if (typeof clipIdOrName === 'string' && (clipIdOrName.startsWith('clip_') || clipIdOrName.length >= 16)) { if (
incomingId = clipIdOrName; typeof clipIdOrName === "string" &&
clipName = (typeof nameOrBuffer === 'string') ? nameOrBuffer : null; (clipIdOrName.startsWith("clip_") ||
existingBuffer = maybeBuffer || (nameOrBuffer && typeof nameOrBuffer !== 'string' ? nameOrBuffer : null); clipIdOrName.startsWith("bounced_") ||
clipIdOrName.length >= 16)
) {
incomingId = clipIdOrName; // 4º arg é ID
clipName = typeof nameOrBuffer === "string" ? nameOrBuffer : null; // 5º arg é Nome
// --- INÍCIO DA CORREÇÃO (Passo 3) ---
// O 6º argumento (maybeBuffer) pode ser o buffer OU o patternData.
// O 5º (nameOrBuffer) pode ser o nome OU o buffer.
// 1. Checa se o 6º argumento é o buffer
if (isBuffer(maybeBuffer)) {
existingBuffer = maybeBuffer;
}
// 2. Checa se o 6º argumento é o patternData (array)
else if (Array.isArray(maybeBuffer)) {
incomingPatternData = maybeBuffer;
}
// 3. Se o buffer não veio no 6º, checa se veio no 5º (assinatura antiga)
if (!existingBuffer && isBuffer(nameOrBuffer)) {
existingBuffer = nameOrBuffer;
}
// --- FIM DA CORREÇÃO ---
} else { } else {
// assinatura antiga: 4º arg era nome // assinatura antiga: 4º arg era nome
clipName = (typeof clipIdOrName === 'string') ? clipIdOrName : null; clipName = typeof clipIdOrName === "string" ? clipIdOrName : null;
existingBuffer = (nameOrBuffer && typeof nameOrBuffer !== 'string') ? nameOrBuffer : null; // 5º arg era buffer
existingBuffer = isBuffer(nameOrBuffer) ? nameOrBuffer : null;
} }
const finalId = incomingId || genClipId(); const finalId = incomingId || genClipId();
// idempotência: se o id já existe, não duplica // idempotência: se o id já existe, não duplica
if (audioState.clips.some(c => String(c.id) === String(finalId))) { if (audioState.clips.some((c) => String(c.id) === String(finalId))) {
return; return;
} }
@ -179,7 +242,9 @@ export function addAudioClipToTimeline(samplePath, trackId = 1, startTime = 0, c
id: finalId, id: finalId,
trackId: trackId, trackId: trackId,
sourcePath: samplePath, // Pode ser null se existingBuffer for fornecido sourcePath: samplePath, // Pode ser null se existingBuffer for fornecido
name: clipName || (samplePath ? String(samplePath).split('/').pop() : 'Bounced Clip'), name:
clipName ||
(samplePath ? String(samplePath).split("/").pop() : "Bounced Clip"),
startTimeInSeconds: startTime, startTimeInSeconds: startTime,
offset: 0, offset: 0,
@ -192,15 +257,23 @@ export function addAudioClipToTimeline(samplePath, trackId = 1, startTime = 0, c
buffer: existingBuffer || null, buffer: existingBuffer || null,
player: null, player: null,
// --- INÍCIO DA CORREÇÃO (Passo 3) ---
// A "partitura" é finalmente armazenada no objeto do clipe!
patternData: incomingPatternData || null,
// --- FIM DA CORREÇÃO ---
}; };
// volume linear (01) // volume linear (01)
newClip.gainNode = new Tone.Gain(DEFAULT_VOLUME); newClip.gainNode = new Tone.Gain(DEFAULT_VOLUME);
newClip.pannerNode = new Tone.Panner(DEFAULT_PAN); newClip.pannerNode = new Tone.Panner(DEFAULT_PAN);
// conecta tudo no grafo do Tone (mesmo contexto) // --- INÍCIO DA CORREÇÃO (O BUG ESTÁ AQUI) ---
// Precisamos de ligar os nós do novo clipe à saída principal,
// caso contrário, ele será criado "mudo".
newClip.gainNode.connect(newClip.pannerNode); newClip.gainNode.connect(newClip.pannerNode);
newClip.pannerNode.connect(getMainGainNode()); newClip.pannerNode.connect(getMainGainNode());
// --- FIM DA CORREÇÃO ---
audioState.clips.push(newClip); audioState.clips.push(newClip);
@ -211,18 +284,23 @@ export function addAudioClipToTimeline(samplePath, trackId = 1, startTime = 0, c
} }
export function updateAudioClipProperties(clipId, properties) { export function updateAudioClipProperties(clipId, properties) {
const clip = audioState.clips.find(c => String(c.id) == String(clipId)); const clip = audioState.clips.find((c) => String(c.id) == String(clipId));
if (clip) { if (clip) {
Object.assign(clip, properties); Object.assign(clip, properties);
} }
} }
export function sliceAudioClip(clipId, sliceTimeInTimeline) { export function sliceAudioClip(clipId, sliceTimeInTimeline) {
const originalClip = audioState.clips.find(c => String(c.id) == String(clipId)); const originalClip = audioState.clips.find(
(c) => String(c.id) == String(clipId)
);
if (!originalClip || if (
!originalClip ||
sliceTimeInTimeline <= originalClip.startTimeInSeconds || sliceTimeInTimeline <= originalClip.startTimeInSeconds ||
sliceTimeInTimeline >= (originalClip.startTimeInSeconds + originalClip.durationInSeconds)) { sliceTimeInTimeline >=
originalClip.startTimeInSeconds + originalClip.durationInSeconds
) {
console.warn("Corte inválido: fora dos limites do clipe."); console.warn("Corte inválido: fora dos limites do clipe.");
return; return;
} }
@ -251,7 +329,12 @@ export function sliceAudioClip(clipId, sliceTimeInTimeline) {
gainNode: new Tone.Gain(originalClip.volume), gainNode: new Tone.Gain(originalClip.volume),
pannerNode: new Tone.Panner(originalClip.pan), pannerNode: new Tone.Panner(originalClip.pan),
player: null player: null,
// --- NOVA MODIFICAÇÃO (SLICE) ---
// Se o clip original tinha dados de pattern, o novo clip (parte 2)
// também deve tê-los, pois a referência é a mesma.
patternData: originalClip.patternData || null,
}; };
newClip.gainNode.connect(newClip.pannerNode); newClip.gainNode.connect(newClip.pannerNode);
@ -292,19 +375,29 @@ export function addAudioTrackLane() {
} }
export function removeAudioClip(clipId) { export function removeAudioClip(clipId) {
const clipIndex = audioState.clips.findIndex(c => String(c.id) == String(clipId)); const clipIndex = audioState.clips.findIndex(
(c) => String(c.id) == String(clipId)
);
if (clipIndex === -1) return false; // Retorna false se não encontrou if (clipIndex === -1) return false; // Retorna false se não encontrou
const clip = audioState.clips[clipIndex]; const clip = audioState.clips[clipIndex];
// 1. Limpa os nós de áudio do Tone.js // 1. Limpa os nós de áudio do Tone.js
if (clip.gainNode) { if (clip.gainNode) {
try { clip.gainNode.disconnect(); } catch {} try {
try { clip.gainNode.dispose(); } catch {} clip.gainNode.disconnect();
} catch {}
try {
clip.gainNode.dispose();
} catch {}
} }
if (clip.pannerNode) { if (clip.pannerNode) {
try { clip.pannerNode.disconnect(); } catch {} try {
try { clip.pannerNode.dispose(); } catch {} clip.pannerNode.disconnect();
} catch {}
try {
clip.pannerNode.dispose();
} catch {}
} }
// 2. Remove o clipe do array de estado // 2. Remove o clipe do array de estado

View File

@ -320,7 +320,7 @@ export function renderAudioEditor() {
grid.style.setProperty("--bar-width", `${barWidthPx}px`); grid.style.setProperty("--bar-width", `${barWidthPx}px`);
}); });
// Render Clips (sem alterações) // Render Clips (MODIFICADO)
appState.audio.clips.forEach((clip) => { appState.audio.clips.forEach((clip) => {
const parentGrid = newTrackContainer.querySelector( const parentGrid = newTrackContainer.querySelector(
`.audio-track-lane[data-track-id="${clip.trackId}"] .spectrogram-view-grid` `.audio-track-lane[data-track-id="${clip.trackId}"] .spectrogram-view-grid`
@ -342,8 +342,44 @@ export function renderAudioEditor() {
let pitchStr = let pitchStr =
clip.pitch > 0 ? `+${clip.pitch.toFixed(1)}` : `${clip.pitch.toFixed(1)}`; clip.pitch > 0 ? `+${clip.pitch.toFixed(1)}` : `${clip.pitch.toFixed(1)}`;
if (clip.pitch === 0) pitchStr = ""; if (clip.pitch === 0) pitchStr = "";
// Define o HTML base (sem a visualização de steps ainda)
clipElement.innerHTML = ` <div class="clip-resize-handle left"></div> <span class="clip-name">${clip.name} ${pitchStr}</span> <canvas class="waveform-canvas-clip"></canvas> <div class="clip-resize-handle right"></div> `; clipElement.innerHTML = ` <div class="clip-resize-handle left"></div> <span class="clip-name">${clip.name} ${pitchStr}</span> <canvas class="waveform-canvas-clip"></canvas> <div class="clip-resize-handle right"></div> `;
// --- INÍCIO DA MODIFICAÇÃO (Passo 4: Desenhar Steps) ---
// (Fazemos isso DEPOIS de definir o innerHTML)
// 1. Verifica se este clipe tem os dados da "partitura" (steps)
if (
clip.patternData &&
Array.isArray(clip.patternData) &&
clip.patternData.length > 0
) {
// 2. Adiciona a classe CSS principal (do creation.html)
clipElement.classList.add("pattern-clip");
// 3. Determina o número de steps (do primeiro array de trilha)
// Assumimos que todos têm o mesmo comprimento, pois vieram do mesmo pattern.
const totalSteps = clip.patternData[0]?.length || 0;
if (totalSteps > 0) {
// 4. Chama a nova função (adicionada no final deste arquivo)
// para construir o HTML da visualização
const patternViewEl = createPatternViewElement(
clip.patternData,
totalSteps
);
// 5. Adiciona a visualização ao clipe
// (O CSS .pattern-clip-view o posicionará sobre o canvas)
clipElement.appendChild(patternViewEl);
}
}
// --- FIM DA MODIFICAÇÃO ---
parentGrid.appendChild(clipElement); parentGrid.appendChild(clipElement);
// Renderização do Canvas (Waveform)
if (clip.buffer) { if (clip.buffer) {
const canvas = clipElement.querySelector(".waveform-canvas-clip"); const canvas = clipElement.querySelector(".waveform-canvas-clip");
const canvasWidth = (clip.durationInSeconds || 0) * pixelsPerSecond; const canvasWidth = (clip.durationInSeconds || 0) * pixelsPerSecond;
@ -365,6 +401,8 @@ export function renderAudioEditor() {
); );
} }
} }
// Wheel listener (pitch)
clipElement.addEventListener("wheel", (e) => { clipElement.addEventListener("wheel", (e) => {
e.preventDefault(); e.preventDefault();
const clipToUpdate = appState.audio.clips.find( const clipToUpdate = appState.audio.clips.find(
@ -817,3 +855,57 @@ export function resetPlayheadVisual() {
ph.style.left = "0px"; ph.style.left = "0px";
}); });
} }
// --- INÍCIO DA NOVA FUNÇÃO (Passo 4: A Função de Desenho) ---
// (Adicionada ao final de audio_ui.js)
/**
* Cria o elemento HTML (e seus filhos) para a visualização
* dos steps de um pattern clip.
* * @param {Array<Array<boolean>>} patternData - ex: [[true, false], [true, true]]
* @returns {HTMLElement} Um <div> com a classe 'pattern-clip-view'
*/
function createPatternViewElement(patternData) {
const view = document.createElement("div");
view.className = "pattern-clip-view"; // (do creation.html)
// Filtra trilhas que possam ser vazias ou inválidas no array
const validTracksData = patternData.filter(
(steps) => Array.isArray(steps) && steps.length > 0
);
// Encontra o total de steps (usando a trilha mais longa como referência)
const totalSteps = validTracksData.reduce(
(max, steps) => Math.max(max, steps.length),
0
);
if (totalSteps === 0) return view; // Retorna view vazia se não houver steps
validTracksData.forEach((trackSteps) => {
const row = document.createElement("div");
row.className = "pattern-clip-track-row"; // (do creation.html)
// Calcula a largura de cada step como porcentagem
const stepWidthPercent = (1 / totalSteps) * 100;
for (let i = 0; i < totalSteps; i++) {
// Se o step[i] for true, desenha a nota
if (trackSteps[i] === true) {
const note = document.createElement("div");
note.className = "pattern-step-note"; // (do creation.html)
// Define a posição (left) e a largura (width) em porcentagem
// Isso permite que o clip seja redimensionado (stretch)
// e as notas se ajustem.
note.style.left = `${(i / totalSteps) * 100}%`;
note.style.width = `${stepWidthPercent}%`;
row.appendChild(note);
}
}
view.appendChild(row);
});
return view;
}
// --- FIM DA NOVA FUNÇÃO ---

View File

@ -1,14 +1,65 @@
// js/file.js // js/file.js
import { appState, resetProjectState } from "./state.js"; import { appState, saveStateToSession, resetProjectState } from "./state.js";
import { loadAudioForTrack } from "./pattern/pattern_state.js"; import { loadAudioForTrack } from "./pattern/pattern_state.js";
import { renderAll, getSamplePathMap } from "./ui.js"; import { renderAll, getSamplePathMap } from "./ui.js";
import { DEFAULT_PAN, DEFAULT_VOLUME, NOTE_LENGTH } from "./config.js"; import { DEFAULT_PAN, DEFAULT_VOLUME, NOTE_LENGTH } from "./config.js";
import { initializeAudioContext, getAudioContext, getMainGainNode } from "./audio.js"; import {
initializeAudioContext,
getAudioContext,
getMainGainNode,
} from "./audio.js";
import * as Tone from "https://esm.sh/tone"; import * as Tone from "https://esm.sh/tone";
// --- NOVA IMPORTAÇÃO --- // --- NOVA IMPORTAÇÃO ---
import { sendAction } from "./socket.js"; import { sendAction } from "./socket.js";
// --- NOVA ADIÇÃO ---
// Conteúdo do 'teste.mmp' (projeto em branco)
const BLANK_PROJECT_XML = `<?xml version="1.0"?>
<!DOCTYPE lmms-project>
<lmms-project type="song" version="1.0" creatorversion="1.2.2" creator="LMMS">
<head bpm="140" mastervol="100" timesig_numerator="4" timesig_denominator="4" masterpitch="0"/>
<song>
<trackcontainer width="600" height="300" type="song" visible="1" maximized="0" x="5" y="5" minimized="0">
</trackcontainer>
<timeline lp0pos="0" lp1pos="192" lpstate="0"/>
<controllers/>
</song>
</lmms-project>`;
/**
* Executa um reset completo do estado local do projeto.
* Limpa o backup da sessão, reseta o appState e renderiza a UI.
*/
export function handleLocalProjectReset() {
console.log("Recebido comando de reset. Limpando estado local...");
// 1. Limpa o backup da sessão
if (window.ROOM_NAME) {
try {
sessionStorage.removeItem(`temp_state_${window.ROOM_NAME}`);
console.log("Estado da sessão local limpo.");
} catch (e) {
console.error("Falha ao limpar estado da sessão:", e);
}
}
// 2. Reseta o estado da memória (appState)
// (Isso deve zerar o appState.pattern.tracks, etc)
resetProjectState();
// 3. Reseta a UI global para os padrões
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;
// 4. Renderiza a UI vazia
renderAll(); // Isso deve redesenhar o editor de patterns vazio
console.log("Reset local concluído.");
}
export async function handleFileLoad(file) { export async function handleFileLoad(file) {
let xmlContent = ""; let xmlContent = "";
try { try {
@ -19,7 +70,9 @@ export async function handleFileLoad(file) {
name.toLowerCase().endsWith(".mmp") name.toLowerCase().endsWith(".mmp")
); );
if (!projectFile) if (!projectFile)
throw new Error("Não foi possível encontrar um arquivo .mmp dentro do .mmpz"); throw new Error(
"Não foi possível encontrar um arquivo .mmp dentro do .mmpz"
);
xmlContent = await zip.files[projectFile].async("string"); xmlContent = await zip.files[projectFile].async("string");
} else { } else {
xmlContent = await file.text(); xmlContent = await file.text();
@ -28,8 +81,7 @@ export async function handleFileLoad(file) {
// ANTES: await parseMmpContent(xmlContent); // ANTES: await parseMmpContent(xmlContent);
// DEPOIS: // DEPOIS:
// Envia o XML para o servidor, que o transmitirá para todos (incluindo nós) // Envia o XML para o servidor, que o transmitirá para todos (incluindo nós)
sendAction({ type: 'LOAD_PROJECT', xml: xmlContent }); sendAction({ type: "LOAD_PROJECT", xml: xmlContent });
} catch (error) { } catch (error) {
console.error("Erro ao carregar o projeto:", error); console.error("Erro ao carregar o projeto:", error);
alert(`Erro ao carregar projeto: ${error.message}`); alert(`Erro ao carregar projeto: ${error.message}`);
@ -50,9 +102,8 @@ export async function loadProjectFromServer(fileName) {
// DEPOIS: // DEPOIS:
// Envia o XML para o servidor // Envia o XML para o servidor
sendAction({ type: 'LOAD_PROJECT', xml: xmlContent }); sendAction({ type: "LOAD_PROJECT", xml: xmlContent });
return true; // Retorna true para que o modal de UI feche return true; // Retorna true para que o modal de UI feche
} catch (error) { } catch (error) {
console.error("Erro ao carregar projeto do servidor:", error); console.error("Erro ao carregar projeto do servidor:", error);
console.error(error); console.error(error);
@ -67,6 +118,13 @@ export async function loadProjectFromServer(fileName) {
export async function parseMmpContent(xmlString) { export async function parseMmpContent(xmlString) {
resetProjectState(); resetProjectState();
initializeAudioContext(); initializeAudioContext();
appState.global.justReset = xmlString === BLANK_PROJECT_XML;
// Limpa manualmente a UI de áudio, pois resetProjectState()
// só limpa os *dados* (appState.audio.clips).
const audioContainer = document.getElementById("audio-track-container");
if (audioContainer) {
audioContainer.innerHTML = "";
}
const parser = new DOMParser(); const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlString, "application/xml"); const xmlDoc = parser.parseFromString(xmlString, "application/xml");
@ -76,68 +134,132 @@ export async function parseMmpContent(xmlString) {
const head = xmlDoc.querySelector("head"); const head = xmlDoc.querySelector("head");
if (head) { if (head) {
document.getElementById("bpm-input").value = head.getAttribute("bpm") || 140; document.getElementById("bpm-input").value =
document.getElementById("compasso-a-input").value = head.getAttribute("timesig_numerator") || 4; head.getAttribute("bpm") || 140;
document.getElementById("compasso-b-input").value = head.getAttribute("timesig_denominator") || 4; document.getElementById("compasso-a-input").value =
head.getAttribute("timesig_numerator") || 4;
document.getElementById("compasso-b-input").value =
head.getAttribute("timesig_denominator") || 4;
} }
const allBBTrackNodes = Array.from(xmlDoc.querySelectorAll('song > trackcontainer[type="song"] > track[type="1"]')); const allBBTrackNodes = Array.from(
xmlDoc.querySelectorAll(
'song > trackcontainer[type="song"] > track[type="1"]'
)
);
if (allBBTrackNodes.length === 0) {
const allBBTrackNodes = Array.from(
xmlDoc.querySelectorAll(
'song > trackcontainer[type="song"] > track[type="1"]'
)
);
if (allBBTrackNodes.length === 0) { if (allBBTrackNodes.length === 0) {
appState.pattern.tracks = []; appState.pattern.tracks = [];
renderAll();
return; // --- INÍCIO DA CORREÇÃO ---
// O resetProjectState() [na linha 105] já limpou o appState.audio.
// No entanto, a UI (DOM) do editor de áudio não foi limpa.
// Vamos forçar a limpeza do container aqui:
const audioContainer = document.getElementById("audio-track-container");
if (audioContainer) {
audioContainer.innerHTML = ""; // Limpa a UI de áudio
}
// --- FIM DA CORREÇÃO ---
renderAll(); //
return; //
}
} }
const sortedBBTrackNameNodes = [...allBBTrackNodes].sort((a, b) => { const sortedBBTrackNameNodes = [...allBBTrackNodes].sort((a, b) => {
const bbtcoA = a.querySelector('bbtco'); const bbtcoA = a.querySelector("bbtco");
const bbtcoB = b.querySelector('bbtco'); const bbtcoB = a.querySelector("bbtco");
const posA = bbtcoA ? parseInt(bbtcoA.getAttribute('pos'), 10) : Infinity; const posA = bbtcoA ? parseInt(bbtcoA.getAttribute("pos"), 10) : Infinity;
const posB = bbtcoB ? parseInt(bbtcoB.getAttribute('pos'), 10) : Infinity; const posB = bbtcoB ? parseInt(bbtcoB.getAttribute("pos"), 10) : Infinity;
return posA - posB; return posA - posB;
}); });
const dataSourceTrack = allBBTrackNodes[0]; // --- INÍCIO DA CORREÇÃO 1: Lendo TODAS as Basslines (Tracks type="1") ---
appState.global.currentBeatBasslineName = dataSourceTrack.getAttribute("name") || "Beat/Bassline"; // O bug anterior era que o código só lia os instrumentos (tracks type="0")
// da PRIMEIRA bassline encontrada (allBBTrackNodes[0]).
// A correção abaixo itera em TODAS as basslines (allBBTrackNodes.forEach)
// e coleta os instrumentos de CADA UMA delas.
const bbTrackContainer = dataSourceTrack.querySelector('bbtrack > trackcontainer'); // Define um nome global (pode usar o da primeira track, se existir)
if (!bbTrackContainer) { appState.global.currentBeatBasslineName =
allBBTrackNodes[0]?.getAttribute("name") || "Beat/Bassline";
// Cria um array para guardar TODOS os instrumentos de TODAS as basslines
const allInstrumentTrackNodes = [];
// Loop em CADA bassline (allBBTrackNodes) em vez de apenas na [0]
allBBTrackNodes.forEach((bbTrackNode) => {
const bbTrackContainer = bbTrackNode.querySelector(
"bbtrack > trackcontainer"
);
if (bbTrackContainer) {
// Encontra os instrumentos (type="0") DENTRO desta bassline
const instrumentTracks =
bbTrackContainer.querySelectorAll('track[type="0"]');
// Adiciona os instrumentos encontrados ao array principal
allInstrumentTrackNodes.push(...Array.from(instrumentTracks));
}
});
// Se não achou NENHUM instrumento em NENHUMA bassline, encerra
if (allInstrumentTrackNodes.length === 0) {
appState.pattern.tracks = []; appState.pattern.tracks = [];
renderAll(); renderAll();
return; return;
} }
// --- FIM DA CORREÇÃO 1 ---
const instrumentTracks = bbTrackContainer.querySelectorAll('track[type="0"]');
const pathMap = getSamplePathMap(); const pathMap = getSamplePathMap();
newTracks = Array.from(instrumentTracks).map(trackNode => { // Agora o map usa o array corrigido (allInstrumentTrackNodes)
newTracks = Array.from(allInstrumentTrackNodes)
.map((trackNode) => {
const instrumentNode = trackNode.querySelector("instrument"); const instrumentNode = trackNode.querySelector("instrument");
const instrumentTrackNode = trackNode.querySelector("instrumenttrack"); const instrumentTrackNode = trackNode.querySelector("instrumenttrack");
if (!instrumentNode || !instrumentTrackNode) return null; if (!instrumentNode || !instrumentTrackNode) return null;
const trackName = trackNode.getAttribute("name"); const trackName = trackNode.getAttribute("name");
if (instrumentNode.getAttribute("name") === 'tripleoscillator') { if (instrumentNode.getAttribute("name") === "tripleoscillator") {
return null; return null;
} }
const allPatternsNodeList = trackNode.querySelectorAll("pattern"); const allPatternsNodeList = trackNode.querySelectorAll("pattern");
const allPatternsArray = Array.from(allPatternsNodeList).sort((a, b) => { const allPatternsArray = Array.from(allPatternsNodeList).sort((a, b) => {
const posA = parseInt(a.getAttribute('pos'), 10) || 0; const posA = parseInt(a.getAttribute("pos"), 10) || 0;
const posB = parseInt(b.getAttribute('pos'), 10) || 0; const posB = parseInt(b.getAttribute("pos"), 10) || 0;
return posB - posA;
// --- CORREÇÃO 2: Ordenação dos Patterns ---
// O bug aqui era `posB - posA`, que invertia a ordem dos patterns
// (o "Pattern 1" recebia as notas do "Pattern 8", etc.)
// `posA - posB` garante a ordem correta (crescente: P1, P2, P3...).
return posA - posB;
}); });
const patterns = sortedBBTrackNameNodes.map((bbTrack, index) => { const patterns = sortedBBTrackNameNodes.map((bbTrack, index) => {
const patternNode = allPatternsArray[index]; const patternNode = allPatternsArray[index];
const bbTrackName = bbTrack.getAttribute("name") || `Pattern ${index + 1}`; const bbTrackName =
bbTrack.getAttribute("name") || `Pattern ${index + 1}`;
if (!patternNode) { if (!patternNode) {
const firstPattern = allPatternsArray[0]; const firstPattern = allPatternsArray[0];
const stepsLength = firstPattern ? parseInt(firstPattern.getAttribute("steps"), 10) || 16 : 16; const stepsLength = firstPattern
return { name: bbTrackName, steps: new Array(stepsLength).fill(false), pos: 0 }; ? parseInt(firstPattern.getAttribute("steps"), 10) || 16
: 16;
return {
name: bbTrackName,
steps: new Array(stepsLength).fill(false),
pos: 0,
};
} }
const patternSteps = parseInt(patternNode.getAttribute("steps"), 10) || 16; const patternSteps =
parseInt(patternNode.getAttribute("steps"), 10) || 16;
const steps = new Array(patternSteps).fill(false); const steps = new Array(patternSteps).fill(false);
const ticksPerStep = 12; const ticksPerStep = 12;
@ -152,11 +274,11 @@ export async function parseMmpContent(xmlString) {
return { return {
name: bbTrackName, name: bbTrackName,
steps: steps, steps: steps,
pos: parseInt(patternNode.getAttribute("pos"), 10) || 0 pos: parseInt(patternNode.getAttribute("pos"), 10) || 0,
}; };
}); });
const hasNotes = patterns.some(p => p.steps.includes(true)); const hasNotes = patterns.some((p) => p.steps.includes(true));
if (!hasNotes) return null; if (!hasNotes) return null;
const afpNode = instrumentNode.querySelector("audiofileprocessor"); const afpNode = instrumentNode.querySelector("audiofileprocessor");
@ -168,8 +290,8 @@ export async function parseMmpContent(xmlString) {
finalSamplePath = pathMap[filename]; finalSamplePath = pathMap[filename];
} else { } else {
let cleanSrc = sampleSrc; let cleanSrc = sampleSrc;
if (cleanSrc.startsWith('samples/')) { if (cleanSrc.startsWith("samples/")) {
cleanSrc = cleanSrc.substring('samples/'.length); cleanSrc = cleanSrc.substring("samples/".length);
} }
finalSamplePath = `src/samples/${cleanSrc}`; finalSamplePath = `src/samples/${cleanSrc}`;
} }
@ -177,24 +299,37 @@ export async function parseMmpContent(xmlString) {
const volFromFile = parseFloat(instrumentTrackNode.getAttribute("vol")); const volFromFile = parseFloat(instrumentTrackNode.getAttribute("vol"));
const panFromFile = parseFloat(instrumentTrackNode.getAttribute("pan")); const panFromFile = parseFloat(instrumentTrackNode.getAttribute("pan"));
const firstPatternWithNotesIndex = patterns.findIndex(p => p.steps.includes(true)); const firstPatternWithNotesIndex = patterns.findIndex((p) =>
p.steps.includes(true)
);
return { return {
id: Date.now() + Math.random(), id: Date.now() + Math.random(),
name: trackName, name: trackName,
samplePath: finalSamplePath, samplePath: finalSamplePath,
patterns: patterns, patterns: patterns,
activePatternIndex: firstPatternWithNotesIndex !== -1 ? firstPatternWithNotesIndex : 0,
// --- INÍCIO DA CORREÇÃO ---
// ANTES:
// activePatternIndex:
// firstPatternWithNotesIndex !== -1 ? firstPatternWithNotesIndex : 0, //
// DEPOIS (force o Padrão 1):
activePatternIndex: 0,
// --- FIM DA CORREÇÃO ---
volume: !isNaN(volFromFile) ? volFromFile / 100 : DEFAULT_VOLUME, volume: !isNaN(volFromFile) ? volFromFile / 100 : DEFAULT_VOLUME,
pan: !isNaN(panFromFile) ? panFromFile / 100 : DEFAULT_PAN, pan: !isNaN(panFromFile) ? panFromFile / 100 : DEFAULT_PAN,
instrumentName: instrumentNode.getAttribute("name"), instrumentName: instrumentNode.getAttribute("name"),
instrumentXml: instrumentNode.innerHTML, instrumentXml: instrumentNode.innerHTML,
}; };
}).filter(track => track !== null); })
.filter((track) => track !== null);
let isFirstTrackWithNotes = true; let isFirstTrackWithNotes = true;
newTracks.forEach(track => { newTracks.forEach((track) => {
// --- INÍCIO DA CORREÇÃO --- // --- INÍCIO DA CORREÇÃO ---
// (Esta parte já existia no seu arquivo, mantida)
// Agora usando Volume em dB (Opção B) // Agora usando Volume em dB (Opção B)
track.volumeNode = new Tone.Volume(Tone.gainToDb(track.volume)); track.volumeNode = new Tone.Volume(Tone.gainToDb(track.volume));
track.pannerNode = new Tone.Panner(track.pan); track.pannerNode = new Tone.Panner(track.pan);
@ -211,14 +346,17 @@ export async function parseMmpContent(xmlString) {
const firstPatternSteps = activePattern.steps.length; const firstPatternSteps = activePattern.steps.length;
const stepsPerBar = 16; const stepsPerBar = 16;
const requiredBars = Math.ceil(firstPatternSteps / stepsPerBar); const requiredBars = Math.ceil(firstPatternSteps / stepsPerBar);
document.getElementById("bars-input").value = requiredBars > 0 ? requiredBars : 1; document.getElementById("bars-input").value =
requiredBars > 0 ? requiredBars : 1;
isFirstTrackWithNotes = false; isFirstTrackWithNotes = false;
} }
} }
}); });
try { try {
const trackLoadPromises = newTracks.map(track => loadAudioForTrack(track)); const trackLoadPromises = newTracks.map((track) =>
loadAudioForTrack(track)
);
await Promise.all(trackLoadPromises); await Promise.all(trackLoadPromises);
} catch (error) { } catch (error) {
console.error("Ocorreu um erro ao carregar os áudios do projeto:", error); console.error("Ocorreu um erro ao carregar os áudios do projeto:", error);
@ -226,13 +364,87 @@ export async function parseMmpContent(xmlString) {
appState.pattern.tracks = newTracks; appState.pattern.tracks = newTracks;
appState.pattern.activeTrackId = appState.pattern.tracks[0]?.id || null; appState.pattern.activeTrackId = appState.pattern.tracks[0]?.id || null;
// --- INÍCIO DA CORREÇÃO ---
// Define o estado global para também ser o Padrão 1 (índice 0)
appState.pattern.activePatternIndex = 0;
// --- FIM DA CORREÇÃO ---
// força atualização total da UI e dos editores de pattern // --- A MÁGICA DO F5 (Versão 2.0 - Corrigida) ---
await Promise.resolve(); // garante que os tracks estejam no estado try {
const roomName = window.ROOM_NAME || "default_room";
const tempStateJSON = sessionStorage.getItem(`temp_state_${roomName}`);
if (tempStateJSON) {
console.log("Restaurando estado temporário da sessão (pós-F5)...");
const tempState = JSON.parse(tempStateJSON);
// NÃO FAÇA: appState.pattern = tempState.pattern; (Isso apaga os Tone.js nodes)
// EM VEZ DISSO, FAÇA O "MERGE" (MESCLAGEM):
// 1. Mescla os 'tracks'
// Itera nos tracks "vivos" (com nós de áudio) que acabamos de criar
appState.pattern.tracks.forEach((liveTrack) => {
// Encontra o track salvo correspondente
const savedTrack = tempState.pattern.tracks.find(
(t) => t.id === liveTrack.id
);
if (savedTrack) {
// Copia os dados do 'savedTrack' para o 'liveTrack'
liveTrack.name = savedTrack.name;
liveTrack.patterns = savedTrack.patterns;
liveTrack.activePatternIndex = savedTrack.activePatternIndex;
liveTrack.volume = savedTrack.volume;
liveTrack.pan = savedTrack.pan;
// ATUALIZA OS NÓS DO TONE.JS com os valores salvos!
if (liveTrack.volumeNode) {
liveTrack.volumeNode.volume.value = Tone.gainToDb(
savedTrack.volume
);
}
if (liveTrack.pannerNode) {
liveTrack.pannerNode.pan.value = savedTrack.pan;
}
}
});
// 2. Remove tracks "vivos" que não existem mais no estado salvo
// (Ex: se o usuário deletou um track antes de dar F5)
appState.pattern.tracks = appState.pattern.tracks.filter((liveTrack) =>
tempState.pattern.tracks.some((t) => t.id === liveTrack.id)
);
// 3. Restaura valores globais da UI
document.getElementById("bpm-input").value = tempState.global.bpm;
document.getElementById("compasso-a-input").value =
tempState.global.compassoA;
document.getElementById("compasso-b-input").value =
tempState.global.compassoB;
document.getElementById("bars-input").value = tempState.global.bars;
// 4. Restaura o ID do track ativo
appState.pattern.activeTrackId = tempState.pattern.activeTrackId;
console.log("Estado da sessão restaurado com sucesso.");
}
} catch (e) {
console.error(
"Erro ao restaurar estado da sessão (pode estar corrompido)",
e
);
if (window.ROOM_NAME) {
sessionStorage.removeItem(`temp_state_${window.ROOM_NAME}`);
}
}
// --- FIM DA MÁGICA (V2.0) ---
// Agora sim, renderiza com o estado CORRIGIDO E MESCLADO
await Promise.resolve();
renderAll(); renderAll();
console.log('[UI] Projeto renderizado após parseMmpContent'); console.log("[UI] Projeto renderizado após parseMmpContent");
} }
export function generateMmpFile() { export function generateMmpFile() {
@ -250,7 +462,9 @@ function generateXmlFromState() {
// Se não houver XML original, precisamos gerar um novo // Se não houver XML original, precisamos gerar um novo
// Por simplicidade, para este fix, vamos retornar o estado atual do LMMS // Por simplicidade, para este fix, vamos retornar o estado atual do LMMS
// mas o ideal seria gerar o XML completo (como generateNewMmp) // mas o ideal seria gerar o XML completo (como generateNewMmp)
console.warn("Não há XML original para modificar. Usando a base atual do appState."); console.warn(
"Não há XML original para modificar. Usando a base atual do appState."
);
// No seu caso, use o conteúdo de generateNewMmp() // No seu caso, use o conteúdo de generateNewMmp()
return ""; return "";
} }
@ -260,14 +474,29 @@ function generateXmlFromState() {
if (head) { if (head) {
head.setAttribute("bpm", document.getElementById("bpm-input").value); head.setAttribute("bpm", document.getElementById("bpm-input").value);
head.setAttribute("num_bars", document.getElementById("bars-input").value); head.setAttribute("num_bars", document.getElementById("bars-input").value);
head.setAttribute("timesig_numerator", document.getElementById("compasso-a-input").value); head.setAttribute(
head.setAttribute("timesig_denominator", document.getElementById("compasso-b-input").value); "timesig_numerator",
document.getElementById("compasso-a-input").value
);
head.setAttribute(
"timesig_denominator",
document.getElementById("compasso-b-input").value
);
} }
const bbTrackContainer = xmlDoc.querySelector('track[type="1"] > bbtrack > trackcontainer'); const bbTrackContainer = xmlDoc.querySelector(
'track[type="1"] > bbtrack > trackcontainer'
);
if (bbTrackContainer) { if (bbTrackContainer) {
bbTrackContainer.querySelectorAll('track[type="0"]').forEach(node => node.remove()); bbTrackContainer
const tracksXml = appState.pattern.tracks.map((track) => createTrackXml(track)).join(""); .querySelectorAll('track[type="0"]')
const tempDoc = new DOMParser().parseFromString(`<root>${tracksXml}</root>`, "application/xml"); .forEach((node) => node.remove());
const tracksXml = appState.pattern.tracks
.map((track) => createTrackXml(track))
.join("");
const tempDoc = new DOMParser().parseFromString(
`<root>${tracksXml}</root>`,
"application/xml"
);
Array.from(tempDoc.documentElement.children).forEach((newTrackNode) => { Array.from(tempDoc.documentElement.children).forEach((newTrackNode) => {
bbTrackContainer.appendChild(newTrackNode); bbTrackContainer.appendChild(newTrackNode);
}); });
@ -282,15 +511,16 @@ function generateXmlFromState() {
* Deve ser chamado APÓS alterações significativas no padrão (steps, tracks). * Deve ser chamado APÓS alterações significativas no padrão (steps, tracks).
*/ */
export function syncPatternStateToServer() { export function syncPatternStateToServer() {
if (!window.ROOM_NAME) return; // Não faz nada se não estiver em sala if (!window.ROOM_NAME) return;
const currentXml = generateXmlFromState(); const currentXml = generateXmlFromState();
// NOTA: Usamos um novo tipo de ação para não confundir com o carregamento de arquivo
sendAction({ sendAction({
type: 'SYNC_PATTERN_STATE', type: "SYNC_PATTERN_STATE",
xml: currentXml xml: currentXml,
}); });
// Salva o estado localmente também!
saveStateToSession(); // <-- ADICIONE ISSO
} }
function createTrackXml(track) { function createTrackXml(track) {
@ -298,18 +528,22 @@ function createTrackXml(track) {
const ticksPerStep = 12; const ticksPerStep = 12;
const lmmsVolume = Math.round(track.volume * 100); const lmmsVolume = Math.round(track.volume * 100);
const lmmsPan = Math.round(track.pan * 100); const lmmsPan = Math.round(track.pan * 100);
const patternsXml = track.patterns.map(pattern => { const patternsXml = track.patterns
const patternNotes = pattern.steps.map((isActive, index) => { .map((pattern) => {
const patternNotes = pattern.steps
.map((isActive, index) => {
if (isActive) { if (isActive) {
const notePos = Math.round(index * ticksPerStep); const notePos = Math.round(index * ticksPerStep);
return `<note vol="100" len="${NOTE_LENGTH}" pos="${notePos}" pan="0" key="57"/>`; return `<note vol="100" len="${NOTE_LENGTH}" pos="${notePos}" pan="0" key="57"/>`;
} }
return ""; return "";
}).join("\n "); })
.join("\n ");
return `<pattern type="0" pos="${pattern.pos}" muted="0" steps="${pattern.steps.length}" name="${pattern.name}"> return `<pattern type="0" pos="${pattern.pos}" muted="0" steps="${pattern.steps.length}" name="${pattern.name}">
${patternNotes} ${patternNotes}
</pattern>`; </pattern>`;
}).join('\n '); })
.join("\n ");
return ` return `
<track type="0" solo="0" muted="0" name="${track.name}"> <track type="0" solo="0" muted="0" name="${track.name}">
<instrumenttrack vol="${lmmsVolume}" pitch="0" fxch="0" pitchrange="1" basenote="57" usemasterpitch="1" pan="${lmmsPan}"> <instrumenttrack vol="${lmmsVolume}" pitch="0" fxch="0" pitchrange="1" basenote="57" usemasterpitch="1" pan="${lmmsPan}">
@ -329,14 +563,29 @@ function modifyAndSaveExistingMmp() {
if (head) { if (head) {
head.setAttribute("bpm", document.getElementById("bpm-input").value); head.setAttribute("bpm", document.getElementById("bpm-input").value);
head.setAttribute("num_bars", document.getElementById("bars-input").value); head.setAttribute("num_bars", document.getElementById("bars-input").value);
head.setAttribute("timesig_numerator", document.getElementById("compasso-a-input").value); head.setAttribute(
head.setAttribute("timesig_denominator", document.getElementById("compasso-b-input").value); "timesig_numerator",
document.getElementById("compasso-a-input").value
);
head.setAttribute(
"timesig_denominator",
document.getElementById("compasso-b-input").value
);
} }
const bbTrackContainer = xmlDoc.querySelector('track[type="1"] > bbtrack > trackcontainer'); const bbTrackContainer = xmlDoc.querySelector(
'track[type="1"] > bbtrack > trackcontainer'
);
if (bbTrackContainer) { if (bbTrackContainer) {
bbTrackContainer.querySelectorAll('track[type="0"]').forEach(node => node.remove()); bbTrackContainer
const tracksXml = appState.pattern.tracks.map((track) => createTrackXml(track)).join(""); .querySelectorAll('track[type="0"]')
const tempDoc = new DOMParser().parseFromString(`<root>${tracksXml}</root>`, "application/xml"); .forEach((node) => node.remove());
const tracksXml = appState.pattern.tracks
.map((track) => createTrackXml(track))
.join("");
const tempDoc = new DOMParser().parseFromString(
`<root>${tracksXml}</root>`,
"application/xml"
);
Array.from(tempDoc.documentElement.children).forEach((newTrackNode) => { Array.from(tempDoc.documentElement.children).forEach((newTrackNode) => {
bbTrackContainer.appendChild(newTrackNode); bbTrackContainer.appendChild(newTrackNode);
}); });
@ -351,7 +600,9 @@ function generateNewMmp() {
const sig_num = document.getElementById("compasso-a-input").value; const sig_num = document.getElementById("compasso-a-input").value;
const sig_den = document.getElementById("compasso-b-input").value; const sig_den = document.getElementById("compasso-b-input").value;
const num_bars = document.getElementById("bars-input").value; const num_bars = document.getElementById("bars-input").value;
const tracksXml = appState.pattern.tracks.map((track) => createTrackXml(track)).join(""); const tracksXml = appState.pattern.tracks
.map((track) => createTrackXml(track))
.join("");
const mmpContent = `<?xml version="1.0"?> const mmpContent = `<?xml version="1.0"?>
<!DOCTYPE lmms-project> <!DOCTYPE lmms-project>
<lmms-project version="1.0" type="song" creator="MMPCreator" creatorversion="1.0"> <lmms-project version="1.0" type="song" creator="MMPCreator" creatorversion="1.0">
@ -391,3 +642,5 @@ function downloadFile(content, fileName) {
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
export { BLANK_PROJECT_XML };

View File

@ -1,12 +1,12 @@
// js/main.js (ESM com import absoluto de socket.js + ROOM_NAME local) // js/main.js (ESM com import absoluto de socket.js + ROOM_NAME local)
import { appState, resetProjectState } from "./state.js"; import { appState } from "./state.js";
import { import {
updateTransportLoop, updateTransportLoop,
restartAudioEditorIfPlaying, restartAudioEditorIfPlaying,
} from "./audio/audio_audio.js"; } from "./audio/audio_audio.js";
import { initializeAudioContext } from "./audio.js"; import { initializeAudioContext } from "./audio.js";
import { handleFileLoad, generateMmpFile } from "./file.js"; import { handleFileLoad, generateMmpFile, BLANK_PROJECT_XML } from "./file.js";
import { import {
renderAll, renderAll,
loadAndRenderSampleBrowser, loadAndRenderSampleBrowser,
@ -16,12 +16,15 @@ import {
import { renderAudioEditor } from "./audio/audio_ui.js"; import { renderAudioEditor } from "./audio/audio_ui.js";
import { adjustValue, enforceNumericInput } from "./utils.js"; import { adjustValue, enforceNumericInput } from "./utils.js";
import { ZOOM_LEVELS } from "./config.js"; import { ZOOM_LEVELS } from "./config.js";
import { loadProjectFromServer } from "./file.js" import { loadProjectFromServer } from "./file.js";
// ⚠️ IMPORT ABSOLUTO para evitar 404/text/html quando a página estiver em /creation/ ou fora dela. // ⚠️ 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: // Ajuste o prefixo abaixo para o caminho real onde seus assets vivem no servidor:
import { sendAction, joinRoom, setUserName } from "./socket.js"; import { sendAction, joinRoom, setUserName } from "./socket.js";
import { renderActivePatternToBlob } from "./pattern/pattern_audio.js"; // <-- ADICIONE ESTA LINHA
import { showToast } from "./ui.js";
// Descobre a sala pela URL (local ao main.js) e expõe no window para debug // 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"); const ROOM_NAME = new URLSearchParams(window.location.search).get("room");
window.ROOM_NAME = ROOM_NAME; window.ROOM_NAME = ROOM_NAME;
@ -34,7 +37,8 @@ if (PROJECT_NAME) {
// do arquivo dentro da pasta 'mmp/' (ex: 'nome-do-projeto.mmp'). // do arquivo dentro da pasta 'mmp/' (ex: 'nome-do-projeto.mmp').
console.log(`[MAIN] Carregando projeto do servidor: ${PROJECT_NAME}`); console.log(`[MAIN] Carregando projeto do servidor: ${PROJECT_NAME}`);
// Adicione a extensão se ela não estiver no link // Adicione a extensão se ela não estiver no link
const filename = PROJECT_NAME.endsWith('.mmp') || PROJECT_NAME.endsWith('.mmpz') const filename =
PROJECT_NAME.endsWith(".mmp") || PROJECT_NAME.endsWith(".mmpz")
? PROJECT_NAME ? PROJECT_NAME
: `${PROJECT_NAME}.mmp`; : `${PROJECT_NAME}.mmp`;
@ -106,6 +110,96 @@ document.addEventListener("DOMContentLoaded", () => {
const zoomOutBtn = document.getElementById("zoom-out-btn"); const zoomOutBtn = document.getElementById("zoom-out-btn");
const deleteClipBtn = document.getElementById("delete-clip"); const deleteClipBtn = document.getElementById("delete-clip");
//envia pattern pro editor de áudio
const bouncePatternBtn = document.getElementById(
"send-pattern-to-playlist-btn"
);
bouncePatternBtn?.addEventListener("click", async () => {
// 1. Verifica se existe uma pista de áudio para onde enviar
const targetTrackId = appState.audio.tracks[0]?.id;
if (!targetTrackId) {
showToast(
"Crie uma Pista de Áudio (no editor de amostras) primeiro!",
"error"
);
return;
}
showToast("Renderizando pattern...", "info");
try {
// 2. Chama a função de renderização que criamos
const audioBlob = await renderActivePatternToBlob();
// 3. Cria uma URL local para o áudio renderizado
const audioUrl = URL.createObjectURL(audioBlob);
// --- INÍCIO DA NOVA MODIFICAÇÃO (Visualização de Steps) ---
// 4. Pega o índice do pattern que foi renderizado
const activePatternIndex =
appState.pattern.tracks[0]?.activePatternIndex || 0;
// 5. Coleta os dados de steps de CADA trilha para esse pattern
const patternData = appState.pattern.tracks.map((track) => {
const pattern = track.patterns[activePatternIndex];
// Retorna o array de steps, ou um array vazio se não houver
return pattern && pattern.steps ? pattern.steps : [];
});
// --- FIM DA NOVA MODIFICAÇÃO ---
// 6. Prepara o nome e ID
const patternName =
appState.pattern.tracks[0]?.patterns[activePatternIndex]?.name ||
"Pattern";
const clipName = `${patternName} (Bounced).wav`;
const clipId = `bounced_${Date.now()}`;
// 7. Envia a ação (a lógica ADD_AUDIO_CLIP já sabe como carregar o áudio)
sendAction({
type: "ADD_AUDIO_CLIP",
filePath: audioUrl, // O player de áudio sabe ler essa URL 'blob:'
trackId: targetTrackId, // Envia para a primeira pista de áudio
startTimeInSeconds: 0, // Coloca no início da timeline
clipId: clipId,
name: clipName,
patternData: patternData, // <-- AQUI ESTÁ A "PARTITURA"
});
showToast("Pattern enviada para a Pista de Áudio!", "success");
} catch (error) {
console.error("Erro ao renderizar pattern:", error);
showToast("Erro ao renderizar pattern", "error");
}
});
//Seleção de pattern
const globalPatternSelector = document.getElementById(
"global-pattern-selector"
);
// Adiciona o novo "ouvinte" de evento para o seletor de pattern
globalPatternSelector?.addEventListener("change", () => {
// Pega o novo índice (ex: 0, 1, 2...) do seletor
const newPatternIndex = parseInt(globalPatternSelector.value, 10);
// --- CORREÇÃO DE LÓGICA (não precisamos mais do activeTrackId) ---
// A ação agora é global e afeta TODAS as tracks.
if (isNaN(newPatternIndex)) {
console.warn("Não é possível trocar pattern: índice inválido.");
return;
}
// Envia a ação para todos (incluindo você)
sendAction({
type: "SET_ACTIVE_PATTERN",
patternIndex: newPatternIndex,
});
});
// ================================================================= // =================================================================
// 👇 INÍCIO DA CORREÇÃO (Botão de Sincronia - Agora envia Ação) // 👇 INÍCIO DA CORREÇÃO (Botão de Sincronia - Agora envia Ação)
// ================================================================= // =================================================================
@ -193,7 +287,7 @@ document.addEventListener("DOMContentLoaded", () => {
) )
) )
return; return;
sendAction({ type: "RESET_PROJECT" }); sendAction({ type: "LOAD_PROJECT", xml: BLANK_PROJECT_XML });
}); });
addBarBtn?.addEventListener("click", () => { addBarBtn?.addEventListener("click", () => {
@ -399,7 +493,7 @@ document.addEventListener("DOMContentLoaded", () => {
alert( alert(
`Você já está na sala: ${currentParams.get( `Você já está na sala: ${currentParams.get(
"room" "room"
)}\n\nCopie o link da barra de endereços para convidar.` )}\n\Copie o link da barra de endereços para convidar.`
); );
return; return;
} }

View File

@ -6,13 +6,17 @@ import { highlightStep } from "./pattern_ui.js";
import { getTotalSteps } from "../utils.js"; import { getTotalSteps } from "../utils.js";
import { initializeAudioContext } from "../audio.js"; import { initializeAudioContext } from "../audio.js";
const timerDisplay = document.getElementById('timer-display'); const timerDisplay = document.getElementById("timer-display");
function formatTime(milliseconds) { function formatTime(milliseconds) {
const totalSeconds = Math.floor(milliseconds / 1000); const totalSeconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0'); const minutes = Math.floor(totalSeconds / 60)
const seconds = (totalSeconds % 60).toString().padStart(2, '0'); .toString()
const centiseconds = Math.floor((milliseconds % 1000) / 10).toString().padStart(2, '0'); .padStart(2, "0");
const seconds = (totalSeconds % 60).toString().padStart(2, "0");
const centiseconds = Math.floor((milliseconds % 1000) / 10)
.toString()
.padStart(2, "0");
return `${minutes}:${seconds}:${centiseconds}`; return `${minutes}:${seconds}:${centiseconds}`;
} }
@ -26,7 +30,9 @@ export function playMetronomeSound(isDownbeat) {
// Dispara o sample de uma track, garantindo que o player esteja roteado corretamente // Dispara o sample de uma track, garantindo que o player esteja roteado corretamente
export function playSample(filePath, trackId) { export function playSample(filePath, trackId) {
initializeAudioContext(); initializeAudioContext();
const track = trackId ? appState.pattern.tracks.find((t) => t.id == trackId) : null; const track = trackId
? appState.pattern.tracks.find((t) => t.id == trackId)
: null;
// Se a faixa existe e tem um player pré-carregado // Se a faixa existe e tem um player pré-carregado
if (track && track.player) { if (track && track.player) {
@ -41,7 +47,9 @@ export function playSample(filePath, trackId) {
} }
// Garante conexão: player -> volumeNode (não usar mais gainNode) // Garante conexão: player -> volumeNode (não usar mais gainNode)
try { track.player.disconnect(); } catch {} try {
track.player.disconnect();
} catch {}
if (track.volumeNode) { if (track.volumeNode) {
track.player.connect(track.volumeNode); track.player.connect(track.volumeNode);
} }
@ -49,7 +57,9 @@ export function playSample(filePath, trackId) {
// Dispara imediatamente // Dispara imediatamente
track.player.start(Tone.now()); track.player.start(Tone.now());
} else { } else {
console.warn(`Player da trilha "${track.name}" ainda não carregado — pulando este tick.`); console.warn(
`Player da trilha "${track.name}" ainda não carregado — pulando este tick.`
);
} }
} }
// Fallback para preview de sample sem trackId // Fallback para preview de sample sem trackId
@ -66,7 +76,10 @@ function tick() {
} }
const totalSteps = getTotalSteps(); const totalSteps = getTotalSteps();
const lastStepIndex = appState.global.currentStep === 0 ? totalSteps - 1 : appState.global.currentStep - 1; const lastStepIndex =
appState.global.currentStep === 0
? totalSteps - 1
: appState.global.currentStep - 1;
highlightStep(lastStepIndex, false); highlightStep(lastStepIndex, false);
const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120; const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120;
@ -78,10 +91,13 @@ function tick() {
// Metrônomo // Metrônomo
if (appState.global.metronomeEnabled) { if (appState.global.metronomeEnabled) {
const noteValue = parseInt(document.getElementById("compasso-b-input").value, 10) || 4; const noteValue =
parseInt(document.getElementById("compasso-b-input").value, 10) || 4;
const stepsPerBeat = 16 / noteValue; const stepsPerBeat = 16 / noteValue;
if (appState.global.currentStep % stepsPerBeat === 0) { if (appState.global.currentStep % stepsPerBeat === 0) {
playMetronomeSound(appState.global.currentStep % (stepsPerBeat * 4) === 0); playMetronomeSound(
appState.global.currentStep % (stepsPerBeat * 4) === 0
);
} }
} }
@ -92,9 +108,11 @@ function tick() {
// IMPORTANTE: usar o pattern ativo da PRÓPRIA TRILHA // IMPORTANTE: usar o pattern ativo da PRÓPRIA TRILHA
const activePattern = track.patterns[track.activePatternIndex]; const activePattern = track.patterns[track.activePatternIndex];
if (activePattern && if (
activePattern &&
activePattern.steps[appState.global.currentStep] && activePattern.steps[appState.global.currentStep] &&
track.samplePath) { track.samplePath
) {
playSample(track.samplePath, track.id); playSample(track.samplePath, track.id);
} }
}); });
@ -115,7 +133,8 @@ export function startPlayback() {
Tone.Transport.bpm.value = bpm; Tone.Transport.bpm.value = bpm;
const stepInterval = (60 * 1000) / (bpm * 4); const stepInterval = (60 * 1000) / (bpm * 4);
if (appState.global.playbackIntervalId) clearInterval(appState.global.playbackIntervalId); if (appState.global.playbackIntervalId)
clearInterval(appState.global.playbackIntervalId);
appState.global.isPlaying = true; appState.global.isPlaying = true;
const playBtn = document.getElementById("play-btn"); const playBtn = document.getElementById("play-btn");
@ -135,9 +154,11 @@ export function stopPlayback() {
appState.global.playbackIntervalId = null; appState.global.playbackIntervalId = null;
appState.global.isPlaying = false; appState.global.isPlaying = false;
document.querySelectorAll('.step.playing').forEach(s => s.classList.remove('playing')); document
.querySelectorAll(".step.playing")
.forEach((s) => s.classList.remove("playing"));
appState.global.currentStep = 0; appState.global.currentStep = 0;
if (timerDisplay) timerDisplay.textContent = '00:00:00'; if (timerDisplay) timerDisplay.textContent = "00:00:00";
const playBtn = document.getElementById("play-btn"); const playBtn = document.getElementById("play-btn");
if (playBtn) { if (playBtn) {
@ -147,10 +168,13 @@ export function stopPlayback() {
} }
export function rewindPlayback() { export function rewindPlayback() {
const lastStep = appState.global.currentStep > 0 ? appState.global.currentStep - 1 : getTotalSteps() - 1; const lastStep =
appState.global.currentStep > 0
? appState.global.currentStep - 1
: getTotalSteps() - 1;
appState.global.currentStep = 0; appState.global.currentStep = 0;
if (!appState.global.isPlaying) { if (!appState.global.isPlaying) {
if (timerDisplay) timerDisplay.textContent = '00:00:00'; if (timerDisplay) timerDisplay.textContent = "00:00:00";
highlightStep(lastStep, false); highlightStep(lastStep, false);
} }
} }
@ -164,3 +188,165 @@ export function togglePlayback() {
startPlayback(); startPlayback();
} }
} }
// =========================================================================
// FUNÇÃO CORRIGIDA v3: Renderizar o Pattern atual para um Blob de Áudio
// =========================================================================
export async function renderActivePatternToBlob() {
initializeAudioContext(); // Garante que o contexto de áudio principal existe
// 1. Obter configs atuais
const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120;
const totalSteps = getTotalSteps();
const stepInterval = 60 / (bpm * 4); // Duração de 1 step (em segundos)
const duration = totalSteps * stepInterval; // Duração total em segundos
// 2. Descobrir qual pattern está ativo (assume que todos estão no mesmo)
const activePatternIndex =
appState.pattern.tracks[0]?.activePatternIndex || 0;
// 3. Renderizar offline usando Tone.Offline
const buffer = await Tone.Offline(async () => {
// ----------------------------------------------------
// Contexto de Áudio OFFLINE
// ----------------------------------------------------
const masterGain = new Tone.Gain().toDestination();
// --- INÍCIO DA CORREÇÃO (Lógica de Polifonia) ---
// 1. Criamos as 'Parts'.
const offlineTracksParts = appState.pattern.tracks
.map((track) => {
const pattern = track.patterns[activePatternIndex];
// Verificação crucial: Precisamos do 'track.buffer' (áudio carregado)
if (!pattern || !track.buffer || !pattern.steps.includes(true)) {
return null; // Pula trilha se não tiver áudio ou notas
}
// Obtém o buffer de áudio (que já está carregado)
const trackBuffer = track.buffer;
// Cria a cadeia de áudio (Volume/Pan) para esta *trilha*
const panner = new Tone.Panner(track.pan).connect(masterGain);
const volume = new Tone.Volume(Tone.gainToDb(track.volume)).connect(
panner
);
// Cria a lista de eventos (tempos em que as notas devem tocar)
const events = [];
pattern.steps.forEach((isActive, stepIndex) => {
if (isActive) {
const time = stepIndex * stepInterval;
events.push(time);
}
});
// Cria a Tone.Part
const part = new Tone.Part((time) => {
// *** ESTA É A CORREÇÃO CRÍTICA ***
// Para cada nota (cada 'time' na lista de 'events'),
// nós criamos um PLAYER "ONE-SHOT" (descartável).
// Isso permite que vários sons da mesma trilha
// se sobreponham (polifonia).
new Tone.Player(trackBuffer) // Usa o buffer carregado
.connect(volume) // Conecta na cadeia de áudio (Volume->Pan->Master)
.start(time); // Toca no tempo agendado
}, events); // Passa a lista de tempos [0, 0.25, 0.5, ...]
return part; // Retorna a Part (que sabe quando disparar)
})
.filter((t) => t !== null); // Remove trilhas nulas
// 2. Como estamos usando buffers já carregados,
// não precisamos esperar (remover 'await Tone.loaded()')
// 3. Agenda todas as 'Parts' para começar
offlineTracksParts.forEach((part) => {
part.start(0);
});
// --- FIM DA CORREÇÃO ---
// Define o BPM do transporte offline
Tone.Transport.bpm.value = bpm;
// Inicia o transporte (para a renderização)
Tone.Transport.start();
// ----------------------------------------------------
}, duration); // Duração total da renderização
// 5. Converte o AudioBuffer resultante em um Blob (arquivo .wav)
const blob = bufferToWave(buffer);
return blob;
}
// =========================================================================
// FUNÇÃO UTILITÁRIA: Converte AudioBuffer para Blob WAV
// (Mantenha esta função como está)
// =========================================================================
function bufferToWave(abuffer) {
let numOfChan = abuffer.numberOfChannels;
let length = abuffer.length * numOfChan * 2 + 44;
let buffer = new ArrayBuffer(length);
let view = new DataView(buffer);
let channels = [],
i,
sample;
let offset = 0;
let pos = 0;
// setAll e setString são helpers
function setAll(data) {
for (i = 0; i < data.length; i++) {
view.setUint8(pos + i, data[i]);
}
pos += data.length;
}
function setString(s) {
setAll(s.split("").map((c) => c.charCodeAt(0)));
}
// Cabeçalho WAV
setString("RIFF");
view.setUint32(pos, length - 8, true);
pos += 4;
setString("WAVE");
setString("fmt ");
view.setUint32(pos, 16, true);
pos += 4; // Sub-chunk size
view.setUint16(pos, 1, true);
pos += 2; // Audio format 1
view.setUint16(pos, numOfChan, true);
pos += 2;
view.setUint32(pos, abuffer.sampleRate, true);
pos += 4;
view.setUint32(pos, abuffer.sampleRate * 2 * numOfChan, true);
pos += 4; // Byte rate
view.setUint16(pos, numOfChan * 2, true);
pos += 2; // Block align
view.setUint16(pos, 16, true);
pos += 2; // Bits per sample
setString("data");
view.setUint32(pos, length - 44, true);
pos += 4;
// Pega os dados dos canais
for (i = 0; i < numOfChan; i++) {
channels.push(abuffer.getChannelData(i));
}
// Escreve os dados (intercalando canais)
for (i = 0; i < abuffer.length; i++) {
for (let j = 0; j < numOfChan; j++) {
sample = Math.max(-1, Math.min(1, channels[j][i]));
sample = (0.5 + sample * 32767.5) | 0; // Converte para 16-bit PCM
view.setInt16(pos, sample, true);
pos += 2;
}
}
return new Blob([buffer], { type: "audio/wav" });
}

View File

@ -98,22 +98,28 @@ export function redrawSequencer() {
      sequencerContainer.innerHTML = ""; return;       sequencerContainer.innerHTML = ""; return;
    }     }
// --- CORRIJA ESTAS DUAS LINHAS --- const activePatternIndex = trackData.activePatternIndex;
// ANTES:
// const activePatternIndex = appState.pattern.activePatternIndex;
// const activePattern = trackData.patterns[activePatternIndex];
//
// DEPOIS:
    const activePatternIndex = trackData.activePatternIndex;
    const activePattern = trackData.patterns[activePatternIndex];     const activePattern = trackData.patterns[activePatternIndex];
    if (!activePattern) {     if (!activePattern) {
        sequencerContainer.innerHTML = ""; return;         sequencerContainer.innerHTML = ""; return;
    }     }
// ... resto da função ...
    const patternSteps = activePattern.steps;     const patternSteps = activePattern.steps;
    sequencerContainer.innerHTML = ""; // --- INÍCIO DA CORREÇÃO ---
// Precisamos verificar se 'patternSteps' é um array real.
// Se for 'null' ou 'undefined' (um bug de dados do .mmp),
// o loop 'for' abaixo quebraria ANTES de limpar a UI.
if (!patternSteps || !Array.isArray(patternSteps)) {
// Limpa a UI (remove os steps antigos)
sequencerContainer.innerHTML = "";
// E para a execução desta track, deixando o sequenciador vazio.
return;
}
// --- FIM DA CORREÇÃO ---
    sequencerContainer.innerHTML = ""; // Agora é seguro limpar a UI
    for (let i = 0; i < totalGridSteps; i++) {     for (let i = 0; i < totalGridSteps; i++) {
      const stepWrapper = document.createElement("div");       const stepWrapper = document.createElement("div");
      stepWrapper.className = "step-wrapper";       stepWrapper.className = "step-wrapper";
@ -163,29 +169,6 @@ export function redrawSequencer() {
  });   });
} }
export function updateGlobalPatternSelector() {
    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;
    }
}
export function highlightStep(stepIndex, isActive) { export function highlightStep(stepIndex, isActive) {
  if (stepIndex < 0) return;   if (stepIndex < 0) return;
  document.querySelectorAll(".track-lane").forEach((track) => {   document.querySelectorAll(".track-lane").forEach((track) => {
@ -201,13 +184,26 @@ export function highlightStep(stepIndex, isActive) {
  });   });
} }
// (V7) Função de UI "cirúrgica"
export function updateStepUI(trackIndex, patternIndex, stepIndex, isActive) { export function updateStepUI(trackIndex, patternIndex, stepIndex, isActive) {
if (patternIndex !== appState.pattern.activePatternIndex) { // --- INÍCIO DA CORREÇÃO ---
return; // A lógica antiga (if (patternIndex !== appState.pattern.activePatternIndex))
} // estava errada, pois usava uma variável global.
const trackElement = document.querySelector(`.track-lane[data-track-index="${trackIndex}"]`); const trackElement = document.querySelector(`.track-lane[data-track-index="${trackIndex}"]`);
if (!trackElement) return; if (!trackElement) return;
const trackData = appState.pattern.tracks[trackIndex];
if (!trackData) return;
// A UI só deve ser atualizada cirurgicamente se o pattern clicado
// for o MESMO pattern que está VISÍVEL no sequenciador dessa trilha.
if (patternIndex !== trackData.activePatternIndex) {
// O estado mudou, mas não é o pattern que estamos vendo,
// então não faz nada na UI (mas o estado no appState está correto).
return;
}
// --- FIM DA CORREÇÃO ---
const stepWrapper = trackElement.querySelector( const stepWrapper = trackElement.querySelector(
`.step-sequencer .step-wrapper:nth-child(${stepIndex + 1})` `.step-sequencer .step-wrapper:nth-child(${stepIndex + 1})`
); );
@ -216,3 +212,46 @@ export function updateStepUI(trackIndex, patternIndex, stepIndex, isActive) {
if (!stepElement) return; if (!stepElement) return;
stepElement.classList.toggle("active", isActive); stepElement.classList.toggle("active", isActive);
} }
export function updateGlobalPatternSelector() {
const globalPatternSelector = document.getElementById('global-pattern-selector');
if (!globalPatternSelector) return;
// 1. Encontra a track que está ATIVA no momento
const activeTrackId = appState.pattern.activeTrackId;
const activeTrack = appState.pattern.tracks.find(t => t.id === activeTrackId);
// 2. Usa a track[0] como referência para os NOMES dos patterns
const referenceTrack = appState.pattern.tracks[0];
globalPatternSelector.innerHTML = ''; // Limpa as <options> anteriores
if (referenceTrack && referenceTrack.patterns.length > 0) {
// 3. Popula a lista de <option>
referenceTrack.patterns.forEach((pattern, index) => {
const option = document.createElement('option');
option.value = index;
option.textContent = pattern.name; // ex: "Pattern 1"
globalPatternSelector.appendChild(option);
});
// 4. CORREÇÃO PRINCIPAL: Define o item selecionado no <select>
if (activeTrack) {
// O valor do seletor (ex: "2") deve ser igual ao índice
// do pattern ativo da track selecionada.
globalPatternSelector.value = activeTrack.activePatternIndex || 0;
} else {
globalPatternSelector.value = 0; // Padrão
}
globalPatternSelector.disabled = false;
} else {
// 5. Estado desabilitado (nenhum pattern)
const option = document.createElement('option');
option.textContent = 'Sem patterns';
globalPatternSelector.appendChild(option);
globalPatternSelector.disabled = true;
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -3,7 +3,7 @@
// -------------------relómm------------------------------------------------------ // -------------------relómm------------------------------------------------------
// IMPORTS & STATE // IMPORTS & STATE
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
import { appState, resetProjectState } from "./state.js"; import { appState, saveStateToSession } from "./state.js";
import { import {
addTrackToState, addTrackToState,
removeLastTrackFromState, removeLastTrackFromState,
@ -33,7 +33,12 @@ import {
restartAudioEditorIfPlaying, // 👈 Adicionado restartAudioEditorIfPlaying, // 👈 Adicionado
} from "./audio/audio_audio.js"; } from "./audio/audio_audio.js";
import { parseMmpContent } from "./file.js"; import {
parseMmpContent,
handleLocalProjectReset,
syncPatternStateToServer,
BLANK_PROJECT_XML,
} from "./file.js";
import { renderAll, showToast } from "./ui.js"; // showToast() import { renderAll, showToast } from "./ui.js"; // showToast()
import { updateStepUI, renderPatternEditor } from "./pattern/pattern_ui.js"; import { updateStepUI, renderPatternEditor } from "./pattern/pattern_ui.js";
import { PORT_SOCK } from "./config.js"; import { PORT_SOCK } from "./config.js";
@ -167,7 +172,9 @@ socket.on("connect_error", (err) => {
socket.on("system_update", (data) => { socket.on("system_update", (data) => {
if (data.type === "RELOAD_SAMPLES") { if (data.type === "RELOAD_SAMPLES") {
console.log(`[System Update] Recebida ordem para recarregar samples: ${data.message}`); console.log(
`[System Update] Recebida ordem para recarregar samples: ${data.message}`
);
// Certifique-se de que esta função existe e faz o que é esperado: // Certifique-se de que esta função existe e faz o que é esperado:
// 1. Fetch dos novos manifestos (metadata/samples-manifest.json etc.) // 1. Fetch dos novos manifestos (metadata/samples-manifest.json etc.)
@ -186,14 +193,40 @@ socket.on("load_project_state", async (projectXml) => {
if (isLoadingProject) return; if (isLoadingProject) return;
isLoadingProject = true; isLoadingProject = true;
try { try {
await parseMmpContent(projectXml); await parseMmpContent(projectXml); //
renderAll(); renderAll();
showToast("🎵 Projeto carregado com sucesso", "success"); showToast("🎵 Projeto carregado com sucesso", "success");
// --- INÍCIO DA CORREÇÃO ---
// Mova a lógica de snapshot para AQUI.
// Só peça um snapshot DEPOIS que o XML foi carregado
// e SE o XML (appState) não tiver áudio.
const hasAudio =
(appState.audio?.clips?.length || 0) > 0 ||
(appState.audio?.tracks?.length || 0) > 0;
if (!hasAudio && currentRoom) {
console.log(
"Projeto XML carregado, sem áudio. Pedindo snapshot de áudio..."
);
// O 'sendAction' agora é chamado daqui, não do joinRoom.
sendAction({ type: "AUDIO_SNAPSHOT_REQUEST" });
}
// --- FIM DA CORREÇÃO ---
} catch (e) { } catch (e) {
console.error("Erro ao carregar projeto:", e); console.error("Erro ao carregar projeto:", e);
showToast("❌ Erro ao carregar projeto", "error"); showToast("❌ Erro ao carregar projeto", "error");
} }
isLoadingProject = false; isLoadingProject = false;
// Desativa a flag de reset após um delay.
// Isso dá tempo para a "condição de corrida" (o snapshot sujo)
// ser recebida e ignorada.
setTimeout(() => {
if (appState.global.justReset) {
console.log("Socket: Limpando flag 'justReset'.");
appState.global.justReset = false;
}
}, 250); // 2.5 segundos de "janela de proteção"
// --- FIM DA CORREÇÃO ---
}); });
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@ -206,16 +239,6 @@ export function joinRoom() {
console.log(`Entrando na sala: ${currentRoom} como ${USER_NAME}`); console.log(`Entrando na sala: ${currentRoom} como ${USER_NAME}`);
showToast(`🚪 Entrando na sala ${currentRoom}`, "info"); showToast(`🚪 Entrando na sala ${currentRoom}`, "info");
socket.emit("join_room", { roomName: currentRoom, userName: USER_NAME }); 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 { } else {
console.warn("joinRoom() chamado, mas nenhuma sala encontrada na URL."); console.warn("joinRoom() chamado, mas nenhuma sala encontrada na URL.");
showToast("⚠️ Nenhuma sala encontrada na URL", "warning"); showToast("⚠️ Nenhuma sala encontrada na URL", "warning");
@ -498,6 +521,7 @@ async function handleActionBroadcast(action) {
const seekTime = action.seekTime ?? appState.audio.audioEditorSeekTime; const seekTime = action.seekTime ?? appState.audio.audioEditorSeekTime;
setTimeout(() => startAudioEditorPlayback(seekTime), delayMs); setTimeout(() => startAudioEditorPlayback(seekTime), delayMs);
break; break;
case "STOP_AUDIO_PLAYBACK": case "STOP_AUDIO_PLAYBACK":
setTimeout( setTimeout(
() => stopAudioEditorPlayback(action.rewind || false), () => stopAudioEditorPlayback(action.rewind || false),
@ -564,6 +588,8 @@ async function handleActionBroadcast(action) {
const modeText = newMode === "global" ? "Global 🌐" : "Local 🏠"; const modeText = newMode === "global" ? "Global 🌐" : "Local 🏠";
showToast(`${who} mudou modo para ${modeText}`, "info"); showToast(`${who} mudou modo para ${modeText}`, "info");
} }
// Salva o estado localmente também!
saveStateToSession();
break; break;
} }
// ================================================================= // =================================================================
@ -571,28 +597,80 @@ async function handleActionBroadcast(action) {
// ================================================================= // =================================================================
// Estado Global // Estado Global
case "LOAD_PROJECT": case "LOAD_PROJECT": //
isLoadingProject = true; isLoadingProject = true;
showToast("📂 Carregando...", "info"); showToast("📂 Carregando...", "info");
// Esta parte está CORRETA. Sempre limpa o sessionStorage.
if (window.ROOM_NAME) {
try { try {
await parseMmpContent(action.xml); sessionStorage.removeItem(`temp_state_${window.ROOM_NAME}`);
console.log(
"Socket: Estado da sessão local limpo para LOAD_PROJECT."
);
} catch (e) {
console.error("Socket: Falha ao limpar estado da sessão:", e);
}
}
try {
await parseMmpContent(action.xml); //
renderAll(); renderAll();
showToast("🎶 Projeto sync", "success"); showToast("🎶 Projeto sync", "success");
// --- INÍCIO DA CORREÇÃO ---
// O problema é esta chamada. Ela cria um "eco" (SYNC_PATTERN_STATE)
// que força um segundo 'parseMmpContent' e quebra o estado.
// REMOVA ou COMENTE este bloco:
/*
if (window.ROOM_NAME) {
console.log(
"LOAD_PROJECT: Sincronizando novo estado com o servidor..."
);
syncPatternStateToServer(); //
}
*/
// EM VEZ DISSO, apenas salve o estado na sessão local.
// O `syncPatternStateToServer` agora é responsabilidade do servidor
// (que já recebeu o LOAD_PROJECT) ou de outras ações.
saveStateToSession();
// --- FIM DA CORREÇÃO ---
} catch (e) { } catch (e) {
console.error("Erro LOAD_PROJECT:", e); console.error("Erro LOAD_PROJECT:", e);
showToast("❌ Erro projeto", "error"); showToast("❌ Erro projeto", "error");
} }
isLoadingProject = false; isLoadingProject = false;
break; break;
case "RESET_PROJECT":
resetProjectState(); case "SYNC_PATTERN_STATE": //
document.getElementById("bpm-input").value = 140; // Esta ação agora só será recebida de *outros* usuários,
document.getElementById("bars-input").value = 1; // ou quando o servidor enviar, não de você mesmo.
document.getElementById("compasso-a-input").value = 4; try {
document.getElementById("compasso-b-input").value = 4; await parseMmpContent(action.xml); //
renderAll(); renderAll();
saveStateToSession(); //
console.log("Socket: Pattern state sincronizado.");
} catch (e) {
console.error("Erro SYNC_PATTERN_STATE:", e);
showToast("❌ Erro sync pattern", "error");
}
break;
case "RESET_ROOM":
console.log("Socket: Recebendo comando de RESET_ROOM do servidor."); //
// --- INÍCIO DA CORREÇÃO ---
// Faltava esta linha para definir quem executou a ação
const who = actorOf(action); const who = actorOf(action);
showToast(`🧹 Reset por ${who}`, "warning"); // --- FIM DA CORREÇÃO ---
handleLocalProjectReset(); //
showToast(`🧹 Reset por ${who}`, "warning"); //
// Salva o estado localmente também!
saveStateToSession(); //
break; break;
// Configs // Configs
@ -601,24 +679,35 @@ async function handleActionBroadcast(action) {
renderAll(); renderAll();
const who = actorOf(action); const who = actorOf(action);
showToast(`🕰 ${who} BPM ${action.value}`, "info"); showToast(`🕰 ${who} BPM ${action.value}`, "info");
// Salva o estado localmente também!
saveStateToSession();
break; break;
} }
case "SET_BARS": { case "SET_BARS": {
document.getElementById("bars-input").value = action.value; document.getElementById("bars-input").value = action.value;
renderAll(); renderAll();
const who = actorOf(action); const who = actorOf(action);
showToast(`🕰 ${who} Compasso add`, "info"); showToast(`🕰 ${who} Compasso add`, "info");
// Salva o estado localmente também!
saveStateToSession();
break; break;
} }
case "SET_TIMESIG_A": case "SET_TIMESIG_A":
document.getElementById("compasso-a-input").value = action.value; document.getElementById("compasso-a-input").value = action.value;
renderAll(); renderAll();
showToast("Compasso alt", "info"); showToast("Compasso alt", "info");
// Salva o estado localmente também!
saveStateToSession();
break; break;
case "SET_TIMESIG_B": case "SET_TIMESIG_B":
document.getElementById("compasso-b-input").value = action.value; document.getElementById("compasso-b-input").value = action.value;
renderAll(); renderAll();
showToast("Compasso alt", "info"); showToast("Compasso alt", "info");
// Salva o estado localmente também!
saveStateToSession();
break; break;
// Tracks // Tracks
@ -627,15 +716,21 @@ async function handleActionBroadcast(action) {
renderPatternEditor(); renderPatternEditor();
const who = actorOf(action); const who = actorOf(action);
showToast(`🥁 Faixa add por ${who}`, "info"); showToast(`🥁 Faixa add por ${who}`, "info");
// Salva o estado localmente também!
saveStateToSession();
break; break;
} }
case "REMOVE_LAST_TRACK": { case "REMOVE_LAST_TRACK": {
removeLastTrackFromState(); removeLastTrackFromState();
renderPatternEditor(); renderPatternEditor();
const who = actorOf(action); const who = actorOf(action);
showToast(`❌ Faixa remov. por ${who}`, "warning"); showToast(`❌ Faixa remov. por ${who}`, "warning");
// Salva o estado localmente também!
saveStateToSession();
break; break;
} }
case "ADD_AUDIO_LANE": { case "ADD_AUDIO_LANE": {
const id = action.trackId; const id = action.trackId;
if (!id) { if (!id) {
@ -654,14 +749,19 @@ async function handleActionBroadcast(action) {
renderAll(); renderAll();
const who = actorOf(action); const who = actorOf(action);
showToast(`🎧 Pista add por ${who}`, "info"); showToast(`🎧 Pista add por ${who}`, "info");
// Salva o estado localmente também!
saveStateToSession();
break; break;
} }
case "REMOVE_AUDIO_CLIP": case "REMOVE_AUDIO_CLIP":
if (removeAudioClip(action.clipId)) { if (removeAudioClip(action.clipId)) {
appState.global.selectedClipId = null; appState.global.selectedClipId = null;
renderAll(); renderAll();
const who = actorOf(action); const who = actorOf(action);
showToast(`🎚️ Clip remov. por ${who}`, "info"); showToast(`🎚️ Clip remov. por ${who}`, "info");
// Salva o estado localmente também!
saveStateToSession();
} }
break; break;
@ -687,6 +787,8 @@ async function handleActionBroadcast(action) {
const who = actorOf(action); const who = actorOf(action);
const v = isActive ? "+" : "-"; const v = isActive ? "+" : "-";
showToast(`🎯 ${who} ${v} nota ${ti + 1}.${pi + 1}.${si + 1}`, "info"); showToast(`🎯 ${who} ${v} nota ${ti + 1}.${pi + 1}.${si + 1}`, "info");
// Salva o estado localmente também!
saveStateToSession();
break; break;
} }
@ -707,6 +809,35 @@ async function handleActionBroadcast(action) {
showToast("❌ Erro sample", "error"); showToast("❌ Erro sample", "error");
} }
} }
// Salva o estado localmente também!
saveStateToSession();
break;
}
case "SET_ACTIVE_PATTERN": {
// índice que veio do seletor global
const { patternIndex } = action;
// O correto é iterar em TODAS as tracks e definir o
// novo índice de pattern para CADA UMA delas.
appState.pattern.tracks.forEach((track) => {
track.activePatternIndex = patternIndex;
});
// Mostra o toast (só uma vez)
if (!isFromSelf) {
const who = actorOf(action);
showToast(`🔄 ${who} mudou para Pattern ${patternIndex + 1}`, "info");
}
// O renderAll() vai redesenhar tudo.
// O redrawSequencer() (que é chamado pelo renderAll)
// vai ler o 'track.activePatternIndex' (agora atualizado)
// para TODAS as tracks e desenhar o pattern correto.
renderAll();
// Salva o estado localmente também!
saveStateToSession();
break; break;
} }
@ -730,10 +861,17 @@ async function handleActionBroadcast(action) {
} catch (e) { } catch (e) {
console.warn("Erro AUDIO_SNAPSHOT_REQUEST:", e); console.warn("Erro AUDIO_SNAPSHOT_REQUEST:", e);
} }
// Salva o estado localmente também!
saveStateToSession();
break; break;
} }
case "AUDIO_SNAPSHOT": { case "AUDIO_SNAPSHOT": {
if (action.__target && action.__target !== socket.id) break; if (action.__target && action.__target !== socket.id) break;
if (appState.global.justReset) {
console.warn("Socket: Snapshot de áudio ignorado (justReset=true).");
break; // Ignora o snapshot
}
const hasClips = (appState.audio?.clips?.length || 0) > 0; const hasClips = (appState.audio?.clips?.length || 0) > 0;
if (hasClips) break; if (hasClips) break;
try { try {
@ -745,13 +883,26 @@ async function handleActionBroadcast(action) {
console.error("Erro AUDIO_SNAPSHOT:", e); console.error("Erro AUDIO_SNAPSHOT:", e);
showToast("❌ Erro sync áudio", "error"); showToast("❌ Erro sync áudio", "error");
} }
// Salva o estado localmente também!
saveStateToSession();
break; break;
} }
// Clip Sync // Clip Sync
case "ADD_AUDIO_CLIP": { case "ADD_AUDIO_CLIP": {
try { try {
const { filePath, trackId, startTimeInSeconds, clipId, name } = action; // --- INÍCIO DA MODIFICAÇÃO (Passo 2) ---
// Agora extraímos o 'patternData' que o main.js enviou.
const {
filePath,
trackId,
startTimeInSeconds,
clipId,
name,
patternData,
} = action;
// --- FIM DA MODIFICAÇÃO ---
if ( if (
appState.audio?.clips?.some((c) => String(c.id) === String(clipId)) appState.audio?.clips?.some((c) => String(c.id) === String(clipId))
) { ) {
@ -769,13 +920,20 @@ async function handleActionBroadcast(action) {
name: `Pista ${appState.audio.tracks.length + 1}`, name: `Pista ${appState.audio.tracks.length + 1}`,
}); });
} }
// --- INÍCIO DA MODIFICAÇÃO (Passo 2) ---
// Passamos o 'patternData' como o novo (sexto) argumento
// para a função que armazena o clipe no estado.
addAudioClipToTimeline( addAudioClipToTimeline(
filePath, filePath,
trackId, trackId,
startTimeInSeconds, startTimeInSeconds,
clipId, clipId,
name name,
patternData // <-- AQUI ESTÁ A "PARTITURA"
); );
// --- FIM DA MODIFICAÇÃO ---
renderAll(); renderAll();
const who = actorOf(action); const who = actorOf(action);
const track = appState.audio?.tracks?.find((t) => t.id === trackId); const track = appState.audio?.tracks?.find((t) => t.id === trackId);
@ -790,8 +948,11 @@ async function handleActionBroadcast(action) {
console.error("Erro ADD_AUDIO_CLIP:", e); console.error("Erro ADD_AUDIO_CLIP:", e);
showToast("❌ Erro add clip", "error"); showToast("❌ Erro add clip", "error");
} }
// Salva o estado localmente também!
saveStateToSession();
break; break;
} }
case "UPDATE_AUDIO_CLIP": { case "UPDATE_AUDIO_CLIP": {
try { try {
if (action.props?.__operation === "slice") { if (action.props?.__operation === "slice") {
@ -806,6 +967,8 @@ async function handleActionBroadcast(action) {
console.error("Erro UPDATE_AUDIO_CLIP:", e); console.error("Erro UPDATE_AUDIO_CLIP:", e);
showToast("❌ Erro att clip", "error"); showToast("❌ Erro att clip", "error");
} }
// Salva o estado localmente também!
saveStateToSession();
break; break;
} }

View File

@ -1,71 +1,130 @@
// js/state.js // js/state.js
import { initializePatternState } from './pattern/pattern_state.js'; import { initializePatternState } from "./pattern/pattern_state.js";
import { audioState, initializeAudioState } from './audio/audio_state.js'; import { audioState, initializeAudioState } from "./audio/audio_state.js";
import { DEFAULT_VOLUME, DEFAULT_PAN } from "./config.js"; import { DEFAULT_VOLUME, DEFAULT_PAN } from "./config.js";
// Estado global da aplicação // Estado global da aplicação
const globalState = { const globalState = {
  sliceToolActive: false, sliceToolActive: false,
  isPlaying: false, isPlaying: false,
  isAudioEditorPlaying: false, isAudioEditorPlaying: false,
  playbackIntervalId: null, playbackIntervalId: null,
  currentStep: 0, currentStep: 0,
  metronomeEnabled: false, metronomeEnabled: false,
  originalXmlDoc: null, originalXmlDoc: null,
  currentBeatBasslineName: 'Novo Projeto', currentBeatBasslineName: "Novo Projeto",
  masterVolume: DEFAULT_VOLUME, masterVolume: DEFAULT_VOLUME,
  masterPan: DEFAULT_PAN, masterPan: DEFAULT_PAN,
  zoomLevelIndex: 2, zoomLevelIndex: 2,
  isLoopActive: false, isLoopActive: false,
  loopStartTime: 0, loopStartTime: 0,
  loopEndTime: 8, loopEndTime: 8,
  resizeMode: 'trim', resizeMode: "trim",
  selectedClipId: null, selectedClipId: null,
  isRecording: false, isRecording: false,
  clipboard: null, clipboard: null,
  lastRulerClickTime: 0, lastRulerClickTime: 0,
justReset: false,
}; };
// --- ADICIONE ESTE BLOCO --- // --- ADICIONE ESTE BLOCO ---
// Define o ESTADO INICIAL para o pattern module // Define o ESTADO INICIAL para o pattern module
const patternState = { const patternState = {
    tracks: [], tracks: [],
    activeTrackId: null, activeTrackId: null,
    activePatternIndex: 0, activePatternIndex: 0,
}; };
// --- FIM DA ADIÇÃO --- // --- FIM DA ADIÇÃO ---
// Combina todos os estados em um único objeto namespaced // Combina todos os estados em um único objeto namespaced
export let appState = { export let appState = {
  global: globalState, global: globalState,
  pattern: patternState, // <-- AGORA 'patternState' está definido pattern: patternState, // <-- AGORA 'patternState' está definido
  audio: audioState, audio: audioState,
}; };
// Função para resetar o projeto para o estado inicial
export function resetProjectState() { export function resetProjectState() {
    initializePatternState(); // Esta função vai MODIFICAR appState.pattern console.log("Executando resetProjectState completo...");
    initializeAudioState();
    Object.assign(globalState, { // 1. Reseta o estado global para os padrões
        sliceToolActive: false, Object.assign(appState.global, {
        isPlaying: false, sliceToolActive: false,
        isAudioEditorPlaying: false, isPlaying: false,
        playbackIntervalId: null, isAudioEditorPlaying: false,
        currentStep: 0, playbackIntervalId: null,
        metronomeEnabled: false, currentStep: 0,
        originalXmlDoc: null, metronomeEnabled: false,
        currentBeatBasslineName: 'Novo Projeto', originalXmlDoc: null,
        masterVolume: DEFAULT_VOLUME, currentBeatBasslineName: "Novo Projeto",
        masterPan: DEFAULT_PAN, masterVolume: DEFAULT_VOLUME,
        zoomLevelIndex: 2, masterPan: DEFAULT_PAN,
        isLoopActive: false, zoomLevelIndex: 2,
        loopStartTime: 0, isLoopActive: false,
        loopEndTime: 8, loopStartTime: 0,
        resizeMode: 'trim', loopEndTime: 8,
        selectedClipId: null, resizeMode: "trim",
        isRecording: false, selectedClipId: null,
        clipboard: null, isRecording: false,
        lastRulerClickTime: 0, clipboard: null,
    }); lastRulerClickTime: 0,
justReset: false,
});
// 2. Reseta o estado do pattern
Object.assign(appState.pattern, {
tracks: [],
activeTrackId: null,
activePatternIndex: 0,
});
// 3. Reseta o estado de áudio (A PARTE QUE FALTAVA)
// Isso limpa appState.audio.clips e appState.audio.tracks
initializeAudioState(); //
}
export function saveStateToSession() {
if (!window.ROOM_NAME) return;
// 1. Crie uma versão "limpa" dos tracks, contendo APENAS dados
const cleanTracks = appState.pattern.tracks.map((track) => {
// Este novo objeto só contém dados que o JSON entende
return {
id: track.id,
name: track.name,
samplePath: track.samplePath,
patterns: track.patterns, // Isto é seguro (arrays de arrays/booleanos)
activePatternIndex: track.activePatternIndex,
volume: track.volume, // O número (0-1)
pan: track.pan, // O número (-1 to 1)
instrumentName: track.instrumentName,
instrumentXml: track.instrumentXml,
// *** OBJETOS TONE.JS EXCLUÍDOS ***:
// track.volumeNode, track.pannerNode, track.player
};
});
// 2. Construa o objeto de estado final para salvar
const stateToSave = {
pattern: {
...appState.pattern, // Copia outras propriedades (ex: activeTrackId)
tracks: cleanTracks, // Usa a nossa versão limpa dos tracks
},
global: {
bpm: document.getElementById("bpm-input").value,
compassoA: document.getElementById("compasso-a-input").value,
compassoB: document.getElementById("compasso-b-input").value,
bars: document.getElementById("bars-input").value,
},
};
// 3. Agora o stringify vai funcionar
try {
const roomName = window.ROOM_NAME || "default_room";
sessionStorage.setItem(
`temp_state_${roomName}`,
JSON.stringify(stateToSave)
);
} catch (e) {
console.error("Falha ao salvar estado na sessão:", e);
}
} }