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

View File

@ -5,38 +5,43 @@ import { getMainGainNode, getAudioContext } from "../audio.js";
import * as Tone from "https://esm.sh/tone";
export let audioState = {
tracks: [],
clips: [],
// --- TEMPOS MOVIDOS DO audio_audio.js PARA O ESTADO GLOBAL ---
audioEditorSeekTime: 0,
audioEditorLogicalTime: 0,
// --- FIM DA MUDANÇA ---
audioEditorStartTime: 0,
audioEditorAnimationId: null,
audioEditorPlaybackTime: 0,
isAudioEditorLoopEnabled: false,
tracks: [],
clips: [],
// --- TEMPOS MOVIDOS DO audio_audio.js PARA O ESTADO GLOBAL ---
audioEditorSeekTime: 0,
audioEditorLogicalTime: 0,
// --- FIM DA MUDANÇA ---
audioEditorStartTime: 0,
audioEditorAnimationId: null,
audioEditorPlaybackTime: 0,
isAudioEditorLoopEnabled: false,
};
// ==== SNAPSHOT: exportação do estado atual (tracks + clips) ====
export function getAudioSnapshot() {
// Se seu estado “oficial” é audioState.* use ele;
// se for appState.audio.* troque abaixo.
const tracks = (audioState.tracks || []).map(t => ({
id: t.id, name: t.name
const tracks = (audioState.tracks || []).map((t) => ({
id: t.id,
name: t.name,
}));
const clips = (audioState.clips || []).map(c => ({
const clips = (audioState.clips || []).map((c) => ({
id: c.id,
trackId: c.trackId,
name: c.name,
sourcePath: c.sourcePath || null, // URL do asset (precisa ser acessível)
sourcePath: c.sourcePath || null, // URL do asset (precisa ser acessível)
startTimeInSeconds: c.startTimeInSeconds || 0,
durationInSeconds: c.durationInSeconds || (c.buffer?.duration || 0),
durationInSeconds: c.durationInSeconds || c.buffer?.duration || 0,
offset: c.offset || 0,
pitch: c.pitch || 0,
volume: c.volume ?? 1,
pan: c.pan ?? 0,
originalDuration: c.originalDuration || (c.buffer?.duration || 0),
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 };
@ -48,32 +53,47 @@ export async function applyAudioSnapshot(snapshot) {
// aplica trilhas (mantém ids/nome)
if (Array.isArray(snapshot.tracks) && snapshot.tracks.length) {
audioState.tracks = snapshot.tracks.map(t => ({ id: t.id, name: t.name }));
audioState.tracks = snapshot.tracks.map((t) => ({
id: t.id,
name: t.name,
}));
}
// insere clipes usando os MESMOS ids do emissor (idempotente)
if (Array.isArray(snapshot.clips)) {
for (const c of snapshot.clips) {
// evita duplicar se já existir (idempotência)
if (audioState.clips.some(x => String(x.id) === String(c.id))) continue;
if (audioState.clips.some((x) => String(x.id) === String(c.id))) continue;
// usa a própria função de criação (agora ela aceita id e nome)
// assinatura: addAudioClipToTimeline(samplePath, trackId, start, clipId, name)
addAudioClipToTimeline(c.sourcePath, c.trackId, c.startTimeInSeconds, c.id, c.name);
// --- NOVA MODIFICAÇÃO (SNAPSHOT) ---
// 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
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) {
const clip = audioState.clips[idx];
clip.durationInSeconds = c.durationInSeconds;
clip.offset = c.offset;
clip.pitch = c.pitch;
clip.volume = c.volume;
clip.pan = c.pan;
clip.originalDuration = c.originalDuration;
clip.durationInSeconds = c.durationInSeconds;
clip.offset = c.offset;
clip.pitch = c.pitch;
clip.volume = c.volume;
clip.pan = c.pan;
clip.originalDuration = c.originalDuration;
// (patternData já foi definido durante a criação acima)
// 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;
}
}
@ -83,48 +103,53 @@ export async function applyAudioSnapshot(snapshot) {
renderAudioEditor();
}
export function initializeAudioState() {
audioState.clips.forEach(clip => {
if (clip.pannerNode) clip.pannerNode.dispose();
if (clip.gainNode) clip.gainNode.dispose();
});
Object.assign(audioState, {
tracks: [],
clips: [],
// --- ADICIONADO ---
audioEditorSeekTime: 0,
audioEditorLogicalTime: 0,
// --- FIM ---
audioEditorStartTime: 0,
audioEditorAnimationId: null,
audioEditorPlaybackTime: 0,
isAudioEditorLoopEnabled: false,
});
audioState.clips.forEach((clip) => {
if (clip.pannerNode) clip.pannerNode.dispose();
if (clip.gainNode) clip.gainNode.dispose();
});
Object.assign(audioState, {
tracks: [],
clips: [],
// --- ADICIONADO ---
audioEditorSeekTime: 0,
audioEditorLogicalTime: 0,
// --- FIM ---
audioEditorStartTime: 0,
audioEditorAnimationId: null,
audioEditorPlaybackTime: 0,
isAudioEditorLoopEnabled: false,
});
}
export async function loadAudioForClip(clip) {
// --- ADIÇÃO ---
// Se já temos um buffer (do bounce ou colagem), não faz fetch
if (clip.buffer) {
// Garante que as durações estão corretas
if (clip.originalDuration === 0) clip.originalDuration = clip.buffer.duration;
if (clip.durationInSeconds === 0) clip.durationInSeconds = clip.buffer.duration;
return clip;
// Garante que as durações estão corretas
if (clip.originalDuration === 0)
clip.originalDuration = clip.buffer.duration;
if (clip.durationInSeconds === 0)
clip.durationInSeconds = clip.buffer.duration;
return clip;
}
// --- FIM DA ADIÇÃO ---
if (!clip.sourcePath) return clip;
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();
if (!audioCtx) {
console.error("AudioContext não disponível para carregar áudio.");
return clip;
console.error("AudioContext não disponível para carregar áudio.");
return clip;
}
try {
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 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
clip.originalDuration = audioBuffer.duration;
} catch (error) {
console.error(`Falha ao carregar áudio para o clipe ${clip.name}:`, error);
}
@ -145,123 +169,182 @@ export async function loadAudioForClip(clip) {
// helper de id (fallback se o emissor não mandar)
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 ---
// agora aceita clipId e clipName vindos do emissor; mantém compat com chamadas antigas
export function addAudioClipToTimeline(samplePath, trackId = 1, startTime = 0, clipIdOrName = null, nameOrBuffer = null, maybeBuffer = null) {
// compat: se passaram (filePath, trackId, start, clipId)
// mas versões antigas chamavam (filePath, trackId, start) ou (filePath, trackId, start, name, buffer)
let incomingId = null;
let clipName = null;
let existingBuffer = null;
// 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
) {
let incomingId = null;
let clipName = null;
let existingBuffer = null;
let incomingPatternData = null; // <-- NOSSO NOVO DADO
// heurística: se clipIdOrName parece um UUID/clip_ → é id, senão é nome
if (typeof clipIdOrName === 'string' && (clipIdOrName.startsWith('clip_') || clipIdOrName.length >= 16)) {
incomingId = clipIdOrName;
clipName = (typeof nameOrBuffer === 'string') ? nameOrBuffer : null;
existingBuffer = maybeBuffer || (nameOrBuffer && typeof nameOrBuffer !== 'string' ? nameOrBuffer : null);
} else {
// assinatura antiga: 4º arg era nome
clipName = (typeof clipIdOrName === 'string') ? clipIdOrName : null;
existingBuffer = (nameOrBuffer && typeof nameOrBuffer !== 'string') ? nameOrBuffer : null;
// 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
if (
typeof clipIdOrName === "string" &&
(clipIdOrName.startsWith("clip_") ||
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;
}
const finalId = incomingId || genClipId();
// idempotência: se o id já existe, não duplica
if (audioState.clips.some(c => String(c.id) === String(finalId))) {
return;
// 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 {
// assinatura antiga: 4º arg era nome
clipName = typeof clipIdOrName === "string" ? clipIdOrName : null;
// 5º arg era buffer
existingBuffer = isBuffer(nameOrBuffer) ? nameOrBuffer : null;
}
const newClip = {
id: finalId,
trackId: trackId,
sourcePath: samplePath, // Pode ser null se existingBuffer for fornecido
name: clipName || (samplePath ? String(samplePath).split('/').pop() : 'Bounced Clip'),
const finalId = incomingId || genClipId();
startTimeInSeconds: startTime,
offset: 0,
durationInSeconds: 0,
originalDuration: 0,
// idempotência: se o id já existe, não duplica
if (audioState.clips.some((c) => String(c.id) === String(finalId))) {
return;
}
pitch: 0,
volume: DEFAULT_VOLUME,
pan: DEFAULT_PAN,
const newClip = {
id: finalId,
trackId: trackId,
sourcePath: samplePath, // Pode ser null se existingBuffer for fornecido
name:
clipName ||
(samplePath ? String(samplePath).split("/").pop() : "Bounced Clip"),
buffer: existingBuffer || null,
player: null,
};
startTimeInSeconds: startTime,
offset: 0,
durationInSeconds: 0,
originalDuration: 0,
// volume linear (01)
newClip.gainNode = new Tone.Gain(DEFAULT_VOLUME);
newClip.pannerNode = new Tone.Panner(DEFAULT_PAN);
pitch: 0,
volume: DEFAULT_VOLUME,
pan: DEFAULT_PAN,
// conecta tudo no grafo do Tone (mesmo contexto)
newClip.gainNode.connect(newClip.pannerNode);
newClip.pannerNode.connect(getMainGainNode());
buffer: existingBuffer || null,
player: null,
audioState.clips.push(newClip);
// --- INÍCIO DA CORREÇÃO (Passo 3) ---
// A "partitura" é finalmente armazenada no objeto do clipe!
patternData: incomingPatternData || null,
// --- FIM DA CORREÇÃO ---
};
// loadAudioForClip agora vai lidar com 'existingBuffer'
loadAudioForClip(newClip).then(() => {
renderAudioEditor();
});
// volume linear (01)
newClip.gainNode = new Tone.Gain(DEFAULT_VOLUME);
newClip.pannerNode = new Tone.Panner(DEFAULT_PAN);
// --- 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.pannerNode.connect(getMainGainNode());
// --- FIM DA CORREÇÃO ---
audioState.clips.push(newClip);
// loadAudioForClip agora vai lidar com 'existingBuffer'
loadAudioForClip(newClip).then(() => {
renderAudioEditor();
});
}
export function updateAudioClipProperties(clipId, properties) {
const clip = audioState.clips.find(c => String(c.id) == String(clipId));
if (clip) {
Object.assign(clip, properties);
}
const clip = audioState.clips.find((c) => String(c.id) == String(clipId));
if (clip) {
Object.assign(clip, properties);
}
}
export function sliceAudioClip(clipId, sliceTimeInTimeline) {
const originalClip = audioState.clips.find(c => String(c.id) == String(clipId));
const originalClip = audioState.clips.find(
(c) => String(c.id) == String(clipId)
);
if (!originalClip ||
sliceTimeInTimeline <= originalClip.startTimeInSeconds ||
sliceTimeInTimeline >= (originalClip.startTimeInSeconds + originalClip.durationInSeconds)) {
console.warn("Corte inválido: fora dos limites do clipe.");
return;
}
if (
!originalClip ||
sliceTimeInTimeline <= originalClip.startTimeInSeconds ||
sliceTimeInTimeline >=
originalClip.startTimeInSeconds + originalClip.durationInSeconds
) {
console.warn("Corte inválido: fora dos limites do clipe.");
return;
}
const originalOffset = originalClip.offset || 0;
const cutPointInClip = sliceTimeInTimeline - originalClip.startTimeInSeconds;
const originalOffset = originalClip.offset || 0;
const cutPointInClip = sliceTimeInTimeline - originalClip.startTimeInSeconds;
const newClip = {
id: genClipId(),
trackId: originalClip.trackId,
sourcePath: originalClip.sourcePath,
name: originalClip.name,
buffer: originalClip.buffer,
const newClip = {
id: genClipId(),
trackId: originalClip.trackId,
sourcePath: originalClip.sourcePath,
name: originalClip.name,
buffer: originalClip.buffer,
startTimeInSeconds: sliceTimeInTimeline,
offset: originalOffset + cutPointInClip,
durationInSeconds: originalClip.durationInSeconds - cutPointInClip,
startTimeInSeconds: sliceTimeInTimeline,
offset: originalOffset + cutPointInClip,
durationInSeconds: originalClip.durationInSeconds - cutPointInClip,
// --- CORREÇÃO: Propaga a duração original ---
originalDuration: originalClip.originalDuration,
// --- CORREÇÃO: Propaga a duração original ---
originalDuration: originalClip.originalDuration,
pitch: originalClip.pitch,
volume: originalClip.volume,
pan: originalClip.pan,
pitch: originalClip.pitch,
volume: originalClip.volume,
pan: originalClip.pan,
gainNode: new Tone.Gain(originalClip.volume),
pannerNode: new Tone.Panner(originalClip.pan),
gainNode: new Tone.Gain(originalClip.volume),
pannerNode: new Tone.Panner(originalClip.pan),
player: null
};
player: null,
newClip.gainNode.connect(newClip.pannerNode);
newClip.pannerNode.connect(getMainGainNode());
// --- 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,
};
originalClip.durationInSeconds = cutPointInClip;
newClip.gainNode.connect(newClip.pannerNode);
newClip.pannerNode.connect(getMainGainNode());
audioState.clips.push(newClip);
originalClip.durationInSeconds = cutPointInClip;
console.log("Clipe dividido. Original:", originalClip, "Novo:", newClip);
audioState.clips.push(newClip);
console.log("Clipe dividido. Original:", originalClip, "Novo:", newClip);
}
export function updateClipVolume(clipId, volume) {
@ -270,7 +353,7 @@ export function updateClipVolume(clipId, volume) {
const clampedVolume = Math.max(0, Math.min(1.5, volume));
clip.volume = clampedVolume;
if (clip.gainNode) {
clip.gainNode.gain.value = clampedVolume;
clip.gainNode.gain.value = clampedVolume;
}
}
}
@ -281,35 +364,45 @@ export function updateClipPan(clipId, pan) {
const clampedPan = Math.max(-1, Math.min(1, pan));
clip.pan = clampedPan;
if (clip.pannerNode) {
clip.pannerNode.pan.value = clampedPan;
clip.pannerNode.pan.value = clampedPan;
}
}
}
export function addAudioTrackLane() {
const newTrackName = `Pista de Áudio ${audioState.tracks.length + 1}`;
audioState.tracks.push({ id: Date.now(), name: newTrackName });
const newTrackName = `Pista de Áudio ${audioState.tracks.length + 1}`;
audioState.tracks.push({ id: Date.now(), name: newTrackName });
}
export function removeAudioClip(clipId) {
const clipIndex = audioState.clips.findIndex(c => String(c.id) == String(clipId));
if (clipIndex === -1) return false; // Retorna false se não encontrou
const clipIndex = audioState.clips.findIndex(
(c) => String(c.id) == String(clipId)
);
if (clipIndex === -1) return false; // Retorna false se não encontrou
const clip = audioState.clips[clipIndex];
const clip = audioState.clips[clipIndex];
// 1. Limpa os nós de áudio do Tone.js
if (clip.gainNode) {
try { clip.gainNode.disconnect(); } catch {}
try { clip.gainNode.dispose(); } catch {}
}
if (clip.pannerNode) {
try { clip.pannerNode.disconnect(); } catch {}
try { clip.pannerNode.dispose(); } catch {}
}
// 1. Limpa os nós de áudio do Tone.js
if (clip.gainNode) {
try {
clip.gainNode.disconnect();
} catch {}
try {
clip.gainNode.dispose();
} catch {}
}
if (clip.pannerNode) {
try {
clip.pannerNode.disconnect();
} catch {}
try {
clip.pannerNode.dispose();
} catch {}
}
// 2. Remove o clipe do array de estado
audioState.clips.splice(clipIndex, 1);
// 2. Remove o clipe do array de estado
audioState.clips.splice(clipIndex, 1);
// 3. Retorna true para o chamador (Controller)
return true;
// 3. Retorna true para o chamador (Controller)
return true;
}

View File

@ -320,7 +320,7 @@ export function renderAudioEditor() {
grid.style.setProperty("--bar-width", `${barWidthPx}px`);
});
// Render Clips (sem alterações)
// Render Clips (MODIFICADO)
appState.audio.clips.forEach((clip) => {
const parentGrid = newTrackContainer.querySelector(
`.audio-track-lane[data-track-id="${clip.trackId}"] .spectrogram-view-grid`
@ -342,8 +342,44 @@ export function renderAudioEditor() {
let pitchStr =
clip.pitch > 0 ? `+${clip.pitch.toFixed(1)}` : `${clip.pitch.toFixed(1)}`;
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> `;
// --- 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);
// Renderização do Canvas (Waveform)
if (clip.buffer) {
const canvas = clipElement.querySelector(".waveform-canvas-clip");
const canvasWidth = (clip.durationInSeconds || 0) * pixelsPerSecond;
@ -365,6 +401,8 @@ export function renderAudioEditor() {
);
}
}
// Wheel listener (pitch)
clipElement.addEventListener("wheel", (e) => {
e.preventDefault();
const clipToUpdate = appState.audio.clips.find(
@ -817,3 +855,57 @@ export function resetPlayheadVisual() {
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
import { appState, resetProjectState } from "./state.js";
import { appState, saveStateToSession, resetProjectState } from "./state.js";
import { loadAudioForTrack } from "./pattern/pattern_state.js";
import { renderAll, getSamplePathMap } from "./ui.js";
import { DEFAULT_PAN, DEFAULT_VOLUME, NOTE_LENGTH } from "./config.js";
import { initializeAudioContext, getAudioContext, getMainGainNode } from "./audio.js";
import {
initializeAudioContext,
getAudioContext,
getMainGainNode,
} from "./audio.js";
import * as Tone from "https://esm.sh/tone";
// --- NOVA IMPORTAÇÃO ---
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) {
let xmlContent = "";
try {
@ -19,7 +70,9 @@ export async function handleFileLoad(file) {
name.toLowerCase().endsWith(".mmp")
);
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");
} else {
xmlContent = await file.text();
@ -28,8 +81,7 @@ export async function handleFileLoad(file) {
// ANTES: await parseMmpContent(xmlContent);
// DEPOIS:
// Envia o XML para o servidor, que o transmitirá para todos (incluindo nós)
sendAction({ type: 'LOAD_PROJECT', xml: xmlContent });
sendAction({ type: "LOAD_PROJECT", xml: xmlContent });
} catch (error) {
console.error("Erro ao carregar o projeto:", error);
alert(`Erro ao carregar projeto: ${error.message}`);
@ -50,9 +102,8 @@ export async function loadProjectFromServer(fileName) {
// DEPOIS:
// 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
} catch (error) {
console.error("Erro ao carregar projeto do servidor:", error);
console.error(error);
@ -67,6 +118,13 @@ export async function loadProjectFromServer(fileName) {
export async function parseMmpContent(xmlString) {
resetProjectState();
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 xmlDoc = parser.parseFromString(xmlString, "application/xml");
@ -76,125 +134,202 @@ export async function parseMmpContent(xmlString) {
const head = xmlDoc.querySelector("head");
if (head) {
document.getElementById("bpm-input").value = head.getAttribute("bpm") || 140;
document.getElementById("compasso-a-input").value = head.getAttribute("timesig_numerator") || 4;
document.getElementById("compasso-b-input").value = head.getAttribute("timesig_denominator") || 4;
document.getElementById("bpm-input").value =
head.getAttribute("bpm") || 140;
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) {
appState.pattern.tracks = [];
renderAll();
return;
const allBBTrackNodes = Array.from(
xmlDoc.querySelectorAll(
'song > trackcontainer[type="song"] > track[type="1"]'
)
);
if (allBBTrackNodes.length === 0) {
appState.pattern.tracks = [];
// --- 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 bbtcoA = a.querySelector('bbtco');
const bbtcoB = b.querySelector('bbtco');
const posA = bbtcoA ? parseInt(bbtcoA.getAttribute('pos'), 10) : Infinity;
const posB = bbtcoB ? parseInt(bbtcoB.getAttribute('pos'), 10) : Infinity;
const bbtcoA = a.querySelector("bbtco");
const bbtcoB = a.querySelector("bbtco");
const posA = bbtcoA ? parseInt(bbtcoA.getAttribute("pos"), 10) : Infinity;
const posB = bbtcoB ? parseInt(bbtcoB.getAttribute("pos"), 10) : Infinity;
return posA - posB;
});
const dataSourceTrack = allBBTrackNodes[0];
appState.global.currentBeatBasslineName = dataSourceTrack.getAttribute("name") || "Beat/Bassline";
// --- INÍCIO DA CORREÇÃO 1: Lendo TODAS as Basslines (Tracks type="1") ---
// 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');
if (!bbTrackContainer) {
// Define um nome global (pode usar o da primeira track, se existir)
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 = [];
renderAll();
return;
}
// --- FIM DA CORREÇÃO 1 ---
const instrumentTracks = bbTrackContainer.querySelectorAll('track[type="0"]');
const pathMap = getSamplePathMap();
newTracks = Array.from(instrumentTracks).map(trackNode => {
const instrumentNode = trackNode.querySelector("instrument");
const instrumentTrackNode = trackNode.querySelector("instrumenttrack");
if (!instrumentNode || !instrumentTrackNode) return null;
// Agora o map usa o array corrigido (allInstrumentTrackNodes)
newTracks = Array.from(allInstrumentTrackNodes)
.map((trackNode) => {
const instrumentNode = trackNode.querySelector("instrument");
const instrumentTrackNode = trackNode.querySelector("instrumenttrack");
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;
}
}
const allPatternsNodeList = trackNode.querySelectorAll("pattern");
const allPatternsArray = Array.from(allPatternsNodeList).sort((a, b) => {
const posA = parseInt(a.getAttribute('pos'), 10) || 0;
const posB = parseInt(b.getAttribute('pos'), 10) || 0;
return posB - posA;
});
const allPatternsNodeList = trackNode.querySelectorAll("pattern");
const allPatternsArray = Array.from(allPatternsNodeList).sort((a, b) => {
const posA = parseInt(a.getAttribute("pos"), 10) || 0;
const posB = parseInt(b.getAttribute("pos"), 10) || 0;
const patterns = sortedBBTrackNameNodes.map((bbTrack, index) => {
// --- 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 patternNode = allPatternsArray[index];
const bbTrackName = bbTrack.getAttribute("name") || `Pattern ${index + 1}`;
const bbTrackName =
bbTrack.getAttribute("name") || `Pattern ${index + 1}`;
if (!patternNode) {
const firstPattern = allPatternsArray[0];
const stepsLength = firstPattern ? parseInt(firstPattern.getAttribute("steps"), 10) || 16 : 16;
return { name: bbTrackName, steps: new Array(stepsLength).fill(false), pos: 0 };
const firstPattern = allPatternsArray[0];
const stepsLength = firstPattern
? 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 ticksPerStep = 12;
patternNode.querySelectorAll("note").forEach((noteNode) => {
const noteLocalPos = parseInt(noteNode.getAttribute("pos"), 10);
const stepIndex = Math.round(noteLocalPos / ticksPerStep);
if (stepIndex < patternSteps) {
steps[stepIndex] = true;
}
const noteLocalPos = parseInt(noteNode.getAttribute("pos"), 10);
const stepIndex = Math.round(noteLocalPos / ticksPerStep);
if (stepIndex < patternSteps) {
steps[stepIndex] = true;
}
});
return {
name: bbTrackName,
steps: steps,
pos: parseInt(patternNode.getAttribute("pos"), 10) || 0
name: bbTrackName,
steps: steps,
pos: parseInt(patternNode.getAttribute("pos"), 10) || 0,
};
});
});
const hasNotes = patterns.some(p => p.steps.includes(true));
if (!hasNotes) return null;
const hasNotes = patterns.some((p) => p.steps.includes(true));
if (!hasNotes) return null;
const afpNode = instrumentNode.querySelector("audiofileprocessor");
const sampleSrc = afpNode ? afpNode.getAttribute("src") : null;
let finalSamplePath = null;
if (sampleSrc) {
const afpNode = instrumentNode.querySelector("audiofileprocessor");
const sampleSrc = afpNode ? afpNode.getAttribute("src") : null;
let finalSamplePath = null;
if (sampleSrc) {
const filename = sampleSrc.split("/").pop();
if (pathMap[filename]) {
finalSamplePath = pathMap[filename];
finalSamplePath = pathMap[filename];
} else {
let cleanSrc = sampleSrc;
if (cleanSrc.startsWith('samples/')) {
cleanSrc = cleanSrc.substring('samples/'.length);
}
finalSamplePath = `src/samples/${cleanSrc}`;
let cleanSrc = sampleSrc;
if (cleanSrc.startsWith("samples/")) {
cleanSrc = cleanSrc.substring("samples/".length);
}
finalSamplePath = `src/samples/${cleanSrc}`;
}
}
}
const volFromFile = parseFloat(instrumentTrackNode.getAttribute("vol"));
const panFromFile = parseFloat(instrumentTrackNode.getAttribute("pan"));
const firstPatternWithNotesIndex = patterns.findIndex(p => p.steps.includes(true));
const volFromFile = parseFloat(instrumentTrackNode.getAttribute("vol"));
const panFromFile = parseFloat(instrumentTrackNode.getAttribute("pan"));
const firstPatternWithNotesIndex = patterns.findIndex((p) =>
p.steps.includes(true)
);
return {
id: Date.now() + Math.random(),
name: trackName,
samplePath: finalSamplePath,
patterns: patterns,
activePatternIndex: firstPatternWithNotesIndex !== -1 ? firstPatternWithNotesIndex : 0,
volume: !isNaN(volFromFile) ? volFromFile / 100 : DEFAULT_VOLUME,
pan: !isNaN(panFromFile) ? panFromFile / 100 : DEFAULT_PAN,
instrumentName: instrumentNode.getAttribute("name"),
instrumentXml: instrumentNode.innerHTML,
};
}).filter(track => track !== null);
return {
id: Date.now() + Math.random(),
name: trackName,
samplePath: finalSamplePath,
patterns: patterns,
// --- 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,
pan: !isNaN(panFromFile) ? panFromFile / 100 : DEFAULT_PAN,
instrumentName: instrumentNode.getAttribute("name"),
instrumentXml: instrumentNode.innerHTML,
};
})
.filter((track) => track !== null);
let isFirstTrackWithNotes = true;
newTracks.forEach(track => {
newTracks.forEach((track) => {
// --- INÍCIO DA CORREÇÃO ---
// (Esta parte já existia no seu arquivo, mantida)
// Agora usando Volume em dB (Opção B)
track.volumeNode = new Tone.Volume(Tone.gainToDb(track.volume));
track.pannerNode = new Tone.Panner(track.pan);
@ -211,14 +346,17 @@ export async function parseMmpContent(xmlString) {
const firstPatternSteps = activePattern.steps.length;
const stepsPerBar = 16;
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;
}
}
});
try {
const trackLoadPromises = newTracks.map(track => loadAudioForTrack(track));
const trackLoadPromises = newTracks.map((track) =>
loadAudioForTrack(track)
);
await Promise.all(trackLoadPromises);
} catch (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.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
await Promise.resolve(); // garante que os tracks estejam no estado
// --- A MÁGICA DO F5 (Versão 2.0 - Corrigida) ---
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();
console.log('[UI] Projeto renderizado após parseMmpContent');
console.log("[UI] Projeto renderizado após parseMmpContent");
}
export function generateMmpFile() {
@ -247,12 +459,14 @@ export function generateMmpFile() {
// Copiada de generateMmpFile/modifyAndSaveExistingMmp
function generateXmlFromState() {
if (!appState.global.originalXmlDoc) {
// Se não houver XML original, precisamos gerar um novo
// Por simplicidade, para este fix, vamos retornar o estado atual do LMMS
// 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.");
// No seu caso, use o conteúdo de generateNewMmp()
return "";
// Se não houver XML original, precisamos gerar um novo
// Por simplicidade, para este fix, vamos retornar o estado atual do LMMS
// 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."
);
// No seu caso, use o conteúdo de generateNewMmp()
return "";
}
const xmlDoc = appState.global.originalXmlDoc.cloneNode(true);
@ -260,14 +474,29 @@ function generateXmlFromState() {
if (head) {
head.setAttribute("bpm", document.getElementById("bpm-input").value);
head.setAttribute("num_bars", document.getElementById("bars-input").value);
head.setAttribute("timesig_numerator", document.getElementById("compasso-a-input").value);
head.setAttribute("timesig_denominator", document.getElementById("compasso-b-input").value);
head.setAttribute(
"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) {
bbTrackContainer.querySelectorAll('track[type="0"]').forEach(node => node.remove());
const tracksXml = appState.pattern.tracks.map((track) => createTrackXml(track)).join("");
const tempDoc = new DOMParser().parseFromString(`<root>${tracksXml}</root>`, "application/xml");
bbTrackContainer
.querySelectorAll('track[type="0"]')
.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) => {
bbTrackContainer.appendChild(newTrackNode);
});
@ -282,15 +511,16 @@ function generateXmlFromState() {
* Deve ser chamado APÓS alterações significativas no padrão (steps, tracks).
*/
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();
sendAction({
type: "SYNC_PATTERN_STATE",
xml: currentXml,
});
// NOTA: Usamos um novo tipo de ação para não confundir com o carregamento de arquivo
sendAction({
type: 'SYNC_PATTERN_STATE',
xml: currentXml
});
// Salva o estado localmente também!
saveStateToSession(); // <-- ADICIONE ISSO
}
function createTrackXml(track) {
@ -298,18 +528,22 @@ function createTrackXml(track) {
const ticksPerStep = 12;
const lmmsVolume = Math.round(track.volume * 100);
const lmmsPan = Math.round(track.pan * 100);
const patternsXml = track.patterns.map(pattern => {
const patternNotes = pattern.steps.map((isActive, index) => {
if (isActive) {
const patternsXml = track.patterns
.map((pattern) => {
const patternNotes = pattern.steps
.map((isActive, index) => {
if (isActive) {
const notePos = Math.round(index * ticksPerStep);
return `<note vol="100" len="${NOTE_LENGTH}" pos="${notePos}" pan="0" key="57"/>`;
}
return "";
}).join("\n ");
return `<pattern type="0" pos="${pattern.pos}" muted="0" steps="${pattern.steps.length}" name="${pattern.name}">
}
return "";
})
.join("\n ");
return `<pattern type="0" pos="${pattern.pos}" muted="0" steps="${pattern.steps.length}" name="${pattern.name}">
${patternNotes}
</pattern>`;
}).join('\n ');
})
.join("\n ");
return `
<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}">
@ -329,14 +563,29 @@ function modifyAndSaveExistingMmp() {
if (head) {
head.setAttribute("bpm", document.getElementById("bpm-input").value);
head.setAttribute("num_bars", document.getElementById("bars-input").value);
head.setAttribute("timesig_numerator", document.getElementById("compasso-a-input").value);
head.setAttribute("timesig_denominator", document.getElementById("compasso-b-input").value);
head.setAttribute(
"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) {
bbTrackContainer.querySelectorAll('track[type="0"]').forEach(node => node.remove());
const tracksXml = appState.pattern.tracks.map((track) => createTrackXml(track)).join("");
const tempDoc = new DOMParser().parseFromString(`<root>${tracksXml}</root>`, "application/xml");
bbTrackContainer
.querySelectorAll('track[type="0"]')
.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) => {
bbTrackContainer.appendChild(newTrackNode);
});
@ -351,7 +600,9 @@ function generateNewMmp() {
const sig_num = document.getElementById("compasso-a-input").value;
const sig_den = document.getElementById("compasso-b-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"?>
<!DOCTYPE lmms-project>
<lmms-project version="1.0" type="song" creator="MMPCreator" creatorversion="1.0">
@ -391,3 +642,5 @@ function downloadFile(content, fileName) {
document.body.removeChild(a);
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)
import { appState, resetProjectState } from "./state.js";
import { appState } from "./state.js";
import {
updateTransportLoop,
restartAudioEditorIfPlaying,
} from "./audio/audio_audio.js";
import { initializeAudioContext } from "./audio.js";
import { handleFileLoad, generateMmpFile } from "./file.js";
import { handleFileLoad, generateMmpFile, BLANK_PROJECT_XML } from "./file.js";
import {
renderAll,
loadAndRenderSampleBrowser,
@ -16,12 +16,15 @@ import {
import { renderAudioEditor } from "./audio/audio_ui.js";
import { adjustValue, enforceNumericInput } from "./utils.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.
// Ajuste o prefixo abaixo para o caminho real onde seus assets vivem no servidor:
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
const ROOM_NAME = new URLSearchParams(window.location.search).get("room");
window.ROOM_NAME = ROOM_NAME;
@ -29,17 +32,18 @@ window.ROOM_NAME = ROOM_NAME;
const PROJECT_NAME = new URLSearchParams(window.location.search).get("project");
if (PROJECT_NAME) {
// O nome do projeto deve corresponder ao arquivo no servidor, por ex: "mmp/nome-do-seu-projeto-salvo.mmp"
// O arquivo 'file.js' já espera que loadProjectFromServer receba apenas o nome
// do arquivo dentro da pasta 'mmp/' (ex: 'nome-do-projeto.mmp').
console.log(`[MAIN] Carregando projeto do servidor: ${PROJECT_NAME}`);
// Adicione a extensão se ela não estiver no link
const filename = PROJECT_NAME.endsWith('.mmp') || PROJECT_NAME.endsWith('.mmpz')
? PROJECT_NAME
: `${PROJECT_NAME}.mmp`;
// O nome do projeto deve corresponder ao arquivo no servidor, por ex: "mmp/nome-do-seu-projeto-salvo.mmp"
// O arquivo 'file.js' já espera que loadProjectFromServer receba apenas o nome
// do arquivo dentro da pasta 'mmp/' (ex: 'nome-do-projeto.mmp').
console.log(`[MAIN] Carregando projeto do servidor: ${PROJECT_NAME}`);
// Adicione a extensão se ela não estiver no link
const filename =
PROJECT_NAME.endsWith(".mmp") || PROJECT_NAME.endsWith(".mmpz")
? PROJECT_NAME
: `${PROJECT_NAME}.mmp`;
// Chama a função de file.js para carregar (que já envia a ação 'LOAD_PROJECT')
loadProjectFromServer(filename);
// Chama a função de file.js para carregar (que já envia a ação 'LOAD_PROJECT')
loadProjectFromServer(filename);
}
// ✅ NOVO: se tem sala na URL, entra já na sala (independe do áudio)
@ -106,6 +110,96 @@ document.addEventListener("DOMContentLoaded", () => {
const zoomOutBtn = document.getElementById("zoom-out-btn");
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)
// =================================================================
@ -193,7 +287,7 @@ document.addEventListener("DOMContentLoaded", () => {
)
)
return;
sendAction({ type: "RESET_PROJECT" });
sendAction({ type: "LOAD_PROJECT", xml: BLANK_PROJECT_XML });
});
addBarBtn?.addEventListener("click", () => {
@ -399,7 +493,7 @@ document.addEventListener("DOMContentLoaded", () => {
alert(
`Você já está na sala: ${currentParams.get(
"room"
)}\n\nCopie o link da barra de endereços para convidar.`
)}\n\Copie o link da barra de endereços para convidar.`
);
return;
}

View File

@ -6,13 +6,17 @@ import { highlightStep } from "./pattern_ui.js";
import { getTotalSteps } from "../utils.js";
import { initializeAudioContext } from "../audio.js";
const timerDisplay = document.getElementById('timer-display');
const timerDisplay = document.getElementById("timer-display");
function formatTime(milliseconds) {
const totalSeconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0');
const seconds = (totalSeconds % 60).toString().padStart(2, '0');
const centiseconds = Math.floor((milliseconds % 1000) / 10).toString().padStart(2, '0');
const minutes = Math.floor(totalSeconds / 60)
.toString()
.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}`;
}
@ -26,7 +30,9 @@ export function playMetronomeSound(isDownbeat) {
// Dispara o sample de uma track, garantindo que o player esteja roteado corretamente
export function playSample(filePath, trackId) {
initializeAudioContext();
const track = trackId ? appState.pattern.tracks.find((t) => t.id == trackId) : null;
const track = trackId
? appState.pattern.tracks.find((t) => t.id == trackId)
: null;
// Se a faixa existe e tem um player pré-carregado
if (track && track.player) {
@ -41,7 +47,9 @@ export function playSample(filePath, trackId) {
}
// Garante conexão: player -> volumeNode (não usar mais gainNode)
try { track.player.disconnect(); } catch {}
try {
track.player.disconnect();
} catch {}
if (track.volumeNode) {
track.player.connect(track.volumeNode);
}
@ -49,7 +57,9 @@ export function playSample(filePath, trackId) {
// Dispara imediatamente
track.player.start(Tone.now());
} 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
@ -66,7 +76,10 @@ function tick() {
}
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);
const bpm = parseInt(document.getElementById("bpm-input").value, 10) || 120;
@ -78,10 +91,13 @@ function tick() {
// Metrônomo
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;
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
const activePattern = track.patterns[track.activePatternIndex];
if (activePattern &&
activePattern.steps[appState.global.currentStep] &&
track.samplePath) {
if (
activePattern &&
activePattern.steps[appState.global.currentStep] &&
track.samplePath
) {
playSample(track.samplePath, track.id);
}
});
@ -115,7 +133,8 @@ export function startPlayback() {
Tone.Transport.bpm.value = bpm;
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;
const playBtn = document.getElementById("play-btn");
@ -135,9 +154,11 @@ export function stopPlayback() {
appState.global.playbackIntervalId = null;
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;
if (timerDisplay) timerDisplay.textContent = '00:00:00';
if (timerDisplay) timerDisplay.textContent = "00:00:00";
const playBtn = document.getElementById("play-btn");
if (playBtn) {
@ -147,10 +168,13 @@ export function stopPlayback() {
}
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;
if (!appState.global.isPlaying) {
if (timerDisplay) timerDisplay.textContent = '00:00:00';
if (timerDisplay) timerDisplay.textContent = "00:00:00";
highlightStep(lastStep, false);
}
}
@ -164,3 +188,165 @@ export function togglePlayback() {
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;
    }
// --- CORRIJA ESTAS DUAS LINHAS ---
// ANTES:
// const activePatternIndex = appState.pattern.activePatternIndex;
// const activePattern = trackData.patterns[activePatternIndex];
//
// DEPOIS:
    const activePatternIndex = trackData.activePatternIndex;
const activePatternIndex = trackData.activePatternIndex;
    const activePattern = trackData.patterns[activePatternIndex];
    if (!activePattern) {
        sequencerContainer.innerHTML = ""; return;
    }
// ... resto da função ...
    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++) {
      const stepWrapper = document.createElement("div");
      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) {
  if (stepIndex < 0) return;
  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) {
if (patternIndex !== appState.pattern.activePatternIndex) {
return;
}
// --- INÍCIO DA CORREÇÃO ---
// 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}"]`);
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(
`.step-sequencer .step-wrapper:nth-child(${stepIndex + 1})`
);
@ -216,3 +212,46 @@ export function updateStepUI(trackIndex, patternIndex, stepIndex, isActive) {
if (!stepElement) return;
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------------------------------------------------------
// IMPORTS & STATE
// -----------------------------------------------------------------------------
import { appState, resetProjectState } from "./state.js";
import { appState, saveStateToSession } from "./state.js";
import {
addTrackToState,
removeLastTrackFromState,
@ -33,7 +33,12 @@ import {
restartAudioEditorIfPlaying, // 👈 Adicionado
} 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 { updateStepUI, renderPatternEditor } from "./pattern/pattern_ui.js";
import { PORT_SOCK } from "./config.js";
@ -167,7 +172,9 @@ socket.on("connect_error", (err) => {
socket.on("system_update", (data) => {
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:
// 1. Fetch dos novos manifestos (metadata/samples-manifest.json etc.)
@ -186,14 +193,40 @@ socket.on("load_project_state", async (projectXml) => {
if (isLoadingProject) return;
isLoadingProject = true;
try {
await parseMmpContent(projectXml);
await parseMmpContent(projectXml); //
renderAll();
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) {
console.error("Erro ao carregar projeto:", e);
showToast("❌ Erro ao carregar projeto", "error");
}
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}`);
showToast(`🚪 Entrando na sala ${currentRoom}`, "info");
socket.emit("join_room", { roomName: currentRoom, userName: USER_NAME });
setTimeout(() => {
try {
const hasAudio =
(appState.audio?.clips?.length || 0) > 0 ||
(appState.audio?.tracks?.length || 0) > 0;
if (!hasAudio && currentRoom) {
sendAction({ type: "AUDIO_SNAPSHOT_REQUEST" });
}
} catch {}
}, 800);
} else {
console.warn("joinRoom() chamado, mas nenhuma sala encontrada na URL.");
showToast("⚠️ Nenhuma sala encontrada na URL", "warning");
@ -498,6 +521,7 @@ async function handleActionBroadcast(action) {
const seekTime = action.seekTime ?? appState.audio.audioEditorSeekTime;
setTimeout(() => startAudioEditorPlayback(seekTime), delayMs);
break;
case "STOP_AUDIO_PLAYBACK":
setTimeout(
() => stopAudioEditorPlayback(action.rewind || false),
@ -564,6 +588,8 @@ async function handleActionBroadcast(action) {
const modeText = newMode === "global" ? "Global 🌐" : "Local 🏠";
showToast(`${who} mudou modo para ${modeText}`, "info");
}
// Salva o estado localmente também!
saveStateToSession();
break;
}
// =================================================================
@ -571,28 +597,80 @@ async function handleActionBroadcast(action) {
// =================================================================
// Estado Global
case "LOAD_PROJECT":
case "LOAD_PROJECT": //
isLoadingProject = true;
showToast("📂 Carregando...", "info");
// Esta parte está CORRETA. Sempre limpa o sessionStorage.
if (window.ROOM_NAME) {
try {
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);
await parseMmpContent(action.xml); //
renderAll();
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) {
console.error("Erro LOAD_PROJECT:", e);
showToast("❌ Erro projeto", "error");
}
isLoadingProject = false;
break;
case "RESET_PROJECT":
resetProjectState();
document.getElementById("bpm-input").value = 140;
document.getElementById("bars-input").value = 1;
document.getElementById("compasso-a-input").value = 4;
document.getElementById("compasso-b-input").value = 4;
renderAll();
case "SYNC_PATTERN_STATE": //
// Esta ação agora só será recebida de *outros* usuários,
// ou quando o servidor enviar, não de você mesmo.
try {
await parseMmpContent(action.xml); //
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);
showToast(`🧹 Reset por ${who}`, "warning");
// --- FIM DA CORREÇÃO ---
handleLocalProjectReset(); //
showToast(`🧹 Reset por ${who}`, "warning"); //
// Salva o estado localmente também!
saveStateToSession(); //
break;
// Configs
@ -601,24 +679,35 @@ async function handleActionBroadcast(action) {
renderAll();
const who = actorOf(action);
showToast(`🕰 ${who} BPM ${action.value}`, "info");
// Salva o estado localmente também!
saveStateToSession();
break;
}
case "SET_BARS": {
document.getElementById("bars-input").value = action.value;
renderAll();
const who = actorOf(action);
showToast(`🕰 ${who} Compasso add`, "info");
// Salva o estado localmente também!
saveStateToSession();
break;
}
case "SET_TIMESIG_A":
document.getElementById("compasso-a-input").value = action.value;
renderAll();
showToast("Compasso alt", "info");
// Salva o estado localmente também!
saveStateToSession();
break;
case "SET_TIMESIG_B":
document.getElementById("compasso-b-input").value = action.value;
renderAll();
showToast("Compasso alt", "info");
// Salva o estado localmente também!
saveStateToSession();
break;
// Tracks
@ -627,15 +716,21 @@ async function handleActionBroadcast(action) {
renderPatternEditor();
const who = actorOf(action);
showToast(`🥁 Faixa add por ${who}`, "info");
// Salva o estado localmente também!
saveStateToSession();
break;
}
case "REMOVE_LAST_TRACK": {
removeLastTrackFromState();
renderPatternEditor();
const who = actorOf(action);
showToast(`❌ Faixa remov. por ${who}`, "warning");
// Salva o estado localmente também!
saveStateToSession();
break;
}
case "ADD_AUDIO_LANE": {
const id = action.trackId;
if (!id) {
@ -654,14 +749,19 @@ async function handleActionBroadcast(action) {
renderAll();
const who = actorOf(action);
showToast(`🎧 Pista add por ${who}`, "info");
// Salva o estado localmente também!
saveStateToSession();
break;
}
case "REMOVE_AUDIO_CLIP":
if (removeAudioClip(action.clipId)) {
appState.global.selectedClipId = null;
renderAll();
const who = actorOf(action);
showToast(`🎚️ Clip remov. por ${who}`, "info");
// Salva o estado localmente também!
saveStateToSession();
}
break;
@ -687,6 +787,8 @@ async function handleActionBroadcast(action) {
const who = actorOf(action);
const v = isActive ? "+" : "-";
showToast(`🎯 ${who} ${v} nota ${ti + 1}.${pi + 1}.${si + 1}`, "info");
// Salva o estado localmente também!
saveStateToSession();
break;
}
@ -707,6 +809,35 @@ async function handleActionBroadcast(action) {
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;
}
@ -730,10 +861,17 @@ async function handleActionBroadcast(action) {
} catch (e) {
console.warn("Erro AUDIO_SNAPSHOT_REQUEST:", e);
}
// Salva o estado localmente também!
saveStateToSession();
break;
}
case "AUDIO_SNAPSHOT": {
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;
if (hasClips) break;
try {
@ -745,13 +883,26 @@ async function handleActionBroadcast(action) {
console.error("Erro AUDIO_SNAPSHOT:", e);
showToast("❌ Erro sync áudio", "error");
}
// Salva o estado localmente também!
saveStateToSession();
break;
}
// Clip Sync
case "ADD_AUDIO_CLIP": {
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 (
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}`,
});
}
// --- 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(
filePath,
trackId,
startTimeInSeconds,
clipId,
name
name,
patternData // <-- AQUI ESTÁ A "PARTITURA"
);
// --- FIM DA MODIFICAÇÃO ---
renderAll();
const who = actorOf(action);
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);
showToast("❌ Erro add clip", "error");
}
// Salva o estado localmente também!
saveStateToSession();
break;
}
case "UPDATE_AUDIO_CLIP": {
try {
if (action.props?.__operation === "slice") {
@ -806,6 +967,8 @@ async function handleActionBroadcast(action) {
console.error("Erro UPDATE_AUDIO_CLIP:", e);
showToast("❌ Erro att clip", "error");
}
// Salva o estado localmente também!
saveStateToSession();
break;
}

View File

@ -1,71 +1,130 @@
// js/state.js
import { initializePatternState } from './pattern/pattern_state.js';
import { audioState, initializeAudioState } from './audio/audio_state.js';
import { initializePatternState } from "./pattern/pattern_state.js";
import { audioState, initializeAudioState } from "./audio/audio_state.js";
import { DEFAULT_VOLUME, DEFAULT_PAN } from "./config.js";
// Estado global da aplicação
const globalState = {
  sliceToolActive: false,
  isPlaying: false,
  isAudioEditorPlaying: false,
  playbackIntervalId: null,
  currentStep: 0,
  metronomeEnabled: false,
  originalXmlDoc: null,
  currentBeatBasslineName: 'Novo Projeto',
  masterVolume: DEFAULT_VOLUME,
  masterPan: DEFAULT_PAN,
  zoomLevelIndex: 2,
  isLoopActive: false,
  loopStartTime: 0,
  loopEndTime: 8,
  resizeMode: 'trim',
  selectedClipId: null,
  isRecording: false,
  clipboard: null,
  lastRulerClickTime: 0,
sliceToolActive: false,
isPlaying: false,
isAudioEditorPlaying: false,
playbackIntervalId: null,
currentStep: 0,
metronomeEnabled: false,
originalXmlDoc: null,
currentBeatBasslineName: "Novo Projeto",
masterVolume: DEFAULT_VOLUME,
masterPan: DEFAULT_PAN,
zoomLevelIndex: 2,
isLoopActive: false,
loopStartTime: 0,
loopEndTime: 8,
resizeMode: "trim",
selectedClipId: null,
isRecording: false,
clipboard: null,
lastRulerClickTime: 0,
justReset: false,
};
// --- ADICIONE ESTE BLOCO ---
// Define o ESTADO INICIAL para o pattern module
const patternState = {
    tracks: [],
    activeTrackId: null,
    activePatternIndex: 0,
tracks: [],
activeTrackId: null,
activePatternIndex: 0,
};
// --- FIM DA ADIÇÃO ---
// Combina todos os estados em um único objeto namespaced
export let appState = {
  global: globalState,
  pattern: patternState, // <-- AGORA 'patternState' está definido
  audio: audioState,
global: globalState,
pattern: patternState, // <-- AGORA 'patternState' está definido
audio: audioState,
};
// Função para resetar o projeto para o estado inicial
export function resetProjectState() {
    initializePatternState(); // Esta função vai MODIFICAR appState.pattern
    initializeAudioState();
console.log("Executando resetProjectState completo...");
    Object.assign(globalState, {
        sliceToolActive: false,
        isPlaying: false,
        isAudioEditorPlaying: false,
        playbackIntervalId: null,
        currentStep: 0,
        metronomeEnabled: false,
        originalXmlDoc: null,
        currentBeatBasslineName: 'Novo Projeto',
        masterVolume: DEFAULT_VOLUME,
        masterPan: DEFAULT_PAN,
        zoomLevelIndex: 2,
        isLoopActive: false,
        loopStartTime: 0,
        loopEndTime: 8,
        resizeMode: 'trim',
        selectedClipId: null,
        isRecording: false,
        clipboard: null,
        lastRulerClickTime: 0,
    });
// 1. Reseta o estado global para os padrões
Object.assign(appState.global, {
sliceToolActive: false,
isPlaying: false,
isAudioEditorPlaying: false,
playbackIntervalId: null,
currentStep: 0,
metronomeEnabled: false,
originalXmlDoc: null,
currentBeatBasslineName: "Novo Projeto",
masterVolume: DEFAULT_VOLUME,
masterPan: DEFAULT_PAN,
zoomLevelIndex: 2,
isLoopActive: false,
loopStartTime: 0,
loopEndTime: 8,
resizeMode: "trim",
selectedClipId: null,
isRecording: false,
clipboard: null,
lastRulerClickTime: 0,
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);
}
}