From bd400d0e99f40bc9a6ec4754723444082c509890 Mon Sep 17 00:00:00 2001 From: skal Date: Thu, 12 Feb 2026 01:28:58 +0100 Subject: refactor: timeline editor now uses beat-based internal storage Timeline editor now stores all times internally as beats (not seconds), aligning with the project's beat-based timing system. Added BPM slider for tempo control. Serializes to beats (default format) and displays beats primarily with seconds in tooltips. Changes: - parseTime() returns beats (converts 's' suffix to beats) - serializeSeqFile() outputs beats (bare numbers) - Timeline markers show beats (4-beat/bar increments) - BPM slider (60-200) for tempo editing - Snap-to-beat rounds to nearest beat - Audio waveform aligned to beats - showBeats enabled by default Co-Authored-By: Claude Sonnet 4.5 --- tools/timeline_editor/README.md | 17 ++++--- tools/timeline_editor/index.html | 95 +++++++++++++++++++++++----------------- 2 files changed, 66 insertions(+), 46 deletions(-) (limited to 'tools') diff --git a/tools/timeline_editor/README.md b/tools/timeline_editor/README.md index 4861a88..dd1f38b 100644 --- a/tools/timeline_editor/README.md +++ b/tools/timeline_editor/README.md @@ -5,16 +5,17 @@ Interactive web-based editor for `timeline.seq` files. ## Features - 📂 Load/save `timeline.seq` files -- 📊 Visual Gantt-style timeline with sticky time markers +- 📊 Visual Gantt-style timeline with sticky time markers (beat-based) - 🎯 Drag & drop sequences and effects - 🎯 Resize effects with handles - 📦 Collapsible sequences (double-click to collapse) - 📏 Vertical grid lines synchronized with time ticks -- ⏱️ Edit timing and properties +- ⏱️ Edit timing and properties (in beats) - ⚙️ Stack-order based priority system -- 🔍 Zoom (10%-500%) with mouse wheel + Ctrl/Cmd -- 🎵 Audio waveform visualization -- 🎼 Snap-to-beat mode +- 🔍 Zoom (10%-200%) with mouse wheel + Ctrl/Cmd +- 🎵 Audio waveform visualization (aligned to beats) +- 🎼 Snap-to-beat mode (enabled by default) +- 🎛️ BPM slider (60-200 BPM) - 🔄 Re-order sequences by time - 🗑️ Delete sequences/effects @@ -73,8 +74,12 @@ SEQUENCE 2.5s 0 "Explicit seconds" # Rare: start at 2.5 physical seconds ## Technical Notes - Pure HTML/CSS/JavaScript (no dependencies, works offline) -- Sequences have absolute times, effects are relative to parent sequence +- **Internal representation uses beats** (not seconds) +- Sequences have absolute times (beats), effects are relative to parent sequence +- BPM used for seconds conversion (tooltips, audio waveform alignment) - Priority determines render order (higher = on top) - Collapsed sequences show 35px title bar, expanded show full effect stack +- Time markers show beats by default (4-beat/bar increments) - Time markers sticky at top when scrolling - Vertical grid lines aid alignment +- Snap-to-beat enabled by default for musical alignment diff --git a/tools/timeline_editor/index.html b/tools/timeline_editor/index.html index 15e92a6..62b426f 100644 --- a/tools/timeline_editor/index.html +++ b/tools/timeline_editor/index.html @@ -477,9 +477,11 @@
100% + + 120
@@ -510,7 +512,7 @@ let currentFile = null; let selectedItem = null; let pixelsPerSecond = 100; - let showBeats = false; + let showBeats = true; let bpm = 120; let isDragging = false; let dragOffset = { x: 0, y: 0 }; @@ -547,13 +549,18 @@ let bpm = 120; // Default BPM let currentPriority = 0; // Track priority for + = - modifiers - // Helper: Convert time notation to seconds + // Helper: Parse time notation (returns beats) function parseTime(timeStr) { + if (timeStr.endsWith('s')) { + // Explicit seconds: "2.5s" = convert to beats + const seconds = parseFloat(timeStr.slice(0, -1)); + return seconds * bpm / 60.0; + } if (timeStr.endsWith('b')) { - // Beat notation: "4b" = 4 beats - const beats = parseFloat(timeStr.slice(0, -1)); - return beats * (60.0 / bpm); + // Explicit beats: "4b" = 4 beats + return parseFloat(timeStr.slice(0, -1)); } + // Default: beats return parseFloat(timeStr); } @@ -633,7 +640,7 @@ return { sequences, bpm }; } - // Serializer: JavaScript objects → timeline.seq + // Serializer: JavaScript objects → timeline.seq (outputs beats) function serializeSeqFile(sequences) { let output = '# Demo Timeline\n'; output += '# Generated by Timeline Editor\n'; @@ -687,8 +694,9 @@ const canvas = waveformCanvas; const ctx = canvas.getContext('2d'); - // Set canvas size based on audio duration and zoom - const canvasWidth = audioDuration * pixelsPerSecond; + // Set canvas size based on audio duration (convert to beats) and zoom + const audioDurationBeats = audioDuration * bpm / 60.0; + const canvasWidth = audioDurationBeats * pixelsPerSecond; const canvasHeight = 80; // Set actual canvas resolution (for sharp rendering) @@ -767,10 +775,10 @@ const timeMarkers = document.getElementById('timeMarkers'); timeMarkers.innerHTML = ''; - // Calculate max time - let maxTime = 30; // Default 30 seconds + // Calculate max time (in beats) + let maxTime = 60; // Default 60 beats (15 bars) for (const seq of sequences) { - const seqEnd = seq.startTime + 10; // Default sequence duration + const seqEnd = seq.startTime + 16; // Default 4 bars maxTime = Math.max(maxTime, seqEnd); for (const effect of seq.effects) { @@ -780,7 +788,8 @@ // Extend timeline to fit audio if loaded if (audioDuration > 0) { - maxTime = Math.max(maxTime, audioDuration); + const audioBeats = audioDuration * bpm / 60.0; + maxTime = Math.max(maxTime, audioBeats); } // Render time markers @@ -788,23 +797,22 @@ timeline.style.width = `${timelineWidth}px`; if (showBeats) { - // Show beats - const beatDuration = 60.0 / bpm; // seconds per beat - const maxBeats = Math.ceil(maxTime / beatDuration); - for (let beat = 0; beat <= maxBeats; beat++) { - const timeSec = beat * beatDuration; + // Show beats (default) + for (let beat = 0; beat <= maxTime; beat += 4) { const marker = document.createElement('div'); marker.className = 'time-marker'; - marker.style.left = `${timeSec * pixelsPerSecond}px`; + marker.style.left = `${beat * pixelsPerSecond}px`; marker.textContent = `${beat}b`; timeMarkers.appendChild(marker); } } else { // Show seconds - for (let t = 0; t <= maxTime; t += 1) { + const maxSeconds = maxTime * 60.0 / bpm; + for (let t = 0; t <= maxSeconds; t += 1) { + const beatPos = t * bpm / 60.0; const marker = document.createElement('div'); marker.className = 'time-marker'; - marker.style.left = `${t * pixelsPerSecond}px`; + marker.style.left = `${beatPos * pixelsPerSecond}px`; marker.textContent = `${t}s`; timeMarkers.appendChild(marker); } @@ -927,16 +935,14 @@ effectDiv.style.width = `${effectWidth}px`; effectDiv.style.height = '26px'; - // Format time display based on mode (for tooltip) - let timeDisplay; - if (showBeats) { - const beatDuration = 60.0 / bpm; - const startBeat = (effect.startTime / beatDuration).toFixed(1); - const endBeat = (effect.endTime / beatDuration).toFixed(1); - timeDisplay = `${startBeat}-${endBeat}b`; - } else { - timeDisplay = `${effect.startTime.toFixed(1)}-${effect.endTime.toFixed(1)}s`; - } + // Format time display (beats primary, seconds in tooltip) + const startBeat = effect.startTime.toFixed(1); + const endBeat = effect.endTime.toFixed(1); + const startSec = (effect.startTime * 60.0 / bpm).toFixed(1); + const endSec = (effect.endTime * 60.0 / bpm).toFixed(1); + const timeDisplay = showBeats + ? `${startBeat}-${endBeat}b (${startSec}-${endSec}s)` + : `${startSec}-${endSec}s (${startBeat}-${endBeat}b)`; // Show only class name, full info on hover effectDiv.innerHTML = ` @@ -1012,11 +1018,9 @@ const newX = e.clientX - timelineRect.left - dragOffset.x; let newTime = Math.max(0, newX / pixelsPerSecond); - // Snap to beat when in beat mode + // Snap to beat when enabled if (showBeats) { - const beatDuration = 60.0 / bpm; - const nearestBeat = Math.round(newTime / beatDuration); - newTime = nearestBeat * beatDuration; + newTime = Math.round(newTime); } if (selectedItem.type === 'sequence') { @@ -1063,11 +1067,9 @@ const newX = e.clientX - timelineRect.left; let newTime = Math.max(0, newX / pixelsPerSecond); - // Snap to beat when in beat mode + // Snap to beat when enabled if (showBeats) { - const beatDuration = 60.0 / bpm; - const nearestBeat = Math.round(newTime / beatDuration); - newTime = nearestBeat * beatDuration; + newTime = Math.round(newTime); } const seq = sequences[selectedItem.seqIndex]; @@ -1239,6 +1241,7 @@ sequences = parsed.sequences; bpm = parsed.bpm; document.getElementById('currentBPM').textContent = bpm; + document.getElementById('bpmSlider').value = bpm; renderTimeline(); saveBtn.disabled = false; addSequenceBtn.disabled = false; @@ -1338,6 +1341,18 @@ renderTimeline(); }); + // BPM slider + const bpmSlider = document.getElementById('bpmSlider'); + const currentBPMDisplay = document.getElementById('currentBPM'); + bpmSlider.addEventListener('input', (e) => { + bpm = parseInt(e.target.value); + currentBPMDisplay.textContent = bpm; + if (audioBuffer) { + renderWaveform(); + } + renderTimeline(); + }); + // Beats toggle const showBeatsCheckbox = document.getElementById('showBeatsCheckbox'); showBeatsCheckbox.addEventListener('change', (e) => { -- cgit v1.2.3