summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-12 08:44:42 +0100
committerskal <pascal.massimino@gmail.com>2026-02-12 08:44:42 +0100
commit4a1870d1d0cc4676797add05762ed196decd339d (patch)
treeb7ca500f287e34f1fee37f8d75c49ffbb481f693
parentec3a61b9823922f4b9f795834125d8ed97246f66 (diff)
feat: timeline editor playback improvements
- Add red bar playback indicator on waveform (synced with timeline) - Fix playback continuation after double-click seek (async/await) - Improve stopPlayback() to preserve jump positions - Add error handling to startPlayback() - Update waveform click-to-seek to match double-click behavior - Sync waveform indicator scroll with timeline - Display time in both seconds and beats on seek - Update documentation with new features handoff(Claude): Timeline editor now has dual playback indicators and seamless seeking. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
-rw-r--r--tools/timeline_editor/README.md32
-rw-r--r--tools/timeline_editor/ROADMAP.md32
-rw-r--r--tools/timeline_editor/index.html255
3 files changed, 243 insertions, 76 deletions
diff --git a/tools/timeline_editor/README.md b/tools/timeline_editor/README.md
index 6e368cf..4fcb2f4 100644
--- a/tools/timeline_editor/README.md
+++ b/tools/timeline_editor/README.md
@@ -18,19 +18,24 @@ 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)
+- ▶️ 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)
## 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. **Load audio:** Click "🎵 Load Audio (WAV)" → select audio file
+4. **Auto-load via URL:** `index.html?seq=timeline.seq&wav=audio.wav`
4. **Playback:**
- Click "▶ Play" or press **Spacebar** to play/pause
- - Click waveform to seek
+ - Click waveform to seek to position
+ - **Double-click timeline** to seek during playback (continues playing)
- Watch sequences auto-expand/collapse during playback
- - Red playback indicator shows current position
+ - Red playback indicators on both timeline and waveform show current position
5. **Edit:**
- Drag sequences/effects to reposition
- Double-click sequence header to collapse/expand
@@ -78,9 +83,26 @@ SEQUENCE 2.5s 0 "Explicit seconds" # Rare: start at 2.5 physical seconds
EFFECT + Fade 0 4 # Still uses beats for duration
```
+## URL Parameters
+
+Auto-load files on page load:
+```
+index.html?seq=../../workspaces/main/timeline.seq&wav=../../audio/track.wav
+```
+
+**Parameters:**
+- `seq` - Path to `.seq` file (relative or absolute URL)
+- `wav` - Path to `.wav` audio file (relative or absolute URL)
+
+**Example:**
+```bash
+open "tools/timeline_editor/index.html?seq=../../workspaces/main/timeline.seq"
+```
+
## Keyboard Shortcuts
- **Spacebar**: Play/pause audio playback
+- **Double-click timeline**: Seek to position (continues playing if active)
- **Ctrl/Cmd + Wheel**: Zoom in/out at cursor position
## Technical Notes
@@ -97,3 +119,5 @@ SEQUENCE 2.5s 0 "Explicit seconds" # Rare: start at 2.5 physical seconds
- 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)
+- **Dual playback indicators**: Red bars on both timeline and waveform (synchronized)
+- **Seamless seek**: Double-click or waveform click seeks without stopping playback
diff --git a/tools/timeline_editor/ROADMAP.md b/tools/timeline_editor/ROADMAP.md
index 216adbf..b14a73b 100644
--- a/tools/timeline_editor/ROADMAP.md
+++ b/tools/timeline_editor/ROADMAP.md
@@ -8,30 +8,22 @@ This document outlines planned enhancements for the interactive timeline editor.
### 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
+1. ~~**Audio waveform doesn't scale with zoom nor follow timeline**~~ ✅ FIXED
+ - Waveform now correctly syncs with timeline at all zoom levels
-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
+2. ~~**Playback indicator doesn't follow zoom and height issues**~~ ✅ FIXED
+ - Red bar now dynamically spans full timeline height
+ - Position correctly accounts for pixelsPerSecond
-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
+3. ~~**Sequences overlap timeline at scroll origin**~~ ✅ FIXED
+ - Proper padding prevents overlap with timeline border
-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
+4. ~~**Timeline and waveform should be fixed, not floating**~~ ✅ FIXED
+ - Sticky header stays at top during 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
+5. ~~**Status indicator causes reflow**~~ ✅ FIXED
+ - Messages now fixed positioned at top-right
+ - No layout shift when appearing/disappearing
---
diff --git a/tools/timeline_editor/index.html b/tools/timeline_editor/index.html
index c9385ad..b6e9223 100644
--- a/tools/timeline_editor/index.html
+++ b/tools/timeline_editor/index.html
@@ -105,7 +105,7 @@
.timeline-container {
background: #252526;
border-radius: 8px;
- padding: 20px;
+ padding: 0;
position: relative;
height: calc(100vh - 280px);
min-height: 500px;
@@ -118,6 +118,7 @@
overflow-x: auto;
overflow-y: auto;
position: relative;
+ padding: 0 20px 20px 20px;
/* Hide scrollbars while keeping scroll functionality */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
@@ -134,10 +135,11 @@
}
.sticky-header {
- position: relative;
+ position: sticky;
+ top: 0;
background: #252526;
z-index: 100;
- padding-bottom: 10px;
+ padding: 20px 20px 10px 20px;
border-bottom: 2px solid #3c3c3c;
flex-shrink: 0;
}
@@ -154,20 +156,27 @@
padding: 8px 12px;
}
- #waveformCanvas {
+ .waveform-container {
position: relative;
height: 80px;
- width: 100%;
+ overflow: hidden;
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
cursor: crosshair;
}
+ #waveformCanvas {
+ position: absolute;
+ left: 0;
+ top: 0;
+ height: 80px;
+ display: block;
+ }
+
.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;
@@ -476,12 +485,20 @@
color: #858585;
}
+ #messageArea {
+ position: fixed;
+ top: 80px;
+ right: 20px;
+ z-index: 2000;
+ max-width: 400px;
+ }
+
.error {
background: #5a1d1d;
color: #f48771;
padding: 10px;
border-radius: 4px;
- margin-bottom: 10px;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.success {
@@ -489,7 +506,7 @@
color: #89d185;
padding: 10px;
border-radius: 4px;
- margin-bottom: 10px;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
</style>
</head>
@@ -519,6 +536,10 @@
<span id="zoomLevel">100%</span>
<label style="margin-left: 20px">BPM: <input type="range" id="bpmSlider" min="60" max="200" value="120" step="1"></label>
<span id="currentBPM">120</span>
+ <div id="playbackControls" style="display: none; margin-left: 20px;">
+ <button id="playPauseBtn">▶ Play</button>
+ <span id="playbackTime">0.00s (0.00b)</span>
+ </div>
<label class="checkbox-label" style="margin-left: 20px">
<input type="checkbox" id="showBeatsCheckbox" checked>
Show Beats
@@ -529,11 +550,10 @@
<div class="timeline-container">
<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 class="waveform-container" id="waveformContainer" style="display: none;">
+ <div class="playback-indicator" id="waveformPlaybackIndicator"></div>
+ <canvas id="waveformCanvas"></canvas>
</div>
- <canvas id="waveformCanvas" style="display: none;"></canvas>
<div class="time-markers" id="timeMarkers"></div>
</div>
<div class="timeline-content" id="timelineContent">
@@ -587,6 +607,7 @@
const audioInput = document.getElementById('audioInput');
const clearAudioBtn = document.getElementById('clearAudioBtn');
const waveformCanvas = document.getElementById('waveformCanvas');
+ const waveformContainer = document.getElementById('waveformContainer');
const addSequenceBtn = document.getElementById('addSequenceBtn');
const deleteBtn = document.getElementById('deleteBtn');
const reorderBtn = document.getElementById('reorderBtn');
@@ -600,6 +621,7 @@
const playbackControls = document.getElementById('playbackControls');
const playbackTime = document.getElementById('playbackTime');
const playbackIndicator = document.getElementById('playbackIndicator');
+ const waveformPlaybackIndicator = document.getElementById('waveformPlaybackIndicator');
// Parser: timeline.seq → JavaScript objects
// Format specification: doc/SEQUENCE.md
@@ -740,7 +762,7 @@
audioDuration = audioBuffer.duration;
renderWaveform();
- waveformCanvas.style.display = 'block';
+ waveformContainer.style.display = 'block';
playbackControls.style.display = 'flex';
clearAudioBtn.disabled = false;
showMessage(`Audio loaded: ${audioDuration.toFixed(2)}s`, 'success');
@@ -771,6 +793,9 @@
canvas.style.width = `${canvasWidth}px`;
canvas.style.height = `${canvasHeight}px`;
+ // Set waveform playback indicator height
+ waveformPlaybackIndicator.style.height = `${canvasHeight}px`;
+
// Clear canvas
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
@@ -828,44 +853,70 @@
stopPlayback();
audioBuffer = null;
audioDuration = 0;
- waveformCanvas.style.display = 'none';
+ waveformContainer.style.display = 'none';
playbackControls.style.display = 'none';
clearAudioBtn.disabled = true;
renderTimeline();
showMessage('Audio cleared', 'success');
}
+ // Sync waveform scroll with timeline scroll
+ timelineContent.addEventListener('scroll', () => {
+ if (waveformCanvas) {
+ waveformCanvas.style.left = `-${timelineContent.scrollLeft}px`;
+ waveformPlaybackIndicator.style.transform = `translateX(-${timelineContent.scrollLeft}px)`;
+ }
+ });
+
// Playback functions
- function startPlayback() {
+ async function startPlayback() {
if (!audioBuffer || !audioContext) return;
+ // Stop any existing source first
+ if (audioSource) {
+ try {
+ audioSource.stop();
+ } catch (e) {
+ // Already stopped
+ }
+ audioSource = null;
+ }
+
// Resume audio context if suspended
if (audioContext.state === 'suspended') {
- audioContext.resume();
+ await audioContext.resume();
}
- // Create and start audio source
- audioSource = audioContext.createBufferSource();
- audioSource.buffer = audioBuffer;
- audioSource.connect(audioContext.destination);
- audioSource.start(0, playbackOffset);
+ try {
+ // 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');
+ playbackStartTime = audioContext.currentTime;
+ isPlaying = true;
+ playPauseBtn.textContent = '⏸ Pause';
+ playbackIndicator.classList.add('playing');
+ waveformPlaybackIndicator.classList.add('playing');
- // Start animation loop
- updatePlaybackPosition();
+ // Start animation loop
+ updatePlaybackPosition();
- audioSource.onended = () => {
- if (isPlaying) {
- stopPlayback();
- }
- };
+ audioSource.onended = () => {
+ if (isPlaying) {
+ stopPlayback();
+ }
+ };
+ } catch (e) {
+ console.error('Failed to start playback:', e);
+ showMessage('Playback failed: ' + e.message, 'error');
+ audioSource = null;
+ isPlaying = false;
+ }
}
- function stopPlayback() {
+ function stopPlayback(savePosition = true) {
if (audioSource) {
try {
audioSource.stop();
@@ -880,7 +931,7 @@
animationFrameId = null;
}
- if (isPlaying) {
+ if (isPlaying && savePosition) {
// Save current position for resume
const elapsed = audioContext.currentTime - playbackStartTime;
playbackOffset = Math.min(playbackOffset + elapsed, audioDuration);
@@ -889,6 +940,7 @@
isPlaying = false;
playPauseBtn.textContent = '▶ Play';
playbackIndicator.classList.remove('playing');
+ waveformPlaybackIndicator.classList.remove('playing');
}
function updatePlaybackPosition() {
@@ -896,16 +948,15 @@
const elapsed = audioContext.currentTime - playbackStartTime;
const currentTime = playbackOffset + elapsed;
+ const currentBeats = currentTime * bpm / 60.0;
// Update time display
- playbackTime.textContent = `${currentTime.toFixed(2)}s`;
-
- // Convert to beats for position calculation
- const currentBeats = currentTime * bpm / 60.0;
+ playbackTime.textContent = `${currentTime.toFixed(2)}s (${currentBeats.toFixed(2)}b)`;
// Update playback indicator position
const indicatorX = currentBeats * pixelsPerSecond;
playbackIndicator.style.left = `${indicatorX}px`;
+ waveformPlaybackIndicator.style.left = `${indicatorX}px`;
// Auto-scroll timeline to follow playback
const viewportWidth = timelineContent.clientWidth;
@@ -993,6 +1044,9 @@
const timelineWidth = maxTime * pixelsPerSecond;
timeline.style.width = `${timelineWidth}px`;
+ // Track timeline height for playback indicator
+ let totalTimelineHeight = 0;
+
if (showBeats) {
// Show beats (default)
for (let beat = 0; beat <= maxTime; beat += 4) {
@@ -1058,6 +1112,7 @@
// Store Y position for this sequence (used by effects and scroll)
seq._yPosition = cumulativeY;
cumulativeY += seqHeight + sequenceGap;
+ totalTimelineHeight = cumulativeY;
// Create sequence header (double-click to collapse)
const seqHeaderDiv = document.createElement('div');
@@ -1185,6 +1240,14 @@
}
});
+ // Set timeline minimum height to fit all sequences
+ timeline.style.minHeight = `${Math.max(totalTimelineHeight, timelineContent.offsetHeight)}px`;
+
+ // Update playback indicator height to match
+ if (playbackIndicator) {
+ playbackIndicator.style.height = `${Math.max(totalTimelineHeight, timelineContent.offsetHeight)}px`;
+ }
+
updateStats();
}
@@ -1475,7 +1538,7 @@
audioInput.value = ''; // Reset file input
});
- playPauseBtn.addEventListener('click', () => {
+ playPauseBtn.addEventListener('click', async () => {
if (isPlaying) {
stopPlayback();
} else {
@@ -1483,34 +1546,35 @@
if (playbackOffset >= audioDuration) {
playbackOffset = 0;
}
- startPlayback();
+ await startPlayback();
}
});
// Waveform click to seek
- waveformCanvas.addEventListener('click', (e) => {
+ waveformContainer.addEventListener('click', async (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 rect = waveformContainer.getBoundingClientRect();
+ const clickX = e.clientX - rect.left + timelineContent.scrollLeft;
+ const clickBeats = clickX / pixelsPerSecond;
const clickTime = clickBeats * 60.0 / bpm;
const wasPlaying = isPlaying;
if (wasPlaying) {
- stopPlayback();
+ stopPlayback(false); // Don't save position, we're jumping
}
playbackOffset = Math.max(0, Math.min(clickTime, audioDuration));
+ // Update display and position
+ const clickBeats = playbackOffset * bpm / 60.0;
+ playbackTime.textContent = `${playbackOffset.toFixed(2)}s (${clickBeats.toFixed(2)}b)`;
+ const indicatorX = clickBeats * pixelsPerSecond;
+ playbackIndicator.style.left = `${indicatorX}px`;
+ waveformPlaybackIndicator.style.left = `${indicatorX}px`;
+
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`;
+ await startPlayback();
}
});
@@ -1620,6 +1684,52 @@
updateProperties();
});
+ // Double-click to seek (jump playback position)
+ timeline.addEventListener('dblclick', async (e) => {
+ // Only handle clicks on timeline background (not on sequences/effects)
+ if (e.target !== timeline) return;
+
+ const timelineRect = timeline.getBoundingClientRect();
+ const clickX = e.clientX - timelineRect.left + timelineContent.scrollLeft;
+ const clickBeats = clickX / pixelsPerSecond;
+ const clickTime = clickBeats * 60.0 / bpm;
+
+ if (audioBuffer) {
+ const wasPlaying = isPlaying;
+ if (wasPlaying) {
+ stopPlayback(false); // Don't save position, we're jumping
+ }
+
+ playbackOffset = Math.max(0, Math.min(clickTime, audioDuration));
+
+ // Update display and position
+ const pausedBeats = playbackOffset * bpm / 60.0;
+ playbackTime.textContent = `${playbackOffset.toFixed(2)}s (${pausedBeats.toFixed(2)}b)`;
+ const indicatorX = pausedBeats * pixelsPerSecond;
+ const indicatorHeight = Math.max(timeline.offsetHeight, timelineContent.offsetHeight);
+ playbackIndicator.style.left = `${indicatorX}px`;
+ playbackIndicator.style.height = `${indicatorHeight}px`;
+ waveformPlaybackIndicator.style.left = `${indicatorX}px`;
+
+ if (wasPlaying) {
+ // Resume playback at new position
+ await startPlayback();
+ } else {
+ // Brief flash when paused
+ playbackIndicator.classList.add('playing');
+ waveformPlaybackIndicator.classList.add('playing');
+ setTimeout(() => {
+ if (!isPlaying) {
+ playbackIndicator.classList.remove('playing');
+ waveformPlaybackIndicator.classList.remove('playing');
+ }
+ }, 500);
+ }
+
+ showMessage(`Seek to ${clickTime.toFixed(2)}s (${clickBeats.toFixed(2)}b)`, 'success');
+ }
+ });
+
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Spacebar: play/pause (if audio loaded)
@@ -1740,8 +1850,49 @@
`;
}
+ // Load files from URL parameters
+ async function loadFromURLParams() {
+ const params = new URLSearchParams(window.location.search);
+ const seqURL = params.get('seq');
+ const wavURL = params.get('wav');
+
+ if (seqURL) {
+ try {
+ const response = await fetch(seqURL);
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const content = await response.text();
+ const parsed = parseSeqFile(content);
+ sequences = parsed.sequences;
+ bpm = parsed.bpm;
+ document.getElementById('currentBPM').textContent = bpm;
+ document.getElementById('bpmSlider').value = bpm;
+ currentFile = seqURL.split('/').pop();
+ renderTimeline();
+ saveBtn.disabled = false;
+ addSequenceBtn.disabled = false;
+ reorderBtn.disabled = false;
+ showMessage(`Loaded ${currentFile} from URL`, 'success');
+ } catch (err) {
+ showMessage(`Error loading seq file: ${err.message}`, 'error');
+ }
+ }
+
+ if (wavURL) {
+ try {
+ const response = await fetch(wavURL);
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const blob = await response.blob();
+ const file = new File([blob], wavURL.split('/').pop(), { type: 'audio/wav' });
+ await loadAudioFile(file);
+ } catch (err) {
+ showMessage(`Error loading audio file: ${err.message}`, 'error');
+ }
+ }
+ }
+
// Initial render
renderTimeline();
+ loadFromURLParams();
</script>
</body>
</html>