summaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/timeline_editor/README.md17
-rw-r--r--tools/timeline_editor/index.html321
2 files changed, 249 insertions, 89 deletions
diff --git a/tools/timeline_editor/README.md b/tools/timeline_editor/README.md
index cc13a41..72b5ae0 100644
--- a/tools/timeline_editor/README.md
+++ b/tools/timeline_editor/README.md
@@ -17,13 +17,26 @@ Interactive web-based editor for `timeline.seq` files.
- šŸŽ¼ Quantize grid (Off, 1/32, 1/16, 1/8, 1/4, 1/2, 1 beat)
- šŸŽ›ļø BPM slider (60-200 BPM)
- šŸ”„ Re-order sequences by time
-- šŸ—‘ļø Delete sequences/effects
+- ✨ Add effects to sequences
+- šŸ—‘ļø Delete sequences/effects (toolbar + properties panel)
+- šŸ“Š **CPU load visualization** (color-coded effect density)
- ā–¶ļø Audio playback with auto-expand/collapse
- šŸŽšļø Sticky audio track and timeline ticks
- šŸ”“ **Playback indicator on waveform** (NEW)
- šŸŽÆ **Double-click seek during playback** (NEW)
- šŸ“ **Click waveform to seek** (NEW)
+## CPU Load Visualization
+
+The editor displays a **CPU load bar** at the top (underneath audio waveform if loaded):
+- **Full-height bars** (80px) show effect density at each time point
+- **Color-coded:** Green (low) → Yellow (medium) → Red (high load)
+- **Load calculation:** Sum of all active effects across all sequences (1.0 per effect)
+- **Updates automatically** when effects/sequences are moved
+- **Collapsed sequences count** toward load
+
+This helps identify performance hotspots in your timeline.
+
## Usage
1. **Open:** `open tools/timeline_editor/index.html` or double-click in browser
@@ -37,6 +50,8 @@ Interactive web-based editor for `timeline.seq` files.
- Watch sequences auto-expand/collapse during playback
- Red playback indicators on both timeline and waveform show current position
5. **Edit:**
+ - **Add Effect:** Select sequence, click "✨ Add Effect" button
+ - **Delete:** Click item, use "šŸ—‘ļø Delete Selected" or delete button in properties panel
- Drag sequences/effects to reposition (works when collapsed or expanded)
- Double-click anywhere on sequence to collapse/expand
- Click item to edit properties in side panel
diff --git a/tools/timeline_editor/index.html b/tools/timeline_editor/index.html
index 45c9f1f..eca7b97 100644
--- a/tools/timeline_editor/index.html
+++ b/tools/timeline_editor/index.html
@@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Timeline Editor - timeline.seq</title>
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' fill='%231e1e1e'/><rect x='10' y='30' width='15' height='40' fill='%234ec9b0'/><rect x='30' y='20' width='15' height='60' fill='%234ec9b0'/><rect x='50' y='35' width='15' height='30' fill='%234ec9b0'/><rect x='70' y='15' width='15' height='70' fill='%234ec9b0'/></svg>">
<style>
:root {
--bg-dark: #1e1e1e;
@@ -45,7 +46,8 @@
.sticky-header { position: sticky; top: 0; background: var(--bg-medium); z-index: 100; padding: 20px 20px 10px 20px; border-bottom: 2px solid var(--bg-light); flex-shrink: 0; }
.waveform-container { position: relative; height: 80px; overflow: hidden; background: rgba(0, 0, 0, 0.3); border-radius: var(--radius); cursor: crosshair; }
- #waveformCanvas { position: absolute; left: 0; top: 0; height: 80px; display: block; }
+ #cpuLoadCanvas { position: absolute; left: 0; bottom: 0; height: 10px; display: block; z-index: 1; }
+ #waveformCanvas { position: absolute; left: 0; top: 0; height: 80px; display: block; z-index: 2; }
.playback-indicator { position: absolute; top: 0; left: 0; width: 2px; background: var(--accent-red); box-shadow: 0 0 4px rgba(244, 135, 113, 0.8); pointer-events: none; z-index: 90; display: block; }
@@ -75,6 +77,8 @@
.effect { position: absolute; background: #3a3d41; border: 1px solid var(--border-color); border-radius: 3px; padding: 4px 8px; cursor: move; font-size: 11px; transition: box-shadow 0.2s; display: flex; align-items: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.effect:hover { box-shadow: 0 0 8px rgba(133, 133, 133, 0.5); background: #45484d; }
.effect.selected { border-color: var(--accent-orange); box-shadow: 0 0 8px rgba(206, 145, 120, 0.5); }
+ .effect.conflict { background: #4a1d1d; border-color: var(--accent-red); box-shadow: 0 0 8px rgba(244, 135, 113, 0.6); }
+ .effect.conflict:hover { background: #5a2424; }
.effect-handle { position: absolute; top: 0; width: 6px; height: 100%; background: rgba(78, 201, 176, 0.8); cursor: ew-resize; display: none; z-index: 10; }
.effect.selected .effect-handle { display: block; }
.effect-handle.left { left: 0; border-radius: 3px 0 0 3px; }
@@ -111,6 +115,7 @@
<label class="file-label">šŸŽµ Load Audio (WAV)<input type="file" id="audioInput" accept=".wav"></label>
<button id="clearAudioBtn" disabled>āœ– Clear Audio</button>
<button id="addSequenceBtn" disabled>āž• Add Sequence</button>
+ <button id="addEffectBtn" disabled>✨ Add Effect</button>
<button id="deleteBtn" disabled>šŸ—‘ļø Delete Selected</button>
<button id="reorderBtn" disabled>šŸ”„ Re-order by Time</button>
</div>
@@ -145,9 +150,10 @@
<div class="timeline-container">
<div class="sticky-header">
- <div class="waveform-container" id="waveformContainer" style="display: none;">
- <div class="playback-indicator" id="waveformPlaybackIndicator"></div>
+ <div class="waveform-container" id="waveformContainer">
+ <canvas id="cpuLoadCanvas"></canvas>
<canvas id="waveformCanvas"></canvas>
+ <div class="playback-indicator" id="waveformPlaybackIndicator"></div>
</div>
<div class="time-markers" id="timeMarkers"></div>
</div>
@@ -171,11 +177,16 @@
</div>
<script>
+ // Constants
+ const POST_PROCESS_EFFECTS = new Set(['FadeEffect', 'FlashEffect', 'GaussianBlurEffect',
+ 'SolarizeEffect', 'VignetteEffect', 'ChromaAberrationEffect', 'DistortEffect',
+ 'ThemeModulationEffect', 'CNNEffect', 'CNNv2Effect']);
+
// State
const state = {
sequences: [], currentFile: null, selectedItem: null, pixelsPerSecond: 100,
showBeats: true, quantizeUnit: 1, bpm: 120, isDragging: false, dragOffset: { x: 0, y: 0 },
- lastActiveSeqIndex: -1, isDraggingHandle: false, handleType: null,
+ lastActiveSeqIndex: -1, isDraggingHandle: false, handleType: null, handleDragOffset: 0,
audioBuffer: null, audioDuration: 0, audioSource: null, audioContext: null,
isPlaying: false, playbackStartTime: 0, playbackOffset: 0, animationFrameId: null,
lastExpandedSeqIndex: -1, dragMoved: false
@@ -191,7 +202,9 @@
clearAudioBtn: document.getElementById('clearAudioBtn'),
waveformCanvas: document.getElementById('waveformCanvas'),
waveformContainer: document.getElementById('waveformContainer'),
+ cpuLoadCanvas: document.getElementById('cpuLoadCanvas'),
addSequenceBtn: document.getElementById('addSequenceBtn'),
+ addEffectBtn: document.getElementById('addEffectBtn'),
deleteBtn: document.getElementById('deleteBtn'),
reorderBtn: document.getElementById('reorderBtn'),
propertiesPanel: document.getElementById('propertiesPanel'),
@@ -264,18 +277,41 @@
return { sequences, bpm };
}
+ // Helpers
+ const beatsToTime = (beats) => beats * 60.0 / state.bpm;
+ const timeToBeats = (seconds) => seconds * state.bpm / 60.0;
+ const beatRange = (start, end) => {
+ const s = start.toFixed(1), e = end.toFixed(1);
+ const ss = beatsToTime(start).toFixed(1), es = beatsToTime(end).toFixed(1);
+ return state.showBeats ? `${s}-${e}b (${ss}-${es}s)` : `${ss}-${es}s (${s}-${e}b)`;
+ };
+
+ function detectConflicts(seq) {
+ const conflicts = new Set();
+ const priorityGroups = {};
+ seq.effects.forEach((effect, idx) => {
+ if (POST_PROCESS_EFFECTS.has(effect.className)) {
+ if (!priorityGroups[effect.priority]) priorityGroups[effect.priority] = [];
+ priorityGroups[effect.priority].push(idx);
+ }
+ });
+ for (const priority in priorityGroups) {
+ if (priorityGroups[priority].length > 1) {
+ for (const idx of priorityGroups[priority]) conflicts.add(idx);
+ }
+ }
+ return conflicts;
+ }
+
function serializeSeqFile(sequences) {
let output = `# Demo Timeline\n# Generated by Timeline Editor\n# BPM ${state.bpm}\n\n`;
for (const seq of sequences) {
- const seqLine = `SEQUENCE ${seq.startTime.toFixed(2)} ${seq.priority}`;
- output += seq.name ? `${seqLine} "${seq.name}"\n` : `${seqLine}\n`;
+ output += `SEQUENCE ${seq.startTime.toFixed(2)} ${seq.priority}${seq.name ? ` "${seq.name}"` : ''}\n`;
for (const effect of seq.effects) {
const modifier = effect.priorityModifier || '+';
+ const cleanArgs = effect.args?.replace(/\s*#\s*Priority:\s*\d+/i, '').trim();
output += ` EFFECT ${modifier} ${effect.className} ${effect.startTime.toFixed(2)} ${effect.endTime.toFixed(2)}`;
- if (effect.args) {
- const cleanArgs = effect.args.replace(/\s*#\s*Priority:\s*\d+/i, '').trim();
- if (cleanArgs) output += ` ${cleanArgs}`;
- }
+ if (cleanArgs) output += ` ${cleanArgs}`;
output += '\n';
}
output += '\n';
@@ -291,7 +327,6 @@
state.audioBuffer = await state.audioContext.decodeAudioData(arrayBuffer);
state.audioDuration = state.audioBuffer.duration;
renderWaveform();
- dom.waveformContainer.style.display = 'block';
dom.playbackControls.style.display = 'flex';
dom.clearAudioBtn.disabled = false;
showMessage(`Audio loaded: ${state.audioDuration.toFixed(2)}s`, 'success');
@@ -304,38 +339,123 @@
function renderWaveform() {
if (!state.audioBuffer) return;
const canvas = dom.waveformCanvas, ctx = canvas.getContext('2d');
- const audioDurationBeats = state.audioDuration * state.bpm / 60.0;
- const canvasWidth = audioDurationBeats * state.pixelsPerSecond, canvasHeight = 80;
- canvas.width = canvasWidth; canvas.height = canvasHeight;
- canvas.style.width = `${canvasWidth}px`; canvas.style.height = `${canvasHeight}px`;
- dom.waveformPlaybackIndicator.style.height = `${canvasHeight}px`;
- ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; ctx.fillRect(0, 0, canvasWidth, canvasHeight);
+ const w = timeToBeats(state.audioDuration) * state.pixelsPerSecond, h = 80;
+ canvas.width = w; canvas.height = h;
+ canvas.style.width = `${w}px`; canvas.style.height = `${h}px`;
+ dom.waveformPlaybackIndicator.style.height = `${h}px`;
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; ctx.fillRect(0, 0, w, h);
+
const channelData = state.audioBuffer.getChannelData(0);
- const samplesPerPixel = Math.ceil(channelData.length / canvasWidth);
+ const samplesPerPixel = Math.ceil(channelData.length / w);
+ const centerY = h / 2, amplitudeScale = h * 0.4;
+
ctx.strokeStyle = '#4ec9b0'; ctx.lineWidth = 1; ctx.beginPath();
- const centerY = canvasHeight / 2, amplitudeScale = canvasHeight * 0.4;
- for (let x = 0; x < canvasWidth; x++) {
- const startSample = Math.floor(x * samplesPerPixel);
- const endSample = Math.min(startSample + samplesPerPixel, channelData.length);
+ for (let x = 0; x < w; x++) {
+ const start = Math.floor(x * samplesPerPixel);
+ const end = Math.min(start + samplesPerPixel, channelData.length);
let min = 1.0, max = -1.0;
- for (let i = startSample; i < endSample; i++) {
- const sample = channelData[i];
- if (sample < min) min = sample;
- if (sample > max) max = sample;
+ for (let i = start; i < end; i++) {
+ min = Math.min(min, channelData[i]);
+ max = Math.max(max, channelData[i]);
}
const yMin = centerY - min * amplitudeScale, yMax = centerY - max * amplitudeScale;
- if (x === 0) ctx.moveTo(x, yMin); else ctx.lineTo(x, yMin);
+ x === 0 ? ctx.moveTo(x, yMin) : ctx.lineTo(x, yMin);
ctx.lineTo(x, yMax);
}
ctx.stroke();
- ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; ctx.lineWidth = 1; ctx.beginPath();
- ctx.moveTo(0, centerY); ctx.lineTo(canvasWidth, centerY); ctx.stroke();
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
+ ctx.beginPath(); ctx.moveTo(0, centerY); ctx.lineTo(w, centerY); ctx.stroke();
+ }
+
+ function computeCPULoad() {
+ if (state.sequences.length === 0) return { maxTime: 60, loads: [], conflicts: [] };
+ let maxTime = Math.max(60, ...state.sequences.flatMap(seq =>
+ seq.effects.map(eff => seq.startTime + eff.endTime)));
+ if (state.audioDuration > 0) maxTime = Math.max(maxTime, timeToBeats(state.audioDuration));
+
+ const resolution = 0.1, numSamples = Math.ceil(maxTime / resolution);
+ const loads = new Array(numSamples).fill(0);
+ const conflicts = new Array(numSamples).fill(false);
+
+ const markConflict = (seq, effect) => {
+ const start = Math.floor((seq.startTime + effect.startTime) / resolution);
+ const end = Math.ceil((seq.startTime + effect.endTime) / resolution);
+ for (let i = start; i < end && i < numSamples; i++) conflicts[i] = true;
+ };
+
+ // Track load
+ state.sequences.forEach(seq => seq.effects.forEach(effect => {
+ const start = Math.floor((seq.startTime + effect.startTime) / resolution);
+ const end = Math.ceil((seq.startTime + effect.endTime) / resolution);
+ for (let i = start; i < end && i < numSamples; i++) loads[i] += 1.0;
+ }));
+
+ // Detect within-sequence conflicts
+ state.sequences.forEach(seq => {
+ const priorityGroups = {};
+ seq.effects.forEach(eff => {
+ if (POST_PROCESS_EFFECTS.has(eff.className)) {
+ (priorityGroups[eff.priority] ??= []).push(eff);
+ }
+ });
+ Object.values(priorityGroups).forEach(group => {
+ if (group.length > 1) group.forEach(eff => markConflict(seq, eff));
+ });
+ });
+
+ // Detect cross-sequence conflicts
+ const timeGroups = {};
+ state.sequences.forEach((seq, idx) =>
+ (timeGroups[seq.startTime.toFixed(2)] ??= []).push(idx));
+
+ Object.values(timeGroups).forEach(seqIndices => {
+ if (seqIndices.length < 2) return;
+ const crossPriorityMap = {};
+ seqIndices.forEach(idx => {
+ const seq = state.sequences[idx];
+ seq.effects.forEach(eff => {
+ if (POST_PROCESS_EFFECTS.has(eff.className)) {
+ (crossPriorityMap[eff.priority] ??= []).push({ effect: eff, seq });
+ }
+ });
+ });
+ Object.values(crossPriorityMap).forEach(group => {
+ if (group.length > 1) group.forEach(({ effect, seq }) => markConflict(seq, effect));
+ });
+ });
+
+ return { maxTime, loads, conflicts, resolution };
+ }
+
+ function renderCPULoad() {
+ const canvas = dom.cpuLoadCanvas, ctx = canvas.getContext('2d');
+ const { maxTime, loads, conflicts, resolution } = computeCPULoad();
+ const w = maxTime * state.pixelsPerSecond, h = 10;
+ canvas.width = w; canvas.height = h;
+ canvas.style.width = `${w}px`; canvas.style.height = `${h}px`;
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; ctx.fillRect(0, 0, w, h);
+ if (loads.length === 0) return;
+
+ const barWidth = resolution * state.pixelsPerSecond;
+ loads.forEach((load, i) => {
+ if (load === 0) return;
+ const n = Math.min(load / 8, 1.0);
+ let r, g, b;
+ if (conflicts[i]) { r = 200; g = 100; b = 90; }
+ else if (n < 0.5) { const t = n * 2; r = 120 + t * 50; g = 180 + t * 20; b = 140; }
+ else { const t = (n - 0.5) * 2; r = 170 + t * 30; g = 200 - t * 50; b = 140; }
+ ctx.fillStyle = `rgba(${r|0}, ${g|0}, ${b|0}, 0.7)`;
+ ctx.fillRect(i * barWidth, 0, barWidth, h);
+ });
}
function clearAudio() {
stopPlayback(); state.audioBuffer = null; state.audioDuration = 0;
- dom.waveformContainer.style.display = 'none'; dom.playbackControls.style.display = 'none';
- dom.clearAudioBtn.disabled = true; renderTimeline(); showMessage('Audio cleared', 'success');
+ dom.playbackControls.style.display = 'none';
+ dom.clearAudioBtn.disabled = true;
+ const ctx = dom.waveformCanvas.getContext('2d');
+ ctx.clearRect(0, 0, dom.waveformCanvas.width, dom.waveformCanvas.height);
+ renderTimeline(); showMessage('Audio cleared', 'success');
}
async function startPlayback() {
@@ -370,18 +490,13 @@
function updatePlaybackPosition() {
if (!state.isPlaying) return;
const elapsed = state.audioContext.currentTime - state.playbackStartTime;
- const currentTime = state.playbackOffset + elapsed, currentBeats = currentTime * state.bpm / 60.0;
+ const currentTime = state.playbackOffset + elapsed;
+ const currentBeats = timeToBeats(currentTime);
dom.playbackTime.textContent = `${currentTime.toFixed(2)}s (${currentBeats.toFixed(2)}b)`;
const indicatorX = currentBeats * state.pixelsPerSecond;
- dom.playbackIndicator.style.left = `${indicatorX}px`;
- dom.waveformPlaybackIndicator.style.left = `${indicatorX}px`;
- const viewportWidth = dom.timelineContent.clientWidth;
- const targetScrollX = indicatorX - viewportWidth * 0.4;
- const currentScrollX = dom.timelineContent.scrollLeft;
- const scrollDiff = targetScrollX - currentScrollX;
- if (Math.abs(scrollDiff) > 5) {
- dom.timelineContent.scrollLeft += scrollDiff * 0.1;
- }
+ dom.playbackIndicator.style.left = dom.waveformPlaybackIndicator.style.left = `${indicatorX}px`;
+ const scrollDiff = indicatorX - dom.timelineContent.clientWidth * 0.4 - dom.timelineContent.scrollLeft;
+ if (Math.abs(scrollDiff) > 5) dom.timelineContent.scrollLeft += scrollDiff * 0.1;
expandSequenceAtTime(currentBeats);
state.animationFrameId = requestAnimationFrame(updatePlaybackPosition);
}
@@ -407,6 +522,7 @@
// Render
function renderTimeline() {
+ renderCPULoad();
dom.timeline.innerHTML = ''; document.getElementById('timeMarkers').innerHTML = '';
let maxTime = 60;
for (const seq of state.sequences) {
@@ -467,24 +583,33 @@
seqDiv.addEventListener('dblclick', e => { e.stopPropagation(); e.preventDefault(); seq._collapsed = !seq._collapsed; renderTimeline(); });
dom.timeline.appendChild(seqDiv);
if (!seq._collapsed) {
+ const conflicts = detectConflicts(seq);
seq.effects.forEach((effect, effectIndex) => {
- const effectDiv = document.createElement('div'); effectDiv.className = 'effect';
- effectDiv.dataset.seqIndex = seqIndex; effectDiv.dataset.effectIndex = effectIndex;
- const effectStart = (seq.startTime + effect.startTime) * state.pixelsPerSecond;
- const effectWidth = (effect.endTime - effect.startTime) * state.pixelsPerSecond;
- effectDiv.style.left = `${effectStart}px`; effectDiv.style.top = `${seq._yPosition + 20 + effectIndex * 30}px`;
- effectDiv.style.width = `${effectWidth}px`; effectDiv.style.height = '26px';
- const startBeat = effect.startTime.toFixed(1), endBeat = effect.endTime.toFixed(1);
- const startSec = (effect.startTime * 60.0 / state.bpm).toFixed(1), endSec = (effect.endTime * 60.0 / state.bpm).toFixed(1);
- const timeDisplay = state.showBeats ? `${startBeat}-${endBeat}b (${startSec}-${endSec}s)` : `${startSec}-${endSec}s (${startBeat}-${endBeat}b)`;
+ const effectDiv = document.createElement('div');
+ effectDiv.className = 'effect';
+ if (conflicts.has(effectIndex)) effectDiv.classList.add('conflict');
+ Object.assign(effectDiv.dataset, { seqIndex, effectIndex });
+ Object.assign(effectDiv.style, {
+ left: `${(seq.startTime + effect.startTime) * state.pixelsPerSecond}px`,
+ top: `${seq._yPosition + 20 + effectIndex * 30}px`,
+ width: `${(effect.endTime - effect.startTime) * state.pixelsPerSecond}px`,
+ height: '26px'
+ });
effectDiv.innerHTML = `<div class="effect-handle left"></div><small>${effect.className}</small><div class="effect-handle right"></div>`;
- effectDiv.title = `${effect.className}\n${timeDisplay}\nPriority: ${effect.priority}\n${effect.args || '(no args)'}`;
- if (state.selectedItem && state.selectedItem.type === 'effect' && state.selectedItem.seqIndex === seqIndex && state.selectedItem.effectIndex === effectIndex) effectDiv.classList.add('selected');
- const leftHandle = effectDiv.querySelector('.effect-handle.left');
- const rightHandle = effectDiv.querySelector('.effect-handle.right');
- leftHandle.addEventListener('mousedown', e => { e.stopPropagation(); startHandleDrag(e, 'left', seqIndex, effectIndex); });
- rightHandle.addEventListener('mousedown', e => { e.stopPropagation(); startHandleDrag(e, 'right', seqIndex, effectIndex); });
- effectDiv.addEventListener('mousedown', e => { if (!e.target.classList.contains('effect-handle')) { e.stopPropagation(); startDrag(e, 'effect', seqIndex, effectIndex); } });
+ const conflictWarning = conflicts.has(effectIndex) ?
+ `\nāš ļø CONFLICT: Multiple post-process effects share priority ${effect.priority}` : '';
+ effectDiv.title = `${effect.className}\n${beatRange(effect.startTime, effect.endTime)}\nPriority: ${effect.priority}${conflictWarning}\n${effect.args || '(no args)'}`;
+ if (state.selectedItem?.type === 'effect' && state.selectedItem.seqIndex === seqIndex && state.selectedItem.effectIndex === effectIndex)
+ effectDiv.classList.add('selected');
+ effectDiv.querySelector('.effect-handle.left').addEventListener('mousedown', e => {
+ e.stopPropagation(); startHandleDrag(e, 'left', seqIndex, effectIndex);
+ });
+ effectDiv.querySelector('.effect-handle.right').addEventListener('mousedown', e => {
+ e.stopPropagation(); startHandleDrag(e, 'right', seqIndex, effectIndex);
+ });
+ effectDiv.addEventListener('mousedown', e => {
+ if (!e.target.classList.contains('effect-handle')) { e.stopPropagation(); startDrag(e, 'effect', seqIndex, effectIndex); }
+ });
effectDiv.addEventListener('click', e => { e.stopPropagation(); selectItem('effect', seqIndex, effectIndex); });
dom.timeline.appendChild(effectDiv);
});
@@ -499,9 +624,9 @@
function startDrag(e, type, seqIndex, effectIndex = null) {
state.isDragging = true;
state.dragMoved = false;
- const timelineRect = dom.timeline.getBoundingClientRect();
+ const containerRect = dom.timelineContent.getBoundingClientRect();
const currentLeft = parseFloat(e.currentTarget.style.left) || 0;
- state.dragOffset.x = e.clientX - timelineRect.left + dom.timelineContent.scrollLeft - currentLeft;
+ state.dragOffset.x = e.clientX - containerRect.left + dom.timelineContent.scrollLeft - currentLeft;
state.dragOffset.y = e.clientY - e.currentTarget.getBoundingClientRect().top;
state.selectedItem = { type, index: seqIndex, seqIndex, effectIndex };
document.addEventListener('mousemove', onDrag); document.addEventListener('mouseup', stopDrag);
@@ -510,8 +635,8 @@
function onDrag(e) {
if (!state.isDragging || !state.selectedItem) return;
state.dragMoved = true;
- const timelineRect = dom.timeline.getBoundingClientRect();
- let newTime = Math.max(0, (e.clientX - timelineRect.left + dom.timelineContent.scrollLeft - state.dragOffset.x) / state.pixelsPerSecond);
+ const containerRect = dom.timelineContent.getBoundingClientRect();
+ let newTime = Math.max(0, (e.clientX - containerRect.left + dom.timelineContent.scrollLeft - state.dragOffset.x) / state.pixelsPerSecond);
if (state.quantizeUnit > 0) newTime = Math.round(newTime * state.quantizeUnit) / state.quantizeUnit;
if (state.selectedItem.type === 'sequence') state.sequences[state.selectedItem.index].startTime = newTime;
else if (state.selectedItem.type === 'effect') {
@@ -533,13 +658,19 @@
function startHandleDrag(e, type, seqIndex, effectIndex) {
e.preventDefault(); state.isDraggingHandle = true; state.handleType = type;
state.selectedItem = { type: 'effect', seqIndex, effectIndex, index: seqIndex };
+ const seq = state.sequences[seqIndex], effect = seq.effects[effectIndex];
+ const containerRect = dom.timelineContent.getBoundingClientRect();
+ const mouseTimeBeats = (e.clientX - containerRect.left + dom.timelineContent.scrollLeft) / state.pixelsPerSecond;
+ const handleTimeBeats = seq.startTime + (type === 'left' ? effect.startTime : effect.endTime);
+ state.handleDragOffset = handleTimeBeats - mouseTimeBeats;
document.addEventListener('mousemove', onHandleDrag); document.addEventListener('mouseup', stopHandleDrag);
}
function onHandleDrag(e) {
if (!state.isDraggingHandle || !state.selectedItem) return;
- const timelineRect = dom.timeline.getBoundingClientRect();
- let newTime = Math.max(0, (e.clientX - timelineRect.left + dom.timelineContent.scrollLeft) / state.pixelsPerSecond);
+ const containerRect = dom.timelineContent.getBoundingClientRect();
+ let newTime = (e.clientX - containerRect.left + dom.timelineContent.scrollLeft) / state.pixelsPerSecond + state.handleDragOffset;
+ newTime = Math.max(0, newTime);
if (state.quantizeUnit > 0) newTime = Math.round(newTime * state.quantizeUnit) / state.quantizeUnit;
const seq = state.sequences[state.selectedItem.seqIndex], effect = seq.effects[state.selectedItem.effectIndex];
const relativeTime = newTime - seq.startTime;
@@ -556,7 +687,9 @@
function selectItem(type, seqIndex, effectIndex = null) {
state.selectedItem = { type, index: seqIndex, seqIndex, effectIndex };
- renderTimeline(); updateProperties(); dom.deleteBtn.disabled = false;
+ renderTimeline(); updateProperties();
+ dom.deleteBtn.disabled = false;
+ dom.addEffectBtn.disabled = type !== 'sequence';
}
// Properties
@@ -566,11 +699,13 @@
if (state.selectedItem.type === 'sequence') {
const seq = state.sequences[state.selectedItem.index];
dom.propertiesContent.innerHTML = `
- <div class="property-group"><label>Name</label><input type="text" id="propName" value="${seq.name || ''}" placeholder="Sequence name"></div>
- <div class="property-group"><label>Start Time (seconds)</label><input type="number" id="propStartTime" value="${seq.startTime}" step="0.1" min="0"></div>
+ <div class="property-group"><label>Name</label><input type="text" id="propName" value="${seq.name || ''}" placeholder="Sequence name" inputmode="text"></div>
+ <div class="property-group"><label>Start Time (beats)</label><input type="number" id="propStartTime" value="${seq.startTime}" step="0.1" min="0"></div>
+ <div class="property-group"><button id="propDeleteBtn" style="width: 100%; background: var(--accent-red);">šŸ—‘ļø Delete Sequence</button></div>
`;
document.getElementById('propName').addEventListener('input', applyProperties);
document.getElementById('propStartTime').addEventListener('input', applyProperties);
+ document.getElementById('propDeleteBtn').addEventListener('click', () => dom.deleteBtn.click());
} else if (state.selectedItem.type === 'effect') {
const effect = state.sequences[state.selectedItem.seqIndex].effects[state.selectedItem.effectIndex];
const effects = state.sequences[state.selectedItem.seqIndex].effects;
@@ -588,6 +723,7 @@
</div>
<button id="togglePriorityBtn" style="width: 100%;">${samePriority ? 'āœ“ Same as Above (=)' : 'Increment (+)'}</button>
</div>
+ <div class="property-group"><button id="propDeleteBtn" style="width: 100%; background: var(--accent-red);">šŸ—‘ļø Delete Effect</button></div>
`;
document.getElementById('propClassName').addEventListener('input', applyProperties);
document.getElementById('propStartTime').addEventListener('input', applyProperties);
@@ -596,6 +732,7 @@
document.getElementById('moveUpBtn').addEventListener('click', moveEffectUp);
document.getElementById('moveDownBtn').addEventListener('click', moveEffectDown);
document.getElementById('togglePriorityBtn').addEventListener('click', toggleSamePriority);
+ document.getElementById('propDeleteBtn').addEventListener('click', () => dom.deleteBtn.click());
}
}
@@ -649,10 +786,8 @@
function updateStats() {
const effectCount = state.sequences.reduce((sum, seq) => sum + seq.effects.length, 0);
- const maxTime = state.sequences.reduce((max, seq) => {
- const seqMax = seq.effects.reduce((m, e) => Math.max(m, seq.startTime + e.endTime), seq.startTime);
- return Math.max(max, seqMax);
- }, 0);
+ const maxTime = Math.max(0, ...state.sequences.flatMap(seq =>
+ seq.effects.map(e => seq.startTime + e.endTime).concat(seq.startTime)));
dom.stats.innerHTML = `šŸ“Š Sequences: ${state.sequences.length} | šŸŽ¬ Effects: ${effectCount} | ā±ļø Duration: ${maxTime.toFixed(2)}s`;
}
@@ -716,16 +851,17 @@
dom.waveformContainer.addEventListener('click', async e => {
if (!state.audioBuffer) return;
const rect = dom.waveformContainer.getBoundingClientRect();
- const clickX = e.clientX - rect.left + dom.timelineContent.scrollLeft;
- const clickTime = (clickX / state.pixelsPerSecond) * 60.0 / state.bpm;
+ const canvasOffset = parseFloat(dom.waveformCanvas.style.left) || 0;
+ const clickX = e.clientX - rect.left - canvasOffset;
+ const clickBeats = clickX / state.pixelsPerSecond;
+ const clickTime = beatsToTime(clickBeats);
const wasPlaying = state.isPlaying;
if (wasPlaying) stopPlayback(false);
state.playbackOffset = Math.max(0, Math.min(clickTime, state.audioDuration));
- const clickBeats = state.playbackOffset * state.bpm / 60.0;
- dom.playbackTime.textContent = `${state.playbackOffset.toFixed(2)}s (${clickBeats.toFixed(2)}b)`;
- const indicatorX = clickBeats * state.pixelsPerSecond;
- dom.playbackIndicator.style.left = `${indicatorX}px`;
- dom.waveformPlaybackIndicator.style.left = `${indicatorX}px`;
+ const pausedBeats = timeToBeats(state.playbackOffset);
+ dom.playbackTime.textContent = `${state.playbackOffset.toFixed(2)}s (${pausedBeats.toFixed(2)}b)`;
+ const indicatorX = pausedBeats * state.pixelsPerSecond;
+ dom.playbackIndicator.style.left = dom.waveformPlaybackIndicator.style.left = `${indicatorX}px`;
if (wasPlaying) await startPlayback();
});
@@ -734,11 +870,19 @@
renderTimeline(); showMessage('New sequence added', 'success');
});
+ dom.addEffectBtn.addEventListener('click', () => {
+ if (!state.selectedItem || state.selectedItem.type !== 'sequence') return;
+ const seq = state.sequences[state.selectedItem.index];
+ seq.effects.push({ type: 'effect', className: 'Effect', startTime: 0, endTime: 10, priority: 0, priorityModifier: '+', args: '' });
+ seq._collapsed = false;
+ renderTimeline(); showMessage('New effect added', 'success');
+ });
+
dom.deleteBtn.addEventListener('click', () => {
if (!state.selectedItem) return;
if (state.selectedItem.type === 'sequence') state.sequences.splice(state.selectedItem.index, 1);
else if (state.selectedItem.type === 'effect') state.sequences[state.selectedItem.seqIndex].effects.splice(state.selectedItem.effectIndex, 1);
- state.selectedItem = null; dom.deleteBtn.disabled = true; renderTimeline(); updateProperties();
+ state.selectedItem = null; dom.deleteBtn.disabled = true; dom.addEffectBtn.disabled = true; renderTimeline(); updateProperties();
showMessage('Item deleted', 'success');
});
@@ -768,21 +912,22 @@
dom.quantizeSelect.addEventListener('change', e => { state.quantizeUnit = parseFloat(e.target.value); });
dom.panelToggle.addEventListener('click', () => { dom.propertiesPanel.classList.add('collapsed'); dom.panelCollapseBtn.classList.add('visible'); dom.panelToggle.textContent = 'ā–² Expand'; });
dom.panelCollapseBtn.addEventListener('click', () => { dom.propertiesPanel.classList.remove('collapsed'); dom.panelCollapseBtn.classList.remove('visible'); dom.panelToggle.textContent = 'ā–¼ Collapse'; });
- dom.timeline.addEventListener('click', () => { state.selectedItem = null; dom.deleteBtn.disabled = true; renderTimeline(); updateProperties(); });
+ dom.timeline.addEventListener('click', () => { state.selectedItem = null; dom.deleteBtn.disabled = true; dom.addEffectBtn.disabled = true; renderTimeline(); updateProperties(); });
dom.timeline.addEventListener('dblclick', async e => {
if (e.target !== dom.timeline) return;
- const timelineRect = dom.timeline.getBoundingClientRect();
- const clickX = e.clientX - timelineRect.left + dom.timelineContent.scrollLeft;
- const clickBeats = clickX / state.pixelsPerSecond, clickTime = clickBeats * 60.0 / state.bpm;
+ const containerRect = dom.timelineContent.getBoundingClientRect();
+ const clickX = e.clientX - containerRect.left + dom.timelineContent.scrollLeft;
+ const clickBeats = clickX / state.pixelsPerSecond;
+ const clickTime = beatsToTime(clickBeats);
if (state.audioBuffer) {
const wasPlaying = state.isPlaying;
if (wasPlaying) stopPlayback(false);
state.playbackOffset = Math.max(0, Math.min(clickTime, state.audioDuration));
- const pausedBeats = state.playbackOffset * state.bpm / 60.0;
+ const pausedBeats = timeToBeats(state.playbackOffset);
dom.playbackTime.textContent = `${state.playbackOffset.toFixed(2)}s (${pausedBeats.toFixed(2)}b)`;
const indicatorX = pausedBeats * state.pixelsPerSecond;
- dom.playbackIndicator.style.left = `${indicatorX}px`; dom.waveformPlaybackIndicator.style.left = `${indicatorX}px`;
+ dom.playbackIndicator.style.left = dom.waveformPlaybackIndicator.style.left = `${indicatorX}px`;
if (wasPlaying) await startPlayback();
showMessage(`Seek to ${clickTime.toFixed(2)}s (${clickBeats.toFixed(2)}b)`, 'success');
}
@@ -800,10 +945,10 @@
});
dom.timelineContent.addEventListener('scroll', () => {
- if (dom.waveformCanvas) {
- dom.waveformCanvas.style.left = `-${dom.timelineContent.scrollLeft}px`;
- dom.waveformPlaybackIndicator.style.transform = `translateX(-${dom.timelineContent.scrollLeft}px)`;
- }
+ const scrollLeft = dom.timelineContent.scrollLeft;
+ dom.cpuLoadCanvas.style.left = `-${scrollLeft}px`;
+ dom.waveformCanvas.style.left = `-${scrollLeft}px`;
+ dom.waveformPlaybackIndicator.style.transform = `translateX(-${scrollLeft}px)`;
});
dom.timelineContent.addEventListener('wheel', e => {