v1.0.13 - botao de reset funcional para limpar as salas
This commit is contained in:
parent
2153a9d50c
commit
8cfa40a42d
|
|
@ -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";
|
||||
|
|
@ -17,19 +25,19 @@ let schedulerIntervalId = null;
|
|||
let animationFrameId = null;
|
||||
|
||||
// Sincronização de Tempo
|
||||
let startTime = 0;
|
||||
let startTime = 0;
|
||||
// (seek/logical ficam em appState.audio)
|
||||
|
||||
// Configurações de Loop
|
||||
let isLoopActive = false;
|
||||
let loopStartTimeSec = 0;
|
||||
let loopEndTimeSec = 8;
|
||||
let loopEndTimeSec = 8;
|
||||
|
||||
// estado runtime
|
||||
const runtimeClipState = new Map();
|
||||
// ⚠️ agora armazenamos Tone.Player em vez de BufferSource
|
||||
const scheduledNodes = new Map(); // eventId -> { player, clipId }
|
||||
let nextEventId = 0;
|
||||
let nextEventId = 0;
|
||||
|
||||
const callbacks = {
|
||||
onClipScheduled: null,
|
||||
|
|
@ -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,17 +151,21 @@ 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 {}
|
||||
};
|
||||
}
|
||||
|
||||
function _handleClipEnd(eventId, clipId) {
|
||||
scheduledNodes.delete(eventId);
|
||||
runtimeClipState.delete(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 (loopDuration <= 0) continue;
|
||||
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,31 +237,33 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
appState.audio.audioEditorLogicalTime = newLogicalTime;
|
||||
|
||||
|
||||
appState.audio.audioEditorLogicalTime = newLogicalTime;
|
||||
|
||||
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();
|
||||
|
|
@ -222,23 +280,30 @@ export function updateTransportLoop() {
|
|||
loopEndTimeSec = appState.global.loopEndTime;
|
||||
|
||||
runtimeClipState.clear();
|
||||
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
|
|
@ -251,23 +316,24 @@ export async function startAudioEditorPlayback(seekTime) { // 1. Aceita 'seekTim
|
|||
// =================================================================
|
||||
// 👇 INÍCIO DA CORREÇÃO (Bugs 1 & 2)
|
||||
// =================================================================
|
||||
|
||||
|
||||
// 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.
|
||||
appState.audio.audioEditorSeekTime = timeToStart;
|
||||
// 2. Clampa o valor (parte da correção do RangeError)
|
||||
timeToStart = Math.max(0, timeToStart);
|
||||
|
||||
// 3. Alinhe o Tone.Transport a esse tempo
|
||||
// 3. Atualize o estado global (para a agulha pular)
|
||||
appState.audio.audioEditorSeekTime = timeToStart;
|
||||
|
||||
// 4. Alinhe o Tone.Transport a esse tempo
|
||||
try {
|
||||
Tone.Transport.seconds = timeToStart; // 👈 Usa o tempo sincronizado
|
||||
} catch {}
|
||||
|
||||
|
||||
// =================================================================
|
||||
// 👆 FIM DA CORREÇÃO
|
||||
// =================================================================
|
||||
|
|
@ -295,37 +361,48 @@ export function stopAudioEditorPlayback(rewind = false) {
|
|||
|
||||
isPlaying = false;
|
||||
appState.global.isAudioEditorPlaying = 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.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();
|
||||
}
|
||||
|
|
@ -343,16 +420,21 @@ export function seekAudioEditor(newTime) {
|
|||
if (wasPlaying) {
|
||||
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 {}
|
||||
appState.audio.audioEditorLogicalTime = newTime;
|
||||
|
||||
try {
|
||||
Tone.Transport.seconds = newTime;
|
||||
} catch {}
|
||||
|
||||
const pixelsPerSecond = getPixelsPerSecond();
|
||||
const newPositionPx = newTime * pixelsPerSecond;
|
||||
updatePlayheadVisual(newPositionPx);
|
||||
|
||||
|
||||
if (wasPlaying) {
|
||||
startAudioEditorPlayback();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,52 +103,57 @@ 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);
|
||||
|
||||
clip.buffer = audioBuffer;
|
||||
clip.buffer = audioBuffer;
|
||||
|
||||
// --- CORREÇÃO: Salva a duração original ---
|
||||
if (clip.durationInSeconds === 0) {
|
||||
|
|
@ -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'),
|
||||
|
||||
startTimeInSeconds: startTime,
|
||||
offset: 0,
|
||||
durationInSeconds: 0,
|
||||
originalDuration: 0,
|
||||
const finalId = incomingId || genClipId();
|
||||
|
||||
pitch: 0,
|
||||
volume: DEFAULT_VOLUME,
|
||||
pan: DEFAULT_PAN,
|
||||
|
||||
buffer: existingBuffer || null,
|
||||
player: null,
|
||||
};
|
||||
|
||||
// volume linear (0–1)
|
||||
newClip.gainNode = new Tone.Gain(DEFAULT_VOLUME);
|
||||
newClip.pannerNode = new Tone.Panner(DEFAULT_PAN);
|
||||
// idempotência: se o id já existe, não duplica
|
||||
if (audioState.clips.some((c) => String(c.id) === String(finalId))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// conecta tudo no grafo do Tone (mesmo contexto)
|
||||
newClip.gainNode.connect(newClip.pannerNode);
|
||||
newClip.pannerNode.connect(getMainGainNode());
|
||||
const newClip = {
|
||||
id: finalId,
|
||||
trackId: trackId,
|
||||
sourcePath: samplePath, // Pode ser null se existingBuffer for fornecido
|
||||
name:
|
||||
clipName ||
|
||||
(samplePath ? String(samplePath).split("/").pop() : "Bounced Clip"),
|
||||
|
||||
audioState.clips.push(newClip);
|
||||
|
||||
// loadAudioForClip agora vai lidar com 'existingBuffer'
|
||||
loadAudioForClip(newClip).then(() => {
|
||||
renderAudioEditor();
|
||||
});
|
||||
startTimeInSeconds: startTime,
|
||||
offset: 0,
|
||||
durationInSeconds: 0,
|
||||
originalDuration: 0,
|
||||
|
||||
pitch: 0,
|
||||
volume: DEFAULT_VOLUME,
|
||||
pan: DEFAULT_PAN,
|
||||
|
||||
buffer: existingBuffer || null,
|
||||
player: null,
|
||||
|
||||
// --- INÍCIO DA CORREÇÃO (Passo 3) ---
|
||||
// A "partitura" é finalmente armazenada no objeto do clipe!
|
||||
patternData: incomingPatternData || null,
|
||||
// --- FIM DA CORREÇÃO ---
|
||||
};
|
||||
|
||||
// volume linear (0–1)
|
||||
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));
|
||||
|
||||
if (!originalClip ||
|
||||
sliceTimeInTimeline <= originalClip.startTimeInSeconds ||
|
||||
sliceTimeInTimeline >= (originalClip.startTimeInSeconds + originalClip.durationInSeconds)) {
|
||||
console.warn("Corte inválido: fora dos limites do clipe.");
|
||||
return;
|
||||
}
|
||||
const originalClip = audioState.clips.find(
|
||||
(c) => String(c.id) == String(clipId)
|
||||
);
|
||||
|
||||
const originalOffset = originalClip.offset || 0;
|
||||
const cutPointInClip = sliceTimeInTimeline - originalClip.startTimeInSeconds;
|
||||
if (
|
||||
!originalClip ||
|
||||
sliceTimeInTimeline <= originalClip.startTimeInSeconds ||
|
||||
sliceTimeInTimeline >=
|
||||
originalClip.startTimeInSeconds + originalClip.durationInSeconds
|
||||
) {
|
||||
console.warn("Corte inválido: fora dos limites do clipe.");
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
// --- CORREÇÃO: Propaga a duração original ---
|
||||
originalDuration: originalClip.originalDuration,
|
||||
const originalOffset = originalClip.offset || 0;
|
||||
const cutPointInClip = sliceTimeInTimeline - originalClip.startTimeInSeconds;
|
||||
|
||||
pitch: originalClip.pitch,
|
||||
volume: originalClip.volume,
|
||||
pan: originalClip.pan,
|
||||
const newClip = {
|
||||
id: genClipId(),
|
||||
trackId: originalClip.trackId,
|
||||
sourcePath: originalClip.sourcePath,
|
||||
name: originalClip.name,
|
||||
buffer: originalClip.buffer,
|
||||
|
||||
gainNode: new Tone.Gain(originalClip.volume),
|
||||
pannerNode: new Tone.Panner(originalClip.pan),
|
||||
|
||||
player: null
|
||||
};
|
||||
startTimeInSeconds: sliceTimeInTimeline,
|
||||
offset: originalOffset + cutPointInClip,
|
||||
durationInSeconds: originalClip.durationInSeconds - cutPointInClip,
|
||||
|
||||
newClip.gainNode.connect(newClip.pannerNode);
|
||||
newClip.pannerNode.connect(getMainGainNode());
|
||||
// --- CORREÇÃO: Propaga a duração original ---
|
||||
originalDuration: originalClip.originalDuration,
|
||||
|
||||
originalClip.durationInSeconds = cutPointInClip;
|
||||
pitch: originalClip.pitch,
|
||||
volume: originalClip.volume,
|
||||
pan: originalClip.pan,
|
||||
|
||||
audioState.clips.push(newClip);
|
||||
|
||||
console.log("Clipe dividido. Original:", originalClip, "Novo:", newClip);
|
||||
gainNode: new Tone.Gain(originalClip.volume),
|
||||
pannerNode: new Tone.Panner(originalClip.pan),
|
||||
|
||||
player: null,
|
||||
|
||||
// --- NOVA MODIFICAÇÃO (SLICE) ---
|
||||
// Se o clip original tinha dados de pattern, o novo clip (parte 2)
|
||||
// também deve tê-los, pois a referência é a mesma.
|
||||
patternData: originalClip.patternData || null,
|
||||
};
|
||||
|
||||
newClip.gainNode.connect(newClip.pannerNode);
|
||||
newClip.pannerNode.connect(getMainGainNode());
|
||||
|
||||
originalClip.durationInSeconds = cutPointInClip;
|
||||
|
||||
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 {}
|
||||
}
|
||||
|
||||
// 2. Remove o clipe do array de estado
|
||||
audioState.clips.splice(clipIndex, 1);
|
||||
// 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 {}
|
||||
}
|
||||
|
||||
// 3. Retorna true para o chamador (Controller)
|
||||
return true;
|
||||
// 2. Remove o clipe do array de estado
|
||||
audioState.clips.splice(clipIndex, 1);
|
||||
|
||||
// 3. Retorna true para o chamador (Controller)
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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,17 +70,18 @@ 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();
|
||||
}
|
||||
|
||||
|
||||
// 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}`);
|
||||
|
|
@ -41,21 +93,20 @@ export async function loadProjectFromServer(fileName) {
|
|||
const response = await fetch(`mmp/${fileName}`);
|
||||
if (!response.ok)
|
||||
throw new Error(`Não foi possível carregar o arquivo ${fileName}`);
|
||||
|
||||
|
||||
const xmlContent = await response.text();
|
||||
|
||||
// ANTES:
|
||||
// ANTES:
|
||||
// await parseMmpContent(xmlContent);
|
||||
// return true;
|
||||
|
||||
// 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);
|
||||
console.error(error);
|
||||
alert(`Erro ao carregar projeto: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
const bbTrackContainer = dataSourceTrack.querySelector('bbtrack > trackcontainer');
|
||||
if (!bbTrackContainer) {
|
||||
appState.pattern.tracks = [];
|
||||
renderAll();
|
||||
// --- 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.
|
||||
|
||||
// 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;
|
||||
|
||||
const trackName = trackNode.getAttribute("name");
|
||||
|
||||
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 patterns = sortedBBTrackNameNodes.map((bbTrack, index) => {
|
||||
// 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");
|
||||
|
||||
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;
|
||||
|
||||
// --- 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));
|
||||
}
|
||||
|
||||
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);
|
||||
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,
|
||||
|
||||
// --- 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,34 +511,39 @@ 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();
|
||||
|
||||
// NOTA: Usamos um novo tipo de ação para não confundir com o carregamento de arquivo
|
||||
sendAction({
|
||||
type: 'SYNC_PATTERN_STATE',
|
||||
xml: currentXml
|
||||
});
|
||||
sendAction({
|
||||
type: "SYNC_PATTERN_STATE",
|
||||
xml: currentXml,
|
||||
});
|
||||
|
||||
// Salva o estado localmente também!
|
||||
saveStateToSession(); // <-- ADICIONE ISSO
|
||||
}
|
||||
|
||||
function createTrackXml(track) {
|
||||
if (track.patterns.length === 0) return "";
|
||||
const ticksPerStep = 12;
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
||||
// Chama a função de file.js para carregar (que já envia a ação 'LOAD_PROJECT')
|
||||
loadProjectFromServer(filename);
|
||||
// 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);
|
||||
}
|
||||
|
||||
// ✅ 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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})`
|
||||
);
|
||||
|
|
@ -215,4 +211,47 @@ export function updateStepUI(trackIndex, patternIndex, stepIndex, isActive) {
|
|||
const stepElement = stepWrapper.querySelector(".step");
|
||||
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;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
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
|
|
@ -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,12 +172,14 @@ 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.)
|
||||
// 2. Renderiza a interface do navegador de samples
|
||||
loadAndRenderSampleBrowser();
|
||||
loadAndRenderSampleBrowser();
|
||||
}
|
||||
// Se houver outras notificações, adicione-as aqui (ex: RELOAD_PROJECTS)
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -815,4 +978,4 @@ async function handleActionBroadcast(action) {
|
|||
}
|
||||
|
||||
// EXPORTS
|
||||
export { socket };
|
||||
export { socket };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue