diff --git a/assets/js/creations/file.js b/assets/js/creations/file.js index 2f344f57..4d53ecd5 100755 --- a/assets/js/creations/file.js +++ b/assets/js/creations/file.js @@ -706,6 +706,8 @@ function generateXmlFromState() { }); } + applyPlaylistClipsToXml(xmlDoc); + const serializer = new XMLSerializer(); return serializer.serializeToString(xmlDoc); } @@ -717,6 +719,34 @@ export function syncPatternStateToServer() { saveStateToSession(); } +function applyPlaylistClipsToXml(xmlDoc) { + const bbTrackNodes = Array.from(xmlDoc.querySelectorAll('track[type="1"]')); + if (!bbTrackNodes.length) return; + + const basslines = appState.pattern.tracks + .filter((t) => t.type === "bassline" && Number.isFinite(Number(t.patternIndex))) + .slice() + .sort((a, b) => Number(a.patternIndex) - Number(b.patternIndex)); + + for (const b of basslines) { + const idx = Number(b.patternIndex); + const node = bbTrackNodes[idx]; + if (!node) continue; + + // remove bbtco existentes + Array.from(node.querySelectorAll(":scope > bbtco")).forEach((n) => n.remove()); + + const clips = (b.playlist_clips || []).slice().sort((x, y) => (x.pos ?? 0) - (y.pos ?? 0)); + + for (const c of clips) { + const el = xmlDoc.createElement("bbtco"); + el.setAttribute("pos", String(Math.max(0, Math.floor(c.pos ?? 0)))); + el.setAttribute("len", String(Math.max(12, Math.floor(c.len ?? 192)))); + node.appendChild(el); + } + } +} + function createTrackXml(track) { if (!track.patterns || track.patterns.length === 0) return ""; diff --git a/assets/js/creations/main.js b/assets/js/creations/main.js index 75c22bb8..eea8596c 100755 --- a/assets/js/creations/main.js +++ b/assets/js/creations/main.js @@ -13,7 +13,7 @@ import { closeOpenProjectModal, } from "./ui.js"; import { renderAudioEditor } from "./audio/audio_ui.js"; -import { adjustValue, enforceNumericInput, DEFAULT_PROJECT_XML } from "./utils.js"; +import { adjustValue, enforceNumericInput, DEFAULT_PROJECT_XML, secondsToSongTicks, snapSongTicks, LMMS_TICKS_PER_BAR } from "./utils.js"; import { ZOOM_LEVELS } from "./config.js"; import { loadProjectFromServer } from "./file.js"; import { sendAction, joinRoom, setUserName } from "./socket.js"; @@ -661,8 +661,31 @@ document.addEventListener("DOMContentLoaded", () => { renderAll(); showToast(`Editando: ${basslineTrack.name}`, "info"); - }; + }; + const sendPatternToSongBtn = document.getElementById("send-pattern-to-song-btn"); + sendPatternToSongBtn?.addEventListener("click", () => { + const bpm = parseFloat(document.getElementById("bpm-input")?.value) || 120; + + // playhead atual (se estiver tocando, usa logical; senão, seek) + const sec = + (appState.audio.audioEditorLogicalTime ?? 0) || + (appState.audio.audioEditorSeekTime ?? 0) || + 0; + + const patternIndex = appState.pattern.activePatternIndex || 0; + + let posTicks = secondsToSongTicks(sec, bpm); + posTicks = snapSongTicks(posTicks, LMMS_TICKS_PER_BAR); // snap por compasso (fica LMMS-like) + + sendAction({ + type: "ADD_PLAYLIST_PATTERN_CLIP", + patternIndex, + pos: posTicks, + len: LMMS_TICKS_PER_BAR, // 1 compasso default + clipId: `plc_${Date.now()}_${Math.random().toString(36).slice(2)}`, + }); + }); window.exitPatternFocus = function() { diff --git a/assets/js/creations/socket.js b/assets/js/creations/socket.js index 1ba8ef2d..0c232303 100755 --- a/assets/js/creations/socket.js +++ b/assets/js/creations/socket.js @@ -443,6 +443,42 @@ function schedulePatternRerender() { }); } +function _genPlaylistClipId() { + return `plc_${Date.now()}_${Math.random().toString(36).slice(2)}`; +} + +function _ensureBasslineForPatternIndex(patternIndex) { + let b = appState.pattern.tracks.find( + (t) => t.type === "bassline" && Number(t.patternIndex) === Number(patternIndex) + ); + + // fallback: cria se não existir (não quebra nada) + if (!b) { + b = { + id: `bassline_${Date.now()}_${Math.random().toString(36).slice(2)}`, + name: `Beat/Bassline ${patternIndex}`, + type: "bassline", + patternIndex: Number(patternIndex), + playlist_clips: [], + patterns: [], + isMuted: false, + instrumentSourceId: null, + volume: 1, + pan: 0, + }; + appState.pattern.tracks.push(b); + } + + if (!Array.isArray(b.playlist_clips)) b.playlist_clips = []; + + // garante ids nos clips antigos + b.playlist_clips.forEach((c) => { + if (!c.id) c.id = _genPlaylistClipId(); + }); + + return b; +} + // ----------------------------------------------------------------------------- // BROADCAST // ----------------------------------------------------------------------------- @@ -518,6 +554,96 @@ async function handleActionBroadcast(action) { showToast(`▶ ${who} Play bases`, "info"); break; } + + case "ADD_PLAYLIST_PATTERN_CLIP": { + const { patternIndex, pos, len, clipId, name } = action; + + const b = _ensureBasslineForPatternIndex(patternIndex); + + const newClip = { + id: clipId || _genPlaylistClipId(), + pos: Math.max(0, Math.floor(pos ?? 0)), + len: Math.max(12, Math.floor(len ?? 192)), + name: name || b.name || "Pattern", + }; + + // evita duplicar + if (!b.playlist_clips.some((c) => String(c.id) === String(newClip.id))) { + b.playlist_clips.push(newClip); + b.playlist_clips.sort((a, c) => (a.pos ?? 0) - (c.pos ?? 0)); + } + + renderAll(); + saveStateToSession(); + + // ✅ sync XML (inclui bbtco depois do patch no file.js) + if (window.ROOM_NAME && isFromSelf) { + try { + const xml = generateXmlFromStateExported(); + sendAction({ type: "SYNC_PATTERN_STATE", xml }); + } catch {} + } + break; + } + + case "UPDATE_PLAYLIST_PATTERN_CLIP": { + const { patternIndex, clipId, pos, len } = action; + const b = _ensureBasslineForPatternIndex(patternIndex); + + const c = b.playlist_clips.find((x) => String(x.id) === String(clipId)); + if (!c) break; + + if (pos !== undefined) c.pos = Math.max(0, Math.floor(pos)); + if (len !== undefined) c.len = Math.max(12, Math.floor(len)); + + b.playlist_clips.sort((a, d) => (a.pos ?? 0) - (d.pos ?? 0)); + + renderAll(); + saveStateToSession(); + + if (window.ROOM_NAME && isFromSelf) { + try { + const xml = generateXmlFromStateExported(); + sendAction({ type: "SYNC_PATTERN_STATE", xml }); + } catch {} + } + break; + } + + case "REMOVE_PLAYLIST_PATTERN_CLIP": { + const { patternIndex, clipId } = action; + const b = _ensureBasslineForPatternIndex(patternIndex); + + b.playlist_clips = b.playlist_clips.filter((c) => String(c.id) !== String(clipId)); + + // limpa seleção se era o selecionado + if (appState.global.selectedPlaylistClipId === clipId) { + appState.global.selectedPlaylistClipId = null; + appState.global.selectedPlaylistPatternIndex = null; + } + + renderAll(); + saveStateToSession(); + + if (window.ROOM_NAME && isFromSelf) { + try { + const xml = generateXmlFromStateExported(); + sendAction({ type: "SYNC_PATTERN_STATE", xml }); + } catch {} + } + break; + } + + case "SELECT_PLAYLIST_PATTERN_CLIP": { + const { patternIndex, clipId } = action; + appState.global.selectedPlaylistClipId = clipId ?? null; + appState.global.selectedPlaylistPatternIndex = + patternIndex ?? null; + renderAll(); + saveStateToSession(); + break; + } + case "UPDATE_PATTERN_NOTES": { const { trackIndex: ti, diff --git a/assets/js/creations/state.js b/assets/js/creations/state.js index b493318e..c0cd7ff4 100755 --- a/assets/js/creations/state.js +++ b/assets/js/creations/state.js @@ -31,7 +31,9 @@ const globalState = { loopStartTime: 0, loopEndTime: 8, resizeMode: "trim", - selectedClipId: null, + selectedClipId: null, + selectedPlaylistClipId: null, + selectedPlaylistPatternIndex: null, isRecording: false, clipboard: null, lastRulerClickTime: 0, @@ -58,6 +60,24 @@ function makePatternSnapshot() { name: t.name, type: t.type, samplePath: t.samplePath, + + // ✅ extras necessários pro Song Editor / playlist + patternIndex: t.patternIndex ?? null, + playlist_clips: Array.isArray(t.playlist_clips) + ? t.playlist_clips.map((c) => ({ + id: c.id ?? null, + pos: c.pos ?? 0, + len: c.len ?? 192, + name: c.name ?? t.name ?? "Pattern", + })) + : null, + + // ✅ mantém compat com a sua estrutura de rack/focus + parentBasslineId: t.parentBasslineId ?? null, + instrumentSourceId: t.instrumentSourceId ?? null, + isMuted: t.isMuted ?? false, + muted: t.muted ?? false, + patterns: (t.patterns || []).map((p) => ({ name: p.name, steps: p.steps, @@ -72,10 +92,12 @@ function makePatternSnapshot() { })), activeTrackId: appState.pattern.activeTrackId, activePatternIndex: appState.pattern.activePatternIndex, + focusedBasslineId: appState.pattern.focusedBasslineId ?? null, }; } + // ---------------------- // Helper: existe snapshot local com áudio? // ---------------------- @@ -137,6 +159,8 @@ export function resetProjectState() { loopEndTime: 8, resizeMode: "trim", selectedClipId: null, + selectedPlaylistClipId: null, + selectedPlaylistPatternIndex: null, isRecording: false, clipboard: null, lastRulerClickTime: 0, diff --git a/assets/js/creations/utils.js b/assets/js/creations/utils.js index 75168e7c..1e247b4e 100755 --- a/assets/js/creations/utils.js +++ b/assets/js/creations/utils.js @@ -195,4 +195,30 @@ export function adjustValue(inputElement, step) { inputElement.value = newValue; inputElement.dispatchEvent(new Event("input", { bubbles: true })); -} \ No newline at end of file +} + +// =============================== +// LMMS ticks helpers (Song Editor) +// =============================== + +export const LMMS_TICKS_PER_STEP = 12; // 1 step (1/16) = 12 ticks +export const STEPS_PER_BAR = 16; // 4/4: 16 steps por compasso +export const LMMS_TICKS_PER_BAR = LMMS_TICKS_PER_STEP * STEPS_PER_BAR; // 192 + +export function secondsToSongTicks(seconds, bpm = null) { + const b = bpm ?? (parseFloat(document.getElementById("bpm-input")?.value) || 120); + // stepIntervalSec = 60/(bpm*4) => ticksPerSec = 12/stepIntervalSec = bpm*0.8 + const ticks = seconds * b * 0.8; + return Math.round(ticks); +} + +export function songTicksToSeconds(ticks, bpm = null) { + const b = bpm ?? (parseFloat(document.getElementById("bpm-input")?.value) || 120); + // seconds = ticks / (bpm*0.8) + return ticks / (b * 0.8); +} + +export function snapSongTicks(ticks, snap = LMMS_TICKS_PER_STEP) { + const s = Math.max(1, Math.floor(snap)); + return Math.round(ticks / s) * s; +} diff --git a/creation.html b/creation.html index 21d6f55f..3ae62f52 100755 --- a/creation.html +++ b/creation.html @@ -347,7 +347,7 @@