// js/audio/audio_ui.js
import { appState } from "../state.js";
import {
addAudioClipToTimeline,
updateAudioClipProperties,
sliceAudioClip,
removeAudioClip,
} from "./audio_state.js";
import {
seekAudioEditor,
restartAudioEditorIfPlaying,
updateTransportLoop,
} from "./audio_audio.js";
import { drawWaveform } from "../waveform.js";
import { PIXELS_PER_BAR, PIXELS_PER_STEP, ZOOM_LEVELS } from "../config.js";
import {
getPixelsPerSecond,
quantizeTime,
getBeatsPerBar,
getSecondsPerStep,
} from "../utils.js";
import { sendAction, sendActionSafe } from "../socket.js";
// =====================================================
// Playlist Patterns (Bassline clips) - Drag/Resize/Delete
// =====================================================
const PL_TICKS_PER_STEP = 12;
const PL_MIN_LEN_TICKS = PL_TICKS_PER_STEP;
const PL_SNAP_TICKS = PL_TICKS_PER_STEP; // por enquanto 1/16; depois ligamos ao Snap do UI
function _genPlClipId() {
return crypto?.randomUUID?.() || `plc_${Date.now()}_${Math.random().toString(36).slice(2)}`;
}
function _ticksToPx(ticks, stepWidthPx) {
return (Number(ticks) / PL_TICKS_PER_STEP) * stepWidthPx;
}
function _pxToTicks(px, stepWidthPx) {
return Math.round((Number(px) / stepWidthPx) * PL_TICKS_PER_STEP);
}
function _snapTicks(ticks, snap = PL_SNAP_TICKS) {
const s = Math.max(1, Math.floor(snap));
return Math.round(Number(ticks) / s) * s;
}
function _ensureGlobalPlaylistSelectionFields() {
if (appState.global.selectedPlaylistClipId === undefined) {
appState.global.selectedPlaylistClipId = null;
}
if (appState.global.selectedPlaylistPatternIndex === undefined) {
appState.global.selectedPlaylistPatternIndex = null;
}
}
function _findBasslineByPatternIndex(patternIndex) {
return (appState.pattern?.tracks || []).find(
(t) => t.type === "bassline" && Number(t.patternIndex ?? 0) === Number(patternIndex ?? 0)
);
}
function _getPlaylistClipModel(patternIndex, clipId) {
const b = _findBasslineByPatternIndex(patternIndex);
if (!b || !Array.isArray(b.playlist_clips)) return null;
return b.playlist_clips.find((c) => String(c.id) === String(clipId));
}
function _updatePlaylistClipLocal(patternIndex, clipId, props) {
const b = _findBasslineByPatternIndex(patternIndex);
if (!b) return false;
if (!Array.isArray(b.playlist_clips)) b.playlist_clips = [];
const c = b.playlist_clips.find((x) => String(x.id) === String(clipId));
if (!c) return false;
if (props.pos !== undefined) c.pos = Math.max(0, Math.floor(props.pos));
if (props.len !== undefined) c.len = Math.max(PL_MIN_LEN_TICKS, Math.floor(props.len));
b.playlist_clips.sort((a, d) => (a.pos ?? 0) - (d.pos ?? 0));
return true;
}
function _removePlaylistClipLocal(patternIndex, clipId) {
const b = _findBasslineByPatternIndex(patternIndex);
if (!b || !Array.isArray(b.playlist_clips)) return false;
const before = b.playlist_clips.length;
b.playlist_clips = b.playlist_clips.filter((c) => String(c.id) !== String(clipId));
const after = b.playlist_clips.length;
if (appState.global.selectedPlaylistClipId === clipId) {
appState.global.selectedPlaylistClipId = null;
appState.global.selectedPlaylistPatternIndex = null;
}
return after !== before;
}
function _addPlaylistClipLocal(patternIndex, posTicks, lenTicks, name) {
const b = _findBasslineByPatternIndex(patternIndex);
if (!b) return false;
if (!Array.isArray(b.playlist_clips)) b.playlist_clips = [];
const clipId = _genPlClipId();
b.playlist_clips.push({
id: clipId,
pos: Math.max(0, Math.floor(posTicks ?? 0)),
len: Math.max(PL_MIN_LEN_TICKS, Math.floor(lenTicks ?? 192)),
name: name || b.name || `Beat/Bassline ${patternIndex}`,
});
b.playlist_clips.sort((a, d) => (a.pos ?? 0) - (d.pos ?? 0));
return clipId;
}
// Tecla Delete para apagar pattern selecionada na playlist (sem mexer no menu de áudio)
let _playlistKeybindInstalled = false;
function _installPlaylistKeybindOnce() {
if (_playlistKeybindInstalled) return;
_playlistKeybindInstalled = true;
window.addEventListener("keydown", (e) => {
// não atrapalha quando digitando em inputs
const tag = (document.activeElement?.tagName || "").toLowerCase();
if (tag === "input" || tag === "textarea") return;
if (e.key !== "Delete" && e.key !== "Backspace") return;
_ensureGlobalPlaylistSelectionFields();
const clipId = appState.global.selectedPlaylistClipId;
const patternIndex = appState.global.selectedPlaylistPatternIndex;
if (!clipId || patternIndex == null) return;
e.preventDefault();
const ok = confirm("Excluir esta pattern da playlist?");
if (!ok) return;
if (_removePlaylistClipLocal(patternIndex, clipId)) {
sendActionSafe({ type: "REMOVE_PLAYLIST_PATTERN_CLIP", patternIndex, clipId });
renderAudioEditor();
restartAudioEditorIfPlaying();
}
});
}
// Função utilitária: Pattern Editor pode chamar isso para inserir na playlist
export function addActivePatternToPlaylistAt(timeSec = null) {
_ensureGlobalPlaylistSelectionFields();
const patternIndex = Number(appState.pattern?.activePatternIndex ?? 0);
const secondsPerStep = getSecondsPerStep();
// prioridade: tempo passado > último clique na régua > seek atual
const t =
(timeSec != null ? Number(timeSec) : null) ??
(appState.global.lastRulerClickTime != null ? Number(appState.global.lastRulerClickTime) : null) ??
Number(appState.audio?.audioEditorSeekTime ?? 0);
const steps = Math.max(0, Math.round(t / secondsPerStep));
let posTicks = steps * PL_TICKS_PER_STEP;
posTicks = _snapTicks(posTicks, PL_SNAP_TICKS);
// len default: 1 compasso em ticks (192) — pode refinar depois
const lenTicks = 192;
const newId = _addPlaylistClipLocal(patternIndex, posTicks, lenTicks, `Beat/Bassline ${patternIndex}`);
if (newId) {
sendActionSafe({
type: "ADD_PLAYLIST_PATTERN_CLIP",
patternIndex,
pos: posTicks,
len: lenTicks,
clipId: newId,
name: `Beat/Bassline ${patternIndex}`,
});
renderAudioEditor();
restartAudioEditorIfPlaying();
}
}
// opcional: expõe no window para botão no editor chamar sem import
window.addActivePatternToPlaylistAt = addActivePatternToPlaylistAt;
export function renderAudioEditor() {
const audioEditor = document.querySelector(".audio-editor");
const existingTrackContainer = document.getElementById("audio-track-container");
if (!audioEditor || !existingTrackContainer) return;
// ✅ preserva a posição de scroll mesmo recriando o container
const prevScrollLeft =
(existingTrackContainer?.scrollLeft ?? 0) ||
(appState.audio?.editorScrollLeft ?? 0);
const prevScrollTop =
(existingTrackContainer?.scrollTop ?? 0) ||
(appState.audio?.editorScrollTop ?? 0);
_ensureGlobalPlaylistSelectionFields();
_installPlaylistKeybindOnce();
// --- Identifica o pai real do container ---
const tracksParent = existingTrackContainer.parentElement;
// ✅ NÃO recria o container (preserva scroll e evita “voltar pro início”)
const newTrackContainer = existingTrackContainer;
const scrollEl = newTrackContainer;
// ✅ limpa apenas as lanes antigas (mantém o scroller)
newTrackContainer.innerHTML = "";
// --- CRIAÇÃO E RENDERIZAÇÃO DA RÉGUA ---
let rulerWrapper = tracksParent.querySelector(".ruler-wrapper");
if (!rulerWrapper) {
rulerWrapper = document.createElement("div");
rulerWrapper.className = "ruler-wrapper";
rulerWrapper.innerHTML = `
`;
tracksParent.insertBefore(rulerWrapper, existingTrackContainer);
}
const staticRuler = tracksParent.querySelector("#audio-timeline-ruler");
if (staticRuler && staticRuler.parentElement === tracksParent) {
staticRuler.remove();
}
const oldLoopRegion = tracksParent.querySelector("#loop-region");
const oldPlayhead = tracksParent.querySelector("#playhead");
if(oldLoopRegion) oldLoopRegion.remove();
if(oldPlayhead) oldPlayhead.remove();
const ruler = rulerWrapper.querySelector(".timeline-ruler");
ruler.innerHTML = "";
const pixelsPerSecond = getPixelsPerSecond();
let maxTime = appState.global.loopEndTime || 0;
// áudio (segundos)
(appState.audio.clips || []).forEach((clip) => {
const endTime =
(clip.startTimeInSeconds || 0) + (clip.durationInSeconds || 0);
if (endTime > maxTime) maxTime = endTime;
});
// basslines (ticks -> steps -> segundos)
const TICKS_PER_STEP = 12; // você já usa 12 na renderização do bassline
const secondsPerStep = getSecondsPerStep();
if (appState.pattern?.tracks) {
appState.pattern.tracks.forEach((t) => {
if (t.type !== "bassline" || !Array.isArray(t.playlist_clips)) return;
t.playlist_clips.forEach((c) => {
const endTicks = (c.pos || 0) + (c.len || 0);
const endSteps = endTicks / TICKS_PER_STEP;
const endTimeSec = endSteps * secondsPerStep;
if (endTimeSec > maxTime) maxTime = endTimeSec;
});
});
}
const containerWidth =
tracksParent.clientWidth || existingTrackContainer.offsetWidth;
const contentWidth = maxTime * pixelsPerSecond;
// não encolhe se já tiver sido expandido antes
const previousWidth = appState.audio.timelineWidthPx || 0;
const totalWidth = Math.max(contentWidth, containerWidth, previousWidth, 2000);
appState.audio.timelineWidthPx = totalWidth;
ruler.style.width = `${totalWidth}px`;
const zoomFactor = ZOOM_LEVELS[appState.global.zoomLevelIndex];
const beatsPerBar = getBeatsPerBar();
const stepWidthPx = PIXELS_PER_STEP * zoomFactor;
const beatWidthPx = stepWidthPx * 4;
const barWidthPx = beatWidthPx * beatsPerBar;
if (barWidthPx > 0) {
const numberOfBars = Math.ceil(totalWidth / barWidthPx);
for (let i = 1; i <= numberOfBars; i++) {
const marker = document.createElement("div");
marker.className = "ruler-marker";
marker.textContent = i;
marker.style.left = `${(i - 1) * barWidthPx}px`;
ruler.appendChild(marker);
}
}
const loopRegion = document.createElement("div");
loopRegion.id = "loop-region";
loopRegion.style.left = `${
appState.global.loopStartTime * pixelsPerSecond
}px`;
loopRegion.style.width = `${
(appState.global.loopEndTime - appState.global.loopStartTime) *
pixelsPerSecond
}px`;
loopRegion.innerHTML = ``;
loopRegion.classList.toggle("visible", appState.global.isLoopActive);
ruler.appendChild(loopRegion);
// --- LISTENER DA RÉGUA ---
const newRuler = ruler.cloneNode(true);
ruler.parentNode.replaceChild(newRuler, ruler);
newRuler.addEventListener("mousedown", (e) => {
document.getElementById("timeline-context-menu").style.display = "none";
document.getElementById("ruler-context-menu").style.display = "none";
const currentPixelsPerSecond = getPixelsPerSecond();
const loopHandle = e.target.closest(".loop-handle");
const loopRegionBody = e.target.closest("#loop-region:not(.loop-handle)");
// Drag Handle Loop
if (loopHandle) {
e.preventDefault();
e.stopPropagation();
const handleType = loopHandle.classList.contains("left") ? "left" : "right";
const initialMouseX = e.clientX;
const initialStart = appState.global.loopStartTime;
const initialEnd = appState.global.loopEndTime;
const onMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - initialMouseX;
const deltaTime = deltaX / currentPixelsPerSecond;
let newStart = appState.global.loopStartTime;
let newEnd = appState.global.loopEndTime;
if (handleType === "left") {
newStart = Math.max(0, initialStart + deltaTime);
newStart = Math.min(newStart, appState.global.loopEndTime - 0.1);
appState.global.loopStartTime = newStart;
} else {
newEnd = Math.max(
appState.global.loopStartTime + 0.1,
initialEnd + deltaTime
);
appState.global.loopEndTime = newEnd;
}
updateTransportLoop();
const loopRegionEl = newRuler.querySelector("#loop-region");
if (loopRegionEl) {
loopRegionEl.style.left = `${newStart * currentPixelsPerSecond}px`;
loopRegionEl.style.width = `${(newEnd - newStart) * currentPixelsPerSecond}px`;
}
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
sendAction({
type: "SET_LOOP_STATE",
isLoopActive: appState.global.isLoopActive,
loopStartTime: appState.global.loopStartTime,
loopEndTime: appState.global.loopEndTime,
});
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
return;
}
// Drag Body Loop
if (loopRegionBody) {
e.preventDefault();
e.stopPropagation();
const initialMouseX = e.clientX;
const initialStart = appState.global.loopStartTime;
const initialEnd = appState.global.loopEndTime;
const initialDuration = initialEnd - initialStart;
const onMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - initialMouseX;
const deltaTime = deltaX / currentPixelsPerSecond;
let newStart = Math.max(0, initialStart + deltaTime);
let newEnd = newStart + initialDuration;
appState.global.loopStartTime = newStart;
appState.global.loopEndTime = newEnd;
updateTransportLoop();
const loopRegionEl = newRuler.querySelector("#loop-region");
if (loopRegionEl)
loopRegionEl.style.left = `${newStart * currentPixelsPerSecond}px`;
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
sendAction({
type: "SET_LOOP_STATE",
isLoopActive: appState.global.isLoopActive,
loopStartTime: appState.global.loopStartTime,
loopEndTime: appState.global.loopEndTime,
});
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
return;
}
// Seek na Régua
e.preventDefault();
const handleSeek = (event) => {
const rect = newRuler.getBoundingClientRect();
const scrollLeft = scrollEl.scrollLeft;
const clickX = event.clientX - rect.left;
const absoluteX = clickX + scrollLeft;
const newTime = absoluteX / currentPixelsPerSecond;
sendAction({ type: "SET_SEEK_TIME", seekTime: newTime });
};
handleSeek(e);
const onMouseMoveSeek = (moveEvent) => handleSeek(moveEvent);
const onMouseUpSeek = () => {
document.removeEventListener("mousemove", onMouseMoveSeek);
document.removeEventListener("mouseup", onMouseUpSeek);
};
document.addEventListener("mousemove", onMouseMoveSeek);
document.addEventListener("mouseup", onMouseUpSeek);
});
// Menu Contexto Régua
newRuler.addEventListener("contextmenu", (e) => {
e.preventDefault();
document.getElementById("timeline-context-menu").style.display = "none";
const menu = document.getElementById("ruler-context-menu");
const currentPixelsPerSecond = getPixelsPerSecond();
const rect = newRuler.getBoundingClientRect();
const scrollLeft = scrollEl.scrollLeft;
const clickX = e.clientX - rect.left;
const absoluteX = clickX + scrollLeft;
const clickTime = absoluteX / currentPixelsPerSecond;
appState.global.lastRulerClickTime = clickTime;
menu.style.display = "block";
menu.style.left = `${e.clientX}px`;
menu.style.top = `${e.clientY}px`;
});
// === RENDERIZAÇÃO DAS PISTAS (LANES) ===
// CORREÇÃO: Junta as pistas de áudio com as Basslines (que estão no Pattern State)
const tracksToRender = [...(appState.audio.tracks || [])];
if (appState.pattern && appState.pattern.tracks) {
appState.pattern.tracks.forEach(pTrack => {
// Adiciona se for bassline e ainda não existir na lista
if (pTrack.type === 'bassline' && !tracksToRender.find(t => t.id === pTrack.id)) {
tracksToRender.push(pTrack);
}
});
}
function getLoopStepsForBasslineLane(basslineTrack) {
const patternIndex = basslineTrack.patternIndex ?? 0;
// pega os instrumentos que pertencem a esse rack (mesma lógica do pattern_ui) :contentReference[oaicite:1]{index=1}
const srcId = basslineTrack.instrumentSourceId || basslineTrack.id;
const children = (appState.pattern.tracks || []).filter(
(t) => t.type !== "bassline" && t.parentBasslineId === srcId && !t.muted
);
let maxSteps = 0;
for (const t of children) {
const p = t.patterns?.[patternIndex];
if (!p) continue;
if (Array.isArray(p.steps) && p.steps.length > 0) {
maxSteps = Math.max(maxSteps, p.steps.length);
continue;
}
// fallback pra patterns que só têm notes
if (Array.isArray(p.notes) && p.notes.length > 0) {
let endTick = 0;
for (const n of p.notes) {
const pos = Number(n.pos) || 0;
const len = Math.max(0, Number(n.len) || 0);
endTick = Math.max(endTick, pos + len);
}
const steps = Math.ceil(endTick / TICKS_PER_STEP);
maxSteps = Math.max(maxSteps, steps);
}
}
if (maxSteps <= 0) maxSteps = 16; // default
// arredonda pra múltiplo de 16 (bem “LMMS feel”)
maxSteps = Math.ceil(maxSteps / 16) * 16;
return maxSteps;
}
tracksToRender.forEach((trackData) => {
const audioTrackLane = document.createElement("div");
audioTrackLane.className = "audio-track-lane";
audioTrackLane.dataset.trackId = trackData.id;
audioTrackLane.style.minWidth = `calc(var(--track-info-width) + ${totalWidth}px)`;
// Ícone dinâmico
let iconHTML = '';
if(trackData.type === 'bassline') iconHTML = '';
audioTrackLane.innerHTML = `
`;
newTrackContainer.appendChild(audioTrackLane);
const timelineContainer = audioTrackLane.querySelector(".timeline-container");
const grid = timelineContainer.querySelector(".spectrogram-view-grid");
// Configura variáveis CSS
grid.style.setProperty("--step-width", `${stepWidthPx}px`);
grid.style.setProperty("--beat-width", `${beatWidthPx}px`);
grid.style.setProperty("--bar-width", `${barWidthPx}px`);
// Drag & Drop
timelineContainer.addEventListener("dragover", (e) => {
e.preventDefault();
audioTrackLane.classList.add("drag-over");
});
timelineContainer.addEventListener("dragleave", () =>
audioTrackLane.classList.remove("drag-over")
);
timelineContainer.addEventListener("drop", (e) => {
e.preventDefault();
audioTrackLane.classList.remove("drag-over");
const filePath = e.dataTransfer.getData("text/plain");
if (!filePath) return;
const rect = timelineContainer.getBoundingClientRect();
const dropX = e.clientX - rect.left + scrollEl.scrollLeft;
let startTimeInSeconds = dropX / pixelsPerSecond;
startTimeInSeconds = quantizeTime(startTimeInSeconds);
if (!trackData.id || startTimeInSeconds == null || isNaN(startTimeInSeconds)) return;
const clipId = crypto?.randomUUID?.() || `clip_${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
addAudioClipToTimeline(filePath, trackData.id, startTimeInSeconds, clipId);
try {
sendAction({
type: "ADD_AUDIO_CLIP",
filePath,
trackId: trackData.id,
startTimeInSeconds,
clipId,
name: String(filePath).split(/[\\/]/).pop(),
});
} catch (err) {
console.warn("[SYNC] Falha ao emitir ADD_AUDIO_CLIP", err);
}
});
// --- RENDERIZAÇÃO DOS CLIPES DE BASSLINE (Blocos Azuis) ---
if (trackData.type === "bassline" && trackData.playlist_clips) {
trackData.playlist_clips.forEach((clip) => {
// garante id (sem quebrar projetos antigos)
if (!clip.id) clip.id = _genPlClipId();
const clipDiv = document.createElement("div");
clipDiv.className = "timeline-clip bassline-clip";
clipDiv.dataset.patternIndex = String(trackData.patternIndex ?? 0);
clipDiv.dataset.plClipId = String(clip.id);
if (String(clip.id) === String(appState.global.selectedPlaylistClipId)) {
clipDiv.classList.add("selected");
}
// ticks -> steps -> px
const steps = (clip.pos || 0) / PL_TICKS_PER_STEP;
const lengthInSteps = (clip.len || 0) / PL_TICKS_PER_STEP;
const leftPos = steps * stepWidthPx;
const widthDim = Math.max(1, lengthInSteps * stepWidthPx);
clipDiv.style.position = "absolute";
clipDiv.style.left = `${leftPos}px`;
clipDiv.style.width = `${widthDim}px`;
clipDiv.style.height = "100%";
clipDiv.style.boxSizing = "border-box";
clipDiv.style.cursor = "pointer";
clipDiv.style.zIndex = "5";
clipDiv.title = `${clip.name} (pos:${clip.pos}, len:${clip.len})`;
// grid visual igual ao seu (mantém “look”)
const gridStyle = getComputedStyle(grid);
clipDiv.style.backgroundImage = gridStyle.backgroundImage;
clipDiv.style.backgroundSize = gridStyle.backgroundSize;
clipDiv.style.backgroundRepeat = "repeat";
clipDiv.style.backgroundPosition = `-${leftPos}px 0px`;
clipDiv.style.backgroundColor = "rgba(0, 170, 170, 0.6)";
clipDiv.style.border = "1px solid #00aaaa";
// handles de resize (não usa .clip-resize-handle pra não conflitar com áudio)
const leftHandle = document.createElement("div");
leftHandle.className = "pattern-resize-handle left";
const rightHandle = document.createElement("div");
rightHandle.className = "pattern-resize-handle right";
clipDiv.appendChild(leftHandle);
clipDiv.appendChild(rightHandle);
// ✅ overlay de “marquinhas pretas” (loop interno da pattern)
const loopSteps = getLoopStepsForBasslineLane(trackData);
const loopPx = loopSteps * stepWidthPx;
if (loopPx > 0) {
const markers = document.createElement("div");
markers.style.position = "absolute";
markers.style.inset = "0";
markers.style.pointerEvents = "none";
markers.style.opacity = "0.9";
markers.style.backgroundImage = `repeating-linear-gradient(
to right,
rgba(0,0,0,0.75) 0px,
rgba(0,0,0,0.75) 2px,
transparent 2px,
transparent ${loopPx}px
)`;
markers.style.zIndex = "6";
clipDiv.appendChild(markers);
}
// label
const label = document.createElement("span");
label.innerText = clip.name;
label.style.fontSize = "0.7rem";
label.style.color = "#fff";
label.style.padding = "4px";
label.style.pointerEvents = "none";
label.style.whiteSpace = "nowrap";
label.style.overflow = "hidden";
label.style.position = "relative";
label.style.zIndex = "7";
clipDiv.appendChild(label);
// duplo clique abre editor (seu comportamento atual)
clipDiv.addEventListener("dblclick", (e) => {
e.stopPropagation();
if (window.openPatternEditor) {
window.openPatternEditor(trackData);
} else {
console.error("Função window.openPatternEditor não encontrada.");
}
});
grid.appendChild(clipDiv);
});
}
});
// === RENDERIZAÇÃO DE CLIPES DE ÁUDIO (Samples) ===
appState.audio.clips.forEach((clip) => {
// Busca a pista correta (pode ser nova ou antiga)
const parentGrid = newTrackContainer.querySelector(
`.audio-track-lane[data-track-id="${clip.trackId}"] .spectrogram-view-grid`
);
if (!parentGrid) return; // Se o clipe aponta pra uma pista que não existe, ignora
const clipElement = document.createElement("div");
clipElement.className = "timeline-clip";
clipElement.dataset.clipId = clip.id;
if (clip.id === appState.global.selectedClipId)
clipElement.classList.add("selected");
if (appState.global.clipboard?.cutSourceId === clip.id)
clipElement.classList.add("cut");
clipElement.style.left = `${
(clip.startTimeInSeconds || 0) * pixelsPerSecond
}px`;
clipElement.style.width = `${
(clip.durationInSeconds || 0) * pixelsPerSecond
}px`;
let pitchStr =
clip.pitch > 0 ? `+${clip.pitch.toFixed(1)}` : `${clip.pitch.toFixed(1)}`;
if (clip.pitch === 0) pitchStr = "";
clipElement.innerHTML = ` ${clip.name} ${pitchStr} `;
if (
clip.patternData &&
Array.isArray(clip.patternData) &&
clip.patternData.length > 0
) {
clipElement.classList.add("pattern-clip");
const totalSteps = clip.patternData[0]?.length || 0;
if (totalSteps > 0) {
const patternViewEl = createPatternViewElement(
clip.patternData,
totalSteps
);
clipElement.appendChild(patternViewEl);
}
}
parentGrid.appendChild(clipElement);
if (clip.buffer) {
const canvas = clipElement.querySelector(".waveform-canvas-clip");
const canvasWidth = (clip.durationInSeconds || 0) * pixelsPerSecond;
if (canvasWidth > 0) {
canvas.width = canvasWidth;
canvas.height = 40;
const audioBuffer = clip.buffer;
const isStretched = clip.pitch !== 0;
const sourceOffset = isStretched ? 0 : clip.offset || 0;
const sourceDuration = isStretched
? clip.originalDuration
: clip.durationInSeconds;
drawWaveform(
canvas,
audioBuffer,
"var(--accent-green)",
sourceOffset,
sourceDuration
);
}
}
clipElement.addEventListener("wheel", (e) => {
e.preventDefault();
const clipToUpdate = appState.audio.clips.find(
(c) => c.id == clipElement.dataset.clipId
);
if (!clipToUpdate) return;
const direction = e.deltaY < 0 ? 1 : -1;
let newPitch = clipToUpdate.pitch + direction;
newPitch = Math.max(-24, Math.min(24, newPitch));
updateAudioClipProperties(clipToUpdate.id, { pitch: newPitch });
try {
sendAction({
type: "UPDATE_AUDIO_CLIP",
clipId: clipToUpdate.id,
props: { pitch: newPitch },
});
} catch (err) {
console.warn("[SYNC] Falha ao emitir UPDATE_AUDIO_CLIP (wheel)", err);
}
renderAudioEditor();
restartAudioEditorIfPlaying();
});
});
// Sync Scroll
function appendRulerMarkers(rulerEl, oldWidth, newWidth, barWidthPx) {
if (!rulerEl || barWidthPx <= 0) return;
const loopEl = rulerEl.querySelector("#loop-region");
const startBar = Math.floor(oldWidth / barWidthPx) + 1;
const endBar = Math.ceil(newWidth / barWidthPx);
for (let i = startBar; i <= endBar; i++) {
const marker = document.createElement("div");
marker.className = "ruler-marker";
marker.textContent = i;
marker.style.left = `${(i - 1) * barWidthPx}px`;
// mantém loop por cima/ordem estável
rulerEl.insertBefore(marker, loopEl || null);
}
}
function applyTimelineWidth(widthPx) {
appState.audio.timelineWidthPx = widthPx;
const r = tracksParent.querySelector(".timeline-ruler");
if (r) r.style.width = `${widthPx}px`;
tracksParent
.querySelectorAll(".spectrogram-view-grid")
.forEach((g) => (g.style.width = `${widthPx}px`));
}
// Sync Scroll (rebinding para não acumular listeners a cada render)
newTrackContainer.addEventListener("scroll", () => {
if (newTrackContainer.__onScroll) {
newTrackContainer.removeEventListener("scroll", newTrackContainer.__onScroll);
}
newTrackContainer.__onScroll = () => {
const scrollPos = scrollEl.scrollLeft;
// guarda no estado
appState.audio.editorScrollLeft = scrollPos;
appState.audio.editorScrollTop = scrollEl.scrollTop || 0;
// sincroniza régua com o container
const mainRuler = tracksParent.querySelector(".timeline-ruler");
if (mainRuler && mainRuler.scrollLeft !== scrollPos) {
mainRuler.scrollLeft = scrollPos;
}
// expansão "infinita"
const threshold = 300;
const rightEdge = scrollPos + newTrackContainer.clientWidth;
const currentWidth = appState.audio.timelineWidthPx || totalWidth;
if (rightEdge > currentWidth - threshold) {
const newWidth = Math.ceil(currentWidth * 1.5);
applyTimelineWidth(newWidth);
const rulerEl = tracksParent.querySelector(".timeline-ruler");
appendRulerMarkers(rulerEl, currentWidth, newWidth, barWidthPx);
}
};
newTrackContainer.addEventListener("scroll", newTrackContainer.__onScroll);
});
// Event Listener Principal (mousedown) - rebinding
if (newTrackContainer.__onMouseDown) {
newTrackContainer.removeEventListener("mousedown", newTrackContainer.__onMouseDown);
}
// Event Listener Principal (mousedown)
newTrackContainer.__onMouseDown = (e) => {
document.getElementById("timeline-context-menu").style.display = "none";
document.getElementById("ruler-context-menu").style.display = "none";
const clipElement = e.target.closest(".timeline-clip");
const isBasslineClip =
!!(clipElement && clipElement.classList.contains("bassline-clip"));
// ✅ limpa seleções ao clicar no vazio (sem mexer no RMB)
if (!clipElement && e.button !== 2) {
if (appState.global.selectedClipId) {
appState.global.selectedClipId = null;
}
if (appState.global.selectedPlaylistClipId) {
appState.global.selectedPlaylistClipId = null;
appState.global.selectedPlaylistPatternIndex = null;
}
newTrackContainer
.querySelectorAll(".timeline-clip.selected")
.forEach((c) => c.classList.remove("selected"));
}
const currentPixelsPerSecond = getPixelsPerSecond();
const handle = e.target.closest(".clip-resize-handle");
const patternHandle = e.target.closest(".pattern-resize-handle");
// ✅ se clicou num clip de áudio, deseleciona a pattern da playlist
if (clipElement && !isBasslineClip && e.button === 0) {
if (appState.global.selectedPlaylistClipId) {
appState.global.selectedPlaylistClipId = null;
appState.global.selectedPlaylistPatternIndex = null;
newTrackContainer
.querySelectorAll(".bassline-clip.selected")
.forEach((c) => c.classList.remove("selected"));
}
}
// Slice Tool (áudio apenas)
if (
appState.global.sliceToolActive &&
clipElement &&
!clipElement.classList.contains("bassline-clip")
) {
e.preventDefault();
e.stopPropagation();
const clipId = clipElement.dataset.clipId;
const timelineContainer = clipElement.closest(".timeline-container");
const rect = timelineContainer.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const absoluteX = clickX + scrollEl.scrollLeft;
let sliceTimeInTimeline = absoluteX / currentPixelsPerSecond;
sliceTimeInTimeline = quantizeTime(sliceTimeInTimeline);
sliceAudioClip(clipId, sliceTimeInTimeline);
try {
sendAction({
type: "UPDATE_AUDIO_CLIP",
clipId,
props: { __operation: "slice", sliceTimeInTimeline },
});
} catch (err) {
console.warn("[SYNC] Falha ao emitir UPDATE_AUDIO_CLIP (slice)", err);
}
renderAudioEditor();
return;
}
// =========================================================
// ✅ BASSLINE / PATTERN CLIPS (drag horizontal + resize L/R)
// =========================================================
if (isBasslineClip && e.button === 0) {
e.preventDefault();
_ensureGlobalPlaylistSelectionFields();
const patternIndex = Number(clipElement.dataset.patternIndex ?? 0);
const plClipId = String(clipElement.dataset.plClipId || "");
if (!plClipId) return;
// seleção visual/estado
appState.global.selectedPlaylistClipId = plClipId;
appState.global.selectedPlaylistPatternIndex = patternIndex;
// desmarca seleção de áudio se tiver
if (appState.global.selectedClipId) {
appState.global.selectedClipId = null;
newTrackContainer
.querySelectorAll(".timeline-clip.selected")
.forEach((c) => c.classList.remove("selected"));
}
newTrackContainer
.querySelectorAll(".bassline-clip.selected")
.forEach((c) => c.classList.remove("selected"));
clipElement.classList.add("selected");
// (opcional) sync de seleção
sendActionSafe({
type: "SELECT_PLAYLIST_PATTERN_CLIP",
patternIndex,
clipId: plClipId,
});
const model = _getPlaylistClipModel(patternIndex, plClipId);
if (!model) return;
const initialMouseX = e.clientX;
const initialScrollLeft = scrollEl.scrollLeft;
const initialPosTicks = Number(model.pos || 0);
const initialLenTicks = Math.max(
PL_MIN_LEN_TICKS,
Number(model.len || PL_MIN_LEN_TICKS)
);
const initialEndTicks = initialPosTicks + initialLenTicks;
const previewUpdate = (posTicks, lenTicks) => {
const leftPx = _ticksToPx(posTicks, stepWidthPx);
const widthPx = Math.max(1, _ticksToPx(lenTicks, stepWidthPx));
clipElement.style.left = `${leftPx}px`;
clipElement.style.width = `${widthPx}px`;
// mantém grid alinhado com a timeline
clipElement.style.backgroundPosition = `-${leftPx}px 0px`;
};
// ---------- RESIZE ----------
if (patternHandle) {
const handleType = patternHandle.classList.contains("left")
? "left"
: "right";
clipElement.classList.add("dragging");
const onMouseMove = (moveEvent) => {
const deltaPx =
(moveEvent.clientX - initialMouseX) +
(scrollEl.scrollLeft - initialScrollLeft);
const deltaTicks = _pxToTicks(deltaPx, stepWidthPx);
let newPos = initialPosTicks;
let newLen = initialLenTicks;
if (handleType === "right") {
let newEnd = _snapTicks(initialEndTicks + deltaTicks, PL_SNAP_TICKS);
newEnd = Math.max(initialPosTicks + PL_MIN_LEN_TICKS, newEnd);
newLen = newEnd - initialPosTicks;
} else {
newPos = _snapTicks(initialPosTicks + deltaTicks, PL_SNAP_TICKS);
newPos = Math.max(0, newPos);
newPos = Math.min(newPos, initialEndTicks - PL_MIN_LEN_TICKS);
newLen = initialEndTicks - newPos;
}
previewUpdate(newPos, newLen);
};
const onMouseUp = () => {
clipElement.classList.remove("dragging");
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
// converte estado final (px -> ticks) com snap
const finalLeftPx = clipElement.offsetLeft;
const finalWidthPx = clipElement.offsetWidth;
let finalPos = _pxToTicks(finalLeftPx, stepWidthPx);
let finalLen = _pxToTicks(finalWidthPx, stepWidthPx);
finalPos = _snapTicks(finalPos, PL_SNAP_TICKS);
finalLen = _snapTicks(finalLen, PL_SNAP_TICKS);
finalPos = Math.max(0, finalPos);
finalLen = Math.max(PL_MIN_LEN_TICKS, finalLen);
_updatePlaylistClipLocal(patternIndex, plClipId, {
pos: finalPos,
len: finalLen,
});
sendActionSafe({
type: "UPDATE_PLAYLIST_PATTERN_CLIP",
patternIndex,
clipId: plClipId,
pos: finalPos,
len: finalLen,
});
renderAudioEditor();
restartAudioEditorIfPlaying();
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
return;
}
// ---------- DRAG (horizontal apenas) ----------
clipElement.classList.add("dragging");
const onMouseMove = (moveEvent) => {
const deltaPx =
(moveEvent.clientX - initialMouseX) +
(scrollEl.scrollLeft - initialScrollLeft);
let newPos = initialPosTicks + _pxToTicks(deltaPx, stepWidthPx);
newPos = _snapTicks(newPos, PL_SNAP_TICKS);
newPos = Math.max(0, newPos);
previewUpdate(newPos, initialLenTicks);
};
const onMouseUp = () => {
clipElement.classList.remove("dragging");
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
const finalLeftPx = clipElement.offsetLeft;
let finalPos = _pxToTicks(finalLeftPx, stepWidthPx);
finalPos = _snapTicks(finalPos, PL_SNAP_TICKS);
finalPos = Math.max(0, finalPos);
_updatePlaylistClipLocal(patternIndex, plClipId, {
pos: finalPos,
len: initialLenTicks,
});
sendActionSafe({
type: "UPDATE_PLAYLIST_PATTERN_CLIP",
patternIndex,
clipId: plClipId,
pos: finalPos,
len: initialLenTicks,
});
renderAudioEditor();
restartAudioEditorIfPlaying();
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
return;
}
// =========================================================
// Resize Handle (ÁUDIO)
// =========================================================
if (handle) {
e.preventDefault();
e.stopPropagation();
const clipId = clipElement.dataset.clipId;
const clip = appState.audio.clips.find((c) => c.id == clipId);
if (!clip || !clip.buffer) return;
const handleType = handle.classList.contains("left") ? "left" : "right";
const initialMouseX = e.clientX;
const secondsPerStep = getSecondsPerStep();
const initialLeftPx = clipElement.offsetLeft;
const initialWidthPx = clipElement.offsetWidth;
const initialStartTime = clip.startTimeInSeconds;
const initialDuration = clip.durationInSeconds;
const initialOffset = clip.offset || 0;
const initialOriginalDuration =
clip.originalDuration || clip.buffer.duration;
const bufferStartTime = initialStartTime - initialOffset;
const onMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - initialMouseX;
if (appState.global.resizeMode === "trim") {
if (handleType === "right") {
let newWidthPx = initialWidthPx + deltaX;
let newDuration = newWidthPx / currentPixelsPerSecond;
let newEndTime = quantizeTime(initialStartTime + newDuration);
newEndTime = Math.max(initialStartTime + secondsPerStep, newEndTime);
const maxEndTime = bufferStartTime + initialOriginalDuration;
newEndTime = Math.min(newEndTime, maxEndTime);
clipElement.style.width = `${
(newEndTime - initialStartTime) * currentPixelsPerSecond
}px`;
} else if (handleType === "left") {
let newLeftPx = initialLeftPx + deltaX;
let newStartTime = newLeftPx / currentPixelsPerSecond;
newStartTime = quantizeTime(newStartTime);
const minStartTime =
initialStartTime + initialDuration - secondsPerStep;
newStartTime = Math.min(newStartTime, minStartTime);
newStartTime = Math.max(bufferStartTime, newStartTime);
const newLeftFinalPx = newStartTime * currentPixelsPerSecond;
const newWidthFinalPx =
(initialStartTime + initialDuration - newStartTime) *
currentPixelsPerSecond;
clipElement.style.left = `${newLeftFinalPx}px`;
clipElement.style.width = `${newWidthFinalPx}px`;
}
} else if (appState.global.resizeMode === "stretch") {
if (handleType === "right") {
let newWidthPx = initialWidthPx + deltaX;
let newDuration = newWidthPx / currentPixelsPerSecond;
let newEndTime = quantizeTime(initialStartTime + newDuration);
newEndTime = Math.max(initialStartTime + secondsPerStep, newEndTime);
clipElement.style.width = `${
(newEndTime - initialStartTime) * currentPixelsPerSecond
}px`;
} else if (handleType === "left") {
let newLeftPx = initialLeftPx + deltaX;
let newStartTime = newLeftPx / currentPixelsPerSecond;
newStartTime = quantizeTime(newStartTime);
const minStartTime =
initialStartTime + initialDuration - secondsPerStep;
newStartTime = Math.min(newStartTime, minStartTime);
const newLeftFinalPx = newStartTime * currentPixelsPerSecond;
const newWidthFinalPx =
(initialStartTime + initialDuration - newStartTime) *
currentPixelsPerSecond;
clipElement.style.left = `${newLeftFinalPx}px`;
clipElement.style.width = `${newWidthFinalPx}px`;
}
}
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
const finalLeftPx = clipElement.offsetLeft;
const finalWidthPx = clipElement.offsetWidth;
const newStartTime = finalLeftPx / currentPixelsPerSecond;
const newDuration = finalWidthPx / currentPixelsPerSecond;
if (appState.global.resizeMode === "trim") {
const newOffset = newStartTime - bufferStartTime;
if (handleType === "right") {
updateAudioClipProperties(clipId, {
durationInSeconds: newDuration,
pitch: 0,
});
sendActionSafe({
type: "UPDATE_AUDIO_CLIP",
clipId,
props: { durationInSeconds: newDuration, pitch: 0 },
});
} else {
updateAudioClipProperties(clipId, {
startTimeInSeconds: newStartTime,
durationInSeconds: newDuration,
offset: newOffset,
pitch: 0,
});
sendActionSafe({
type: "UPDATE_AUDIO_CLIP",
clipId,
props: {
startTimeInSeconds: newStartTime,
durationInSeconds: newDuration,
offset: newOffset,
pitch: 0,
},
});
}
} else {
const newPlaybackRate = initialOriginalDuration / newDuration;
const newPitch = 12 * Math.log2(newPlaybackRate);
if (handleType === "right") {
updateAudioClipProperties(clipId, {
durationInSeconds: newDuration,
pitch: newPitch,
offset: 0,
});
sendActionSafe({
type: "UPDATE_AUDIO_CLIP",
clipId,
props: { durationInSeconds: newDuration, pitch: newPitch, offset: 0 },
});
} else {
updateAudioClipProperties(clipId, {
startTimeInSeconds: newStartTime,
durationInSeconds: newDuration,
pitch: newPitch,
offset: 0,
});
sendActionSafe({
type: "UPDATE_AUDIO_CLIP",
clipId,
props: {
startTimeInSeconds: newStartTime,
durationInSeconds: newDuration,
pitch: newPitch,
offset: 0,
},
});
}
}
restartAudioEditorIfPlaying();
renderAudioEditor();
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
return;
}
// Drag Clip (Audio apenas)
if (clipElement && !clipElement.classList.contains("bassline-clip")) {
const clipId = clipElement.dataset.clipId;
const clipModel = appState.audio.clips.find(
(c) => String(c.id) === String(clipId)
);
const initialStartTime = Number(clipModel?.startTimeInSeconds || 0);
e.preventDefault();
const initialMouseX = e.clientX;
const initialScrollLeft = scrollEl.scrollLeft;
clipElement.classList.add("dragging");
let lastOverLane = clipElement.closest(".audio-track-lane");
const onMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - initialMouseX;
clipElement.style.transform = `translateX(${deltaX}px)`;
const overElement = document.elementFromPoint(
moveEvent.clientX,
moveEvent.clientY
);
const overLane = overElement
? overElement.closest(".audio-track-lane")
: null;
if (overLane && overLane !== lastOverLane) {
if (lastOverLane) lastOverLane.classList.remove("drag-over");
overLane.classList.add("drag-over");
lastOverLane = overLane;
}
};
const onMouseUp = (upEvent) => {
clipElement.classList.remove("dragging");
if (lastOverLane) lastOverLane.classList.remove("drag-over");
clipElement.style.transform = "";
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
const finalLane = lastOverLane;
if (!finalLane) return;
const newTrackId = finalLane.dataset.trackId;
const deltaX =
(upEvent.clientX - initialMouseX) +
(scrollEl.scrollLeft - initialScrollLeft);
let newStartTime =
initialStartTime + deltaX / currentPixelsPerSecond;
newStartTime = Math.max(0, newStartTime);
newStartTime = quantizeTime(newStartTime);
updateAudioClipProperties(clipId, {
trackId: newTrackId,
startTimeInSeconds: newStartTime,
});
sendActionSafe({
type: "UPDATE_AUDIO_CLIP",
clipId,
props: { trackId: newTrackId, startTimeInSeconds: newStartTime },
});
renderAudioEditor();
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
return;
}
// Seek na Pista
const timelineContainer = e.target.closest(".timeline-container");
if (timelineContainer) {
e.preventDefault();
const handleSeek = (event) => {
const rect = timelineContainer.getBoundingClientRect();
const scrollLeft = scrollEl.scrollLeft;
const clickX = event.clientX - rect.left;
const absoluteX = clickX + scrollLeft;
const newTime = absoluteX / currentPixelsPerSecond;
sendAction({ type: "SET_SEEK_TIME", seekTime: newTime });
};
handleSeek(e);
const onMouseMoveSeek = (moveEvent) => handleSeek(moveEvent);
const onMouseUpSeek = () => {
document.removeEventListener("mousemove", onMouseMoveSeek);
document.removeEventListener("mouseup", onMouseUpSeek);
};
document.addEventListener("mousemove", onMouseMoveSeek);
document.addEventListener("mouseup", onMouseUpSeek);
}
};
newTrackContainer.addEventListener("mousedown", newTrackContainer.__onMouseDown);
// Menu Contexto Pista
if (newTrackContainer.__onContextMenu) {
newTrackContainer.removeEventListener("contextmenu", newTrackContainer.__onContextMenu);
}
newTrackContainer.__onContextMenu = (e) => {
e.preventDefault();
document.getElementById("ruler-context-menu").style.display = "none";
const menu = document.getElementById("timeline-context-menu");
if (!menu) return;
const clipElement = e.target.closest(".timeline-clip");
const copyItem = document.getElementById("copy-clip");
const cutItem = document.getElementById("cut-clip");
const pasteItem = document.getElementById("paste-clip");
const deleteItem = document.getElementById("delete-clip");
const canPaste = appState.global.clipboard?.type === "audio";
pasteItem.style.display = canPaste ? "block" : "none";
if (clipElement) {
if (clipElement.classList.contains("bassline-clip")) {
const patternIndex = Number(clipElement.dataset.patternIndex ?? 0);
const plClipId = String(clipElement.dataset.plClipId || "");
appState.global.selectedPlaylistClipId = plClipId;
appState.global.selectedPlaylistPatternIndex = patternIndex;
const ok = confirm("Excluir esta pattern da playlist?");
if (ok) {
if (_removePlaylistClipLocal(patternIndex, plClipId)) {
sendActionSafe({ type: "REMOVE_PLAYLIST_PATTERN_CLIP", patternIndex, clipId: plClipId });
renderAudioEditor();
restartAudioEditorIfPlaying();
}
}
menu.style.display = "none";
return;
}
const clipId = clipElement.dataset.clipId;
if (appState.global.selectedClipId !== clipId) {
appState.global.selectedClipId = clipId;
newTrackContainer.querySelectorAll(".timeline-clip.selected").forEach((c) => c.classList.remove("selected"));
clipElement.classList.add("selected");
}
copyItem.style.display = "block";
cutItem.style.display = "block";
deleteItem.style.display = "block";
menu.style.display = "block";
menu.style.left = `${e.clientX}px`;
menu.style.top = `${e.clientY}px`;
if (!deleteItem.__synced) {
deleteItem.__synced = true;
deleteItem.addEventListener("click", () => {
const id = appState.global.selectedClipId;
if (!id) return;
const ok = removeAudioClip(id);
sendActionSafe({ type: "REMOVE_AUDIO_CLIP", clipId: id });
if (ok) renderAudioEditor();
menu.style.display = "none";
});
}
} else {
copyItem.style.display = "none";
cutItem.style.display = "none";
deleteItem.style.display = "none";
if (canPaste) {
menu.style.display = "block";
menu.style.left = `${e.clientX}px`;
menu.style.top = `${e.clientY}px`;
} else {
menu.style.display = "none";
}
}
};
newTrackContainer.addEventListener("contextmenu", newTrackContainer.__onContextMenu);
// ✅ restaura scroll após reconstruir a DOM (precisa ser após tudo estar no DOM)
requestAnimationFrame(() => {
try {
newTrackContainer.scrollLeft = prevScrollLeft;
newTrackContainer.scrollTop = prevScrollTop;
// mantém régua alinhada na mesma posição
const mainRuler = tracksParent.querySelector(".timeline-ruler");
if (mainRuler) mainRuler.scrollLeft = prevScrollLeft;
} catch {}
});
}
export function updateAudioEditorUI() {
const playBtn = document.getElementById("audio-editor-play-btn");
if (!playBtn) return;
if (appState.global.isAudioEditorPlaying) {
playBtn.classList.remove("fa-play");
playBtn.classList.add("fa-pause");
} else {
playBtn.classList.remove("fa-pause");
playBtn.classList.add("fa-play");
}
}
export function updatePlayheadVisual(pixels) {
document.querySelectorAll(".audio-track-lane .playhead").forEach((ph) => {
ph.style.left = `${pixels}px`;
});
}
export function resetPlayheadVisual() {
document.querySelectorAll(".audio-track-lane .playhead").forEach((ph) => {
ph.style.left = "0px";
});
}
function createPatternViewElement(patternData) {
const view = document.createElement("div");
view.className = "pattern-clip-view";
const validTracksData = patternData.filter(
(steps) => Array.isArray(steps) && steps.length > 0
);
const totalSteps = validTracksData.reduce(
(max, steps) => Math.max(max, steps.length),
0
);
if (totalSteps === 0) return view;
validTracksData.forEach((trackSteps) => {
const row = document.createElement("div");
row.className = "pattern-clip-track-row";
const stepWidthPercent = (1 / totalSteps) * 100;
for (let i = 0; i < totalSteps; i++) {
if (trackSteps[i] === true) {
const note = document.createElement("div");
note.className = "pattern-step-note";
note.style.left = `${(i / totalSteps) * 100}%`;
note.style.width = `${stepWidthPercent}%`;
row.appendChild(note);
}
}
view.appendChild(row);
});
return view;
}