summaryrefslogtreecommitdiff
path: root/tools/timeline_editor
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-12 01:48:17 +0100
committerskal <pascal.massimino@gmail.com>2026-02-12 01:48:17 +0100
commit0fcb17f6e0c0ab449c5432f4bbacd6948e1283cd (patch)
treef87bc8afc7111ff17ea6bab66544740efefb107a /tools/timeline_editor
parent565b72d08eb1463a7e354c7f63a8edb9e32884e0 (diff)
feat: timeline editor audio playback with auto-expand/collapse
- Audio playback controls (play/pause, spacebar shortcut) - Red playback indicator with auto-scroll (middle third viewport) - Auto-expand active sequence during playback, collapse previous - Click waveform to seek - Sticky header: waveform + timeline ticks stay at top - Sequences confined to separate scrollable container below header - Document known bugs: zoom sync, positioning, reflow issues Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Diffstat (limited to 'tools/timeline_editor')
-rw-r--r--tools/timeline_editor/README.md24
-rw-r--r--tools/timeline_editor/ROADMAP.md31
-rw-r--r--tools/timeline_editor/index.html293
3 files changed, 319 insertions, 29 deletions
diff --git a/tools/timeline_editor/README.md b/tools/timeline_editor/README.md
index dd1f38b..6e368cf 100644
--- a/tools/timeline_editor/README.md
+++ b/tools/timeline_editor/README.md
@@ -18,19 +18,26 @@ Interactive web-based editor for `timeline.seq` files.
- 🎛️ BPM slider (60-200 BPM)
- 🔄 Re-order sequences by time
- 🗑️ Delete sequences/effects
+- ▶️ **Audio playback with auto-expand/collapse** (NEW)
+- 🎚️ **Sticky audio track and timeline ticks** (NEW)
## Usage
1. **Open:** `open tools/timeline_editor/index.html` or double-click in browser
2. **Load timeline:** Click "📂 Load timeline.seq" → select `workspaces/main/timeline.seq`
-3. **Edit:**
+3. **Load audio:** Click "🎵 Load Audio (WAV)" → select audio file
+4. **Playback:**
+ - Click "▶ Play" or press **Spacebar** to play/pause
+ - Click waveform to seek
+ - Watch sequences auto-expand/collapse during playback
+ - Red playback indicator shows current position
+5. **Edit:**
- Drag sequences/effects to reposition
- Double-click sequence header to collapse/expand
- Click item to edit properties in side panel
- Drag effect handles to resize
-4. **Zoom:** Ctrl/Cmd + mouse wheel (zooms at cursor position)
-5. **Audio:** Load WAV file for waveform visualization
-6. **Save:** Click "💾 Save timeline.seq"
+6. **Zoom:** Ctrl/Cmd + mouse wheel (zooms at cursor position)
+7. **Save:** Click "💾 Save timeline.seq"
## File Format
@@ -71,6 +78,11 @@ SEQUENCE 2.5s 0 "Explicit seconds" # Rare: start at 2.5 physical seconds
EFFECT + Fade 0 4 # Still uses beats for duration
```
+## Keyboard Shortcuts
+
+- **Spacebar**: Play/pause audio playback
+- **Ctrl/Cmd + Wheel**: Zoom in/out at cursor position
+
## Technical Notes
- Pure HTML/CSS/JavaScript (no dependencies, works offline)
@@ -80,6 +92,8 @@ SEQUENCE 2.5s 0 "Explicit seconds" # Rare: start at 2.5 physical seconds
- 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
+- **Waveform and time markers are sticky** at top during scroll/zoom
- Vertical grid lines aid alignment
- Snap-to-beat enabled by default for musical alignment
+- **Auto-expand/collapse**: Active sequence expands during playback, previous collapses
+- **Auto-scroll**: Timeline follows playback indicator (keeps it in middle third of viewport)
diff --git a/tools/timeline_editor/ROADMAP.md b/tools/timeline_editor/ROADMAP.md
index 4bfc35c..216adbf 100644
--- a/tools/timeline_editor/ROADMAP.md
+++ b/tools/timeline_editor/ROADMAP.md
@@ -4,6 +4,37 @@ This document outlines planned enhancements for the interactive timeline editor.
---
+## Known Bugs (High Priority)
+
+### Audio Playback Integration Issues
+
+1. **Audio waveform doesn't scale with zoom nor follow timeline**
+ - Waveform should horizontally sync with timeline ticks/sequences
+ - Should scale to match `pixelsPerSecond` zoom level
+ - Currently remains static regardless of zoom
+
+2. **Playback indicator doesn't follow zoom and height issues**
+ - Vertical red bar position calculation doesn't account for `pixelsPerSecond`
+ - Doesn't reach bottom when sequences have scrolled
+ - Needs to span full `timeline-content` height dynamically
+
+3. **Sequences overlap timeline at scroll origin**
+ - Some sequences still go behind timeline ticks
+ - Notably when wheel pans back to beginning (scrollLeft = 0)
+ - Need proper clipping or z-index management
+
+4. **Timeline and waveform should be fixed, not floating**
+ - Currently using sticky positioning
+ - Should use true fixed positioning at top
+ - Should remain stationary regardless of scroll
+
+5. **Status indicator causes reflow**
+ - Green status text appears/disappears causing layout shift
+ - Should be relocated to top or bottom as fixed/always-visible
+ - Prevents jarring reflow when messages appear
+
+---
+
## Phase 1: Core Editing Features (High Priority)
### 1.1 Snap-to-Beat ⭐ Priority: HIGH
diff --git a/tools/timeline_editor/index.html b/tools/timeline_editor/index.html
index 62b426f..c9385ad 100644
--- a/tools/timeline_editor/index.html
+++ b/tools/timeline_editor/index.html
@@ -106,17 +106,24 @@
background: #252526;
border-radius: 8px;
padding: 20px;
- overflow-x: auto;
- overflow-y: auto;
position: relative;
height: calc(100vh - 280px);
min-height: 500px;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .timeline-content {
+ flex: 1;
+ overflow-x: auto;
+ overflow-y: auto;
+ position: relative;
/* Hide scrollbars while keeping scroll functionality */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
- .timeline-container::-webkit-scrollbar {
+ .timeline-content::-webkit-scrollbar {
display: none; /* Chrome/Safari/Opera */
}
@@ -126,24 +133,57 @@
border-left: 2px solid #3c3c3c;
}
+ .sticky-header {
+ position: relative;
+ background: #252526;
+ z-index: 100;
+ padding-bottom: 10px;
+ border-bottom: 2px solid #3c3c3c;
+ flex-shrink: 0;
+ }
+
+ .playback-controls {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 0;
+ }
+
+ #playPauseBtn {
+ width: 60px;
+ padding: 8px 12px;
+ }
+
#waveformCanvas {
position: relative;
height: 80px;
width: 100%;
- margin-bottom: 10px;
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
cursor: crosshair;
}
- .time-markers {
- position: sticky;
+ .playback-indicator {
+ position: absolute;
top: 0;
+ width: 2px;
+ height: 100%;
+ background: #f48771;
+ box-shadow: 0 0 4px rgba(244, 135, 113, 0.8);
+ pointer-events: none;
+ z-index: 90;
+ display: none;
+ }
+
+ .playback-indicator.playing {
+ display: block;
+ }
+
+ .time-markers {
+ position: relative;
height: 30px;
- margin-bottom: 10px;
+ margin-top: 10px;
border-bottom: 1px solid #3c3c3c;
- background: #252526;
- z-index: 100;
}
.time-marker {
@@ -488,9 +528,18 @@
<div id="messageArea"></div>
<div class="timeline-container">
- <canvas id="waveformCanvas" style="display: none;"></canvas>
- <div class="time-markers" id="timeMarkers"></div>
- <div class="timeline" id="timeline"></div>
+ <div class="sticky-header">
+ <div class="playback-controls" id="playbackControls" style="display: none;">
+ <button id="playPauseBtn">▶ Play</button>
+ <span id="playbackTime">0.00s</span>
+ </div>
+ <canvas id="waveformCanvas" style="display: none;"></canvas>
+ <div class="time-markers" id="timeMarkers"></div>
+ </div>
+ <div class="timeline-content" id="timelineContent">
+ <div class="playback-indicator" id="playbackIndicator"></div>
+ <div class="timeline" id="timeline"></div>
+ </div>
</div>
<button class="panel-collapse-btn" id="panelCollapseBtn">▲ Properties</button>
@@ -521,10 +570,18 @@
let handleType = null; // 'left' or 'right'
let audioBuffer = null; // Decoded audio data
let audioDuration = 0; // Duration in seconds
+ let audioSource = null; // Current playback source
+ let audioContext = null; // Audio context for playback
+ let isPlaying = false;
+ let playbackStartTime = 0; // When playback started (audioContext.currentTime)
+ let playbackOffset = 0; // Offset into audio (seconds)
+ let animationFrameId = null;
+ let lastExpandedSeqIndex = -1;
// DOM elements
const timeline = document.getElementById('timeline');
const timelineContainer = document.querySelector('.timeline-container');
+ const timelineContent = document.getElementById('timelineContent');
const fileInput = document.getElementById('fileInput');
const saveBtn = document.getElementById('saveBtn');
const audioInput = document.getElementById('audioInput');
@@ -539,6 +596,10 @@
const zoomSlider = document.getElementById('zoomSlider');
const zoomLevel = document.getElementById('zoomLevel');
const stats = document.getElementById('stats');
+ const playPauseBtn = document.getElementById('playPauseBtn');
+ const playbackControls = document.getElementById('playbackControls');
+ const playbackTime = document.getElementById('playbackTime');
+ const playbackIndicator = document.getElementById('playbackIndicator');
// Parser: timeline.seq → JavaScript objects
// Format specification: doc/SEQUENCE.md
@@ -672,12 +733,15 @@
async function loadAudioFile(file) {
try {
const arrayBuffer = await file.arrayBuffer();
- const audioContext = new (window.AudioContext || window.webkitAudioContext)();
+ if (!audioContext) {
+ audioContext = new (window.AudioContext || window.webkitAudioContext)();
+ }
audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
audioDuration = audioBuffer.duration;
renderWaveform();
waveformCanvas.style.display = 'block';
+ playbackControls.style.display = 'flex';
clearAudioBtn.disabled = false;
showMessage(`Audio loaded: ${audioDuration.toFixed(2)}s`, 'success');
@@ -761,14 +825,147 @@
}
function clearAudio() {
+ stopPlayback();
audioBuffer = null;
audioDuration = 0;
waveformCanvas.style.display = 'none';
+ playbackControls.style.display = 'none';
clearAudioBtn.disabled = true;
renderTimeline();
showMessage('Audio cleared', 'success');
}
+ // Playback functions
+ function startPlayback() {
+ if (!audioBuffer || !audioContext) return;
+
+ // Resume audio context if suspended
+ if (audioContext.state === 'suspended') {
+ audioContext.resume();
+ }
+
+ // Create and start audio source
+ audioSource = audioContext.createBufferSource();
+ audioSource.buffer = audioBuffer;
+ audioSource.connect(audioContext.destination);
+ audioSource.start(0, playbackOffset);
+
+ playbackStartTime = audioContext.currentTime;
+ isPlaying = true;
+ playPauseBtn.textContent = '⏸ Pause';
+ playbackIndicator.classList.add('playing');
+
+ // Start animation loop
+ updatePlaybackPosition();
+
+ audioSource.onended = () => {
+ if (isPlaying) {
+ stopPlayback();
+ }
+ };
+ }
+
+ function stopPlayback() {
+ if (audioSource) {
+ try {
+ audioSource.stop();
+ } catch (e) {
+ // Already stopped
+ }
+ audioSource = null;
+ }
+
+ if (animationFrameId) {
+ cancelAnimationFrame(animationFrameId);
+ animationFrameId = null;
+ }
+
+ if (isPlaying) {
+ // Save current position for resume
+ const elapsed = audioContext.currentTime - playbackStartTime;
+ playbackOffset = Math.min(playbackOffset + elapsed, audioDuration);
+ }
+
+ isPlaying = false;
+ playPauseBtn.textContent = '▶ Play';
+ playbackIndicator.classList.remove('playing');
+ }
+
+ function updatePlaybackPosition() {
+ if (!isPlaying) return;
+
+ const elapsed = audioContext.currentTime - playbackStartTime;
+ const currentTime = playbackOffset + elapsed;
+
+ // Update time display
+ playbackTime.textContent = `${currentTime.toFixed(2)}s`;
+
+ // Convert to beats for position calculation
+ const currentBeats = currentTime * bpm / 60.0;
+
+ // Update playback indicator position
+ const indicatorX = currentBeats * pixelsPerSecond;
+ playbackIndicator.style.left = `${indicatorX}px`;
+
+ // Auto-scroll timeline to follow playback
+ const viewportWidth = timelineContent.clientWidth;
+ const scrollX = timelineContent.scrollLeft;
+ const relativeX = indicatorX - scrollX;
+
+ // Keep indicator in middle third of viewport
+ if (relativeX < viewportWidth * 0.33 || relativeX > viewportWidth * 0.67) {
+ timelineContent.scrollLeft = indicatorX - viewportWidth * 0.5;
+ }
+
+ // Auto-expand/collapse sequences
+ expandSequenceAtTime(currentBeats);
+
+ // Continue animation
+ animationFrameId = requestAnimationFrame(updatePlaybackPosition);
+ }
+
+ function expandSequenceAtTime(currentBeats) {
+ // Find which sequence is active at current time
+ let activeSeqIndex = -1;
+ for (let i = 0; i < sequences.length; i++) {
+ const seq = sequences[i];
+ const seqEndBeats = seq.startTime + (seq.effects.length > 0
+ ? Math.max(...seq.effects.map(e => e.endTime))
+ : 0);
+
+ if (currentBeats >= seq.startTime && currentBeats <= seqEndBeats) {
+ activeSeqIndex = i;
+ break;
+ }
+ }
+
+ // Changed sequence - collapse old, expand new
+ if (activeSeqIndex !== lastExpandedSeqIndex) {
+ // Collapse previous sequence
+ if (lastExpandedSeqIndex >= 0 && lastExpandedSeqIndex < sequences.length) {
+ sequences[lastExpandedSeqIndex]._collapsed = true;
+ }
+
+ // Expand new sequence
+ if (activeSeqIndex >= 0) {
+ sequences[activeSeqIndex]._collapsed = false;
+ lastExpandedSeqIndex = activeSeqIndex;
+
+ // Flash animation
+ const seqDivs = timeline.querySelectorAll('.sequence');
+ if (seqDivs[activeSeqIndex]) {
+ seqDivs[activeSeqIndex].classList.add('active-flash');
+ setTimeout(() => {
+ seqDivs[activeSeqIndex]?.classList.remove('active-flash');
+ }, 600);
+ }
+ }
+
+ // Re-render to show collapse/expand changes
+ renderTimeline();
+ }
+ }
+
// Render timeline
function renderTimeline() {
timeline.innerHTML = '';
@@ -1278,6 +1475,45 @@
audioInput.value = ''; // Reset file input
});
+ playPauseBtn.addEventListener('click', () => {
+ if (isPlaying) {
+ stopPlayback();
+ } else {
+ // Reset to beginning if at end
+ if (playbackOffset >= audioDuration) {
+ playbackOffset = 0;
+ }
+ startPlayback();
+ }
+ });
+
+ // Waveform click to seek
+ waveformCanvas.addEventListener('click', (e) => {
+ if (!audioBuffer) return;
+
+ const rect = waveformCanvas.getBoundingClientRect();
+ const clickX = e.clientX - rect.left;
+ const audioDurationBeats = audioDuration * bpm / 60.0;
+ const clickBeats = (clickX / waveformCanvas.width) * audioDurationBeats;
+ const clickTime = clickBeats * 60.0 / bpm;
+
+ const wasPlaying = isPlaying;
+ if (wasPlaying) {
+ stopPlayback();
+ }
+
+ playbackOffset = Math.max(0, Math.min(clickTime, audioDuration));
+
+ if (wasPlaying) {
+ startPlayback();
+ } else {
+ // Update display even when paused
+ playbackTime.textContent = `${playbackOffset.toFixed(2)}s`;
+ const indicatorX = (playbackOffset * bpm / 60.0) * pixelsPerSecond;
+ playbackIndicator.style.left = `${indicatorX}px`;
+ }
+ });
+
addSequenceBtn.addEventListener('click', () => {
sequences.push({
type: 'sequence',
@@ -1322,7 +1558,7 @@
const newIndex = sequences.indexOf(currentActiveSeq);
if (newIndex >= 0 && sequences[newIndex]._yPosition !== undefined) {
// Scroll to keep it in view
- timelineContainer.scrollTop = sequences[newIndex]._yPosition;
+ timelineContent.scrollTop = sequences[newIndex]._yPosition;
lastActiveSeqIndex = newIndex;
}
}
@@ -1384,18 +1620,27 @@
updateProperties();
});
+ // Keyboard shortcuts
+ document.addEventListener('keydown', (e) => {
+ // Spacebar: play/pause (if audio loaded)
+ if (e.code === 'Space' && audioBuffer) {
+ e.preventDefault();
+ playPauseBtn.click();
+ }
+ });
+
// Mouse wheel: zoom (with Ctrl/Cmd) or diagonal scroll
- timelineContainer.addEventListener('wheel', (e) => {
+ timelineContent.addEventListener('wheel', (e) => {
e.preventDefault();
// Zoom mode: Ctrl/Cmd + wheel
if (e.ctrlKey || e.metaKey) {
- // Get mouse position relative to timeline container
- const rect = timelineContainer.getBoundingClientRect();
+ // Get mouse position relative to timeline content
+ const rect = timelineContent.getBoundingClientRect();
const mouseX = e.clientX - rect.left; // Mouse X in viewport coordinates
// Calculate time position under cursor BEFORE zoom
- const scrollLeft = timelineContainer.scrollLeft;
+ const scrollLeft = timelineContent.scrollLeft;
const timeUnderCursor = (scrollLeft + mouseX) / pixelsPerSecond;
// Calculate new zoom level
@@ -1419,17 +1664,17 @@
// Adjust scroll position so time under cursor stays in same place
// After zoom: new_scrollLeft = time_under_cursor * newPixelsPerSecond - mouseX
const newScrollLeft = timeUnderCursor * newPixelsPerSecond - mouseX;
- timelineContainer.scrollLeft = newScrollLeft;
+ timelineContent.scrollLeft = newScrollLeft;
}
return;
}
// Normal mode: diagonal scroll
- timelineContainer.scrollLeft += e.deltaY;
+ timelineContent.scrollLeft += e.deltaY;
// Calculate current time position with 10% headroom for visual comfort
- const currentScrollLeft = timelineContainer.scrollLeft;
- const viewportWidth = timelineContainer.clientWidth;
+ const currentScrollLeft = timelineContent.scrollLeft;
+ const viewportWidth = timelineContent.clientWidth;
const slack = (viewportWidth / pixelsPerSecond) * 0.1; // 10% of viewport width in seconds
const currentTime = (currentScrollLeft / pixelsPerSecond) + slack;
@@ -1461,12 +1706,12 @@
// Smooth vertical scroll to bring target sequence to top of viewport
const targetScrollTop = sequences[targetSeqIndex]?._yPosition || 0;
- const currentScrollTop = timelineContainer.scrollTop;
+ const currentScrollTop = timelineContent.scrollTop;
const scrollDiff = targetScrollTop - currentScrollTop;
// Smooth transition (don't jump instantly)
if (Math.abs(scrollDiff) > 5) {
- timelineContainer.scrollTop += scrollDiff * 0.3;
+ timelineContent.scrollTop += scrollDiff * 0.3;
}
}, { passive: false });