editando patterns na playlist
Deploy / Deploy (push) Successful in 2m4s Details

This commit is contained in:
JotaChina 2025-12-27 12:12:22 -03:00
parent 39fa026e92
commit 4f92c93ab5
6 changed files with 234 additions and 5 deletions

View File

@ -706,6 +706,8 @@ function generateXmlFromState() {
}); });
} }
applyPlaylistClipsToXml(xmlDoc);
const serializer = new XMLSerializer(); const serializer = new XMLSerializer();
return serializer.serializeToString(xmlDoc); return serializer.serializeToString(xmlDoc);
} }
@ -717,6 +719,34 @@ export function syncPatternStateToServer() {
saveStateToSession(); 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) { function createTrackXml(track) {
if (!track.patterns || track.patterns.length === 0) return ""; if (!track.patterns || track.patterns.length === 0) return "";

View File

@ -13,7 +13,7 @@ import {
closeOpenProjectModal, closeOpenProjectModal,
} from "./ui.js"; } from "./ui.js";
import { renderAudioEditor } from "./audio/audio_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 { ZOOM_LEVELS } from "./config.js";
import { loadProjectFromServer } from "./file.js"; import { loadProjectFromServer } from "./file.js";
import { sendAction, joinRoom, setUserName } from "./socket.js"; import { sendAction, joinRoom, setUserName } from "./socket.js";
@ -661,8 +661,31 @@ document.addEventListener("DOMContentLoaded", () => {
renderAll(); renderAll();
showToast(`Editando: ${basslineTrack.name}`, "info"); 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() { window.exitPatternFocus = function() {

View File

@ -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 // BROADCAST
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@ -518,6 +554,96 @@ async function handleActionBroadcast(action) {
showToast(`${who} Play bases`, "info"); showToast(`${who} Play bases`, "info");
break; 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": { case "UPDATE_PATTERN_NOTES": {
const { const {
trackIndex: ti, trackIndex: ti,

View File

@ -31,7 +31,9 @@ const globalState = {
loopStartTime: 0, loopStartTime: 0,
loopEndTime: 8, loopEndTime: 8,
resizeMode: "trim", resizeMode: "trim",
selectedClipId: null, selectedClipId: null,
selectedPlaylistClipId: null,
selectedPlaylistPatternIndex: null,
isRecording: false, isRecording: false,
clipboard: null, clipboard: null,
lastRulerClickTime: 0, lastRulerClickTime: 0,
@ -58,6 +60,24 @@ function makePatternSnapshot() {
name: t.name, name: t.name,
type: t.type, type: t.type,
samplePath: t.samplePath, 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) => ({ patterns: (t.patterns || []).map((p) => ({
name: p.name, name: p.name,
steps: p.steps, steps: p.steps,
@ -72,10 +92,12 @@ function makePatternSnapshot() {
})), })),
activeTrackId: appState.pattern.activeTrackId, activeTrackId: appState.pattern.activeTrackId,
activePatternIndex: appState.pattern.activePatternIndex, activePatternIndex: appState.pattern.activePatternIndex,
focusedBasslineId: appState.pattern.focusedBasslineId ?? null,
}; };
} }
// ---------------------- // ----------------------
// Helper: existe snapshot local com áudio? // Helper: existe snapshot local com áudio?
// ---------------------- // ----------------------
@ -137,6 +159,8 @@ export function resetProjectState() {
loopEndTime: 8, loopEndTime: 8,
resizeMode: "trim", resizeMode: "trim",
selectedClipId: null, selectedClipId: null,
selectedPlaylistClipId: null,
selectedPlaylistPatternIndex: null,
isRecording: false, isRecording: false,
clipboard: null, clipboard: null,
lastRulerClickTime: 0, lastRulerClickTime: 0,

View File

@ -195,4 +195,30 @@ export function adjustValue(inputElement, step) {
inputElement.value = newValue; inputElement.value = newValue;
inputElement.dispatchEvent(new Event("input", { bubbles: true })); inputElement.dispatchEvent(new Event("input", { bubbles: true }));
} }
// ===============================
// 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;
}

View File

@ -347,7 +347,7 @@
<div class="toolbar-divider"></div> <div class="toolbar-divider"></div>
<button <button
id="send-pattern-to-playlist-btn" id="send-pattern-to-song-btn"
class="control-btn" class="control-btn"
title="Renderizar para Áudio" title="Renderizar para Áudio"
> >