summaryrefslogtreecommitdiff
path: root/tools/timeline_editor
diff options
context:
space:
mode:
Diffstat (limited to 'tools/timeline_editor')
-rw-r--r--tools/timeline_editor/README.md63
-rw-r--r--tools/timeline_editor/ROADMAP.md32
-rw-r--r--tools/timeline_editor/index.html2127
3 files changed, 751 insertions, 1471 deletions
diff --git a/tools/timeline_editor/README.md b/tools/timeline_editor/README.md
index 6e368cf..72b5ae0 100644
--- a/tools/timeline_editor/README.md
+++ b/tools/timeline_editor/README.md
@@ -14,28 +14,49 @@ Interactive web-based editor for `timeline.seq` files.
- ⚙️ Stack-order based priority system
- 🔍 Zoom (10%-200%) with mouse wheel + Ctrl/Cmd
- 🎵 Audio waveform visualization (aligned to beats)
-- 🎼 Snap-to-beat mode (enabled by default)
+- 🎼 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
-- ▶️ **Audio playback with auto-expand/collapse** (NEW)
-- 🎚️ **Sticky audio track and timeline ticks** (NEW)
+- ✨ 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
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
+ - **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
- Drag effect handles to resize
+ - **Quantize:** Use dropdown or hotkeys (0-6) to snap to grid
6. **Zoom:** Ctrl/Cmd + mouse wheel (zooms at cursor position)
7. **Save:** Click "💾 Save timeline.seq"
@@ -78,9 +99,28 @@ 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
+- **0-6**: Quantize grid (0=Off, 1=1beat, 2=1/2, 3=1/4, 4=1/8, 5=1/16, 6=1/32)
+- **Double-click timeline**: Seek to position (continues playing if active)
+- **Double-click sequence**: Collapse/expand
- **Ctrl/Cmd + Wheel**: Zoom in/out at cursor position
## Technical Notes
@@ -91,9 +131,12 @@ SEQUENCE 2.5s 0 "Explicit seconds" # Rare: start at 2.5 physical seconds
- 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)
+- **Show Beats** toggle: Switch time markers between beats and seconds
+- Time markers show 4-beat/bar increments (beats) or 1s increments (seconds)
- **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
+- **Quantize grid**: Independent snap control (works in both beat and second display modes)
- **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..eca7b97 100644
--- a/tools/timeline_editor/index.html
+++ b/tools/timeline_editor/index.html
@@ -4,493 +4,105 @@
<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>
- * {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
+ :root {
+ --bg-dark: #1e1e1e;
+ --bg-medium: #252526;
+ --bg-light: #3c3c3c;
+ --text-primary: #d4d4d4;
+ --text-muted: #858585;
+ --accent-blue: #0e639c;
+ --accent-blue-hover: #1177bb;
+ --accent-green: #4ec9b0;
+ --accent-orange: #ce9178;
+ --accent-red: #f48771;
+ --border-color: #858585;
+ --gap: 10px;
+ --radius: 4px;
}
- body {
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
- background: #1e1e1e;
- color: #d4d4d4;
- padding: 20px;
- margin: 0;
- min-height: 100vh;
- box-sizing: border-box;
- }
-
- .container {
- max-width: 100%;
- width: 100%;
- margin: 0 auto;
- box-sizing: border-box;
- }
-
- header {
- background: #252526;
- padding: 20px;
- border-radius: 8px;
- margin-bottom: 20px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 20px;
- flex-wrap: wrap;
- }
-
- h1 {
- margin: 0;
- color: #4ec9b0;
- white-space: nowrap;
- }
-
- .controls {
- display: flex;
- gap: 10px;
- flex-wrap: wrap;
- align-items: center;
- }
-
- .checkbox-label {
- display: flex;
- align-items: center;
- gap: 8px;
- color: #d4d4d4;
- cursor: pointer;
- user-select: none;
- }
-
- .checkbox-label input[type="checkbox"] {
- cursor: pointer;
- }
-
- button {
- background: #0e639c;
- color: white;
- border: none;
- padding: 10px 20px;
- border-radius: 4px;
- cursor: pointer;
- font-size: 14px;
- }
-
- button:hover {
- background: #1177bb;
- }
-
- button:disabled {
- background: #3c3c3c;
- cursor: not-allowed;
- }
-
- input[type="file"] {
- display: none;
- }
-
- .file-label {
- background: #0e639c;
- color: white;
- padding: 10px 20px;
- border-radius: 4px;
- cursor: pointer;
- display: inline-block;
- }
-
- .file-label:hover {
- background: #1177bb;
- }
-
- .timeline-container {
- background: #252526;
- border-radius: 8px;
- padding: 20px;
- 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-content::-webkit-scrollbar {
- display: none; /* Chrome/Safari/Opera */
- }
-
- .timeline {
- position: relative;
- min-height: 100%;
- 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;
- }
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: var(--bg-dark); color: var(--text-primary); padding: 20px; min-height: 100vh; }
+ .container { max-width: 100%; width: 100%; margin: 0 auto; }
- #waveformCanvas {
- position: relative;
- height: 80px;
- width: 100%;
- background: rgba(0, 0, 0, 0.3);
- border-radius: 4px;
- cursor: crosshair;
- }
+ header { background: var(--bg-medium); padding: 20px; border-radius: 8px; margin-bottom: 20px; display: flex; align-items: center; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
+ h1 { color: var(--accent-green); white-space: nowrap; }
+ .controls { display: flex; gap: var(--gap); flex-wrap: wrap; align-items: center; }
+ .zoom-controls { display: flex; gap: var(--gap); flex-wrap: wrap; align-items: center; margin-bottom: var(--gap); }
- .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;
- }
+ button, .file-label { background: var(--accent-blue); color: white; border: none; padding: 10px 20px; border-radius: var(--radius); cursor: pointer; font-size: 14px; display: inline-block; }
+ button:hover, .file-label:hover { background: var(--accent-blue-hover); }
+ button:disabled { background: var(--bg-light); cursor: not-allowed; }
+ input[type="file"] { display: none; }
- .playback-indicator.playing {
- display: block;
- }
+ .checkbox-label { display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; }
+ .checkbox-label input[type="checkbox"] { cursor: pointer; }
- .time-markers {
- position: relative;
- height: 30px;
- margin-top: 10px;
- border-bottom: 1px solid #3c3c3c;
- }
+ .timeline-container { background: var(--bg-medium); border-radius: 8px; position: relative; height: calc(100vh - 280px); min-height: 500px; display: flex; flex-direction: column; }
+ .timeline-content { flex: 1; overflow: auto; position: relative; padding: 0 20px 20px 20px; scrollbar-width: none; -ms-overflow-style: none; }
+ .timeline-content::-webkit-scrollbar { display: none; }
+ .timeline { position: relative; min-height: 100%; border-left: 2px solid var(--bg-light); }
- .time-marker {
- position: absolute;
- top: 0;
- font-size: 12px;
- color: #858585;
- }
+ .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; }
+ #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; }
- .time-marker::before {
- content: '';
- position: absolute;
- left: 0;
- top: 20px;
- width: 1px;
- height: 10px;
- background: #3c3c3c;
- }
+ .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; }
- .time-marker::after {
- content: '';
- position: absolute;
- left: 0;
- top: 30px;
- width: 1px;
- height: 10000px;
- background: rgba(60, 60, 60, 0.2);
- pointer-events: none;
- }
-
- .sequence {
- position: absolute;
- background: #264f78;
- border: 2px solid #0e639c;
- border-radius: 4px;
- padding: 8px;
- cursor: move;
- min-height: 40px;
- transition: box-shadow 0.2s;
- }
-
- .sequence:hover {
- box-shadow: 0 0 10px rgba(14, 99, 156, 0.5);
- }
-
- .sequence.selected {
- border-color: #4ec9b0;
- box-shadow: 0 0 10px rgba(78, 201, 176, 0.5);
- }
-
- .sequence.active-flash {
- animation: sequenceFlash 0.6s ease-out;
- }
+ .time-markers { position: relative; height: 30px; margin-top: var(--gap); border-bottom: 1px solid var(--bg-light); }
+ .time-marker { position: absolute; top: 0; font-size: 12px; color: var(--text-muted); }
+ .time-marker::before { content: ''; position: absolute; left: 0; top: 20px; width: 1px; height: 10px; background: var(--bg-light); }
+ .time-marker::after { content: ''; position: absolute; left: 0; top: 30px; width: 1px; height: 10000px; background: rgba(60, 60, 60, 0.2); pointer-events: none; }
+ .sequence { position: absolute; background: #264f78; border: 2px solid var(--accent-blue); border-radius: var(--radius); padding: 8px; cursor: move; min-height: 40px; transition: box-shadow 0.2s; }
+ .sequence:hover { box-shadow: 0 0 10px rgba(14, 99, 156, 0.5); }
+ .sequence.selected { border-color: var(--accent-green); box-shadow: 0 0 10px rgba(78, 201, 176, 0.5); }
+ .sequence.collapsed { overflow: hidden !important; background: #1a3a4a !important; }
+ .sequence.collapsed .sequence-name { display: none !important; }
+ .sequence.active-playing { border-color: var(--accent-green); background: #2a5f4a; }
+ .sequence.active-flash { animation: sequenceFlash 0.6s ease-out; }
@keyframes sequenceFlash {
- 0% {
- box-shadow: 0 0 20px rgba(78, 201, 176, 0.8);
- border-color: #4ec9b0;
- }
- 100% {
- box-shadow: 0 0 10px rgba(14, 99, 156, 0.5);
- border-color: #0e639c;
- }
- }
-
- .sequence-header {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- padding: 8px;
- z-index: 5;
- cursor: pointer;
- user-select: none;
- }
-
- .sequence-header-name {
- font-size: 14px;
- font-weight: bold;
- color: #ffffff;
- }
-
- .sequence:not(.collapsed) .sequence-header-name {
- display: none;
- }
-
- .sequence.collapsed {
- overflow: hidden !important;
- background: #1a3a4a !important;
- }
-
- .sequence.collapsed .sequence-name {
- display: none !important;
+ 0% { box-shadow: 0 0 20px rgba(78, 201, 176, 0.8); border-color: var(--accent-green); }
+ 100% { box-shadow: 0 0 10px rgba(14, 99, 156, 0.5); border-color: var(--accent-blue); }
}
- .sequence-name {
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- font-size: 24px;
- font-weight: bold;
- color: #ffffff;
- text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.9),
- -1px -1px 4px rgba(0, 0, 0, 0.7);
- pointer-events: none;
- white-space: nowrap;
- opacity: 1;
- transition: opacity 0.3s ease;
- z-index: 10;
- }
+ .sequence-header { position: absolute; top: 0; left: 0; right: 0; padding: 8px; z-index: 5; cursor: move; user-select: none; }
+ .sequence-header-name { font-size: 14px; font-weight: bold; color: #ffffff; }
+ .sequence:not(.collapsed) .sequence-header-name { display: none; }
+ .sequence-name { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 24px; font-weight: bold; color: #ffffff; text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.9), -1px -1px 4px rgba(0, 0, 0, 0.7); pointer-events: none; white-space: nowrap; opacity: 1; transition: opacity 0.3s ease; z-index: 10; }
+ .sequence.hovered .sequence-name { opacity: 0; }
- .sequence.hovered .sequence-name {
- opacity: 0;
- }
+ .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; }
+ .effect-handle.right { right: 0; border-radius: 0 3px 3px 0; }
+ .effect-handle:hover { background: var(--accent-green); width: 8px; }
- .sequence-info {
- position: absolute;
- top: 8px;
- left: 8px;
- font-size: 11px;
- color: #858585;
- pointer-events: none;
- }
+ .properties-panel { position: fixed; bottom: 20px; left: 20px; width: 350px; max-height: 80vh; background: var(--bg-medium); padding: 15px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); z-index: 1000; overflow-y: auto; transition: transform 0.3s ease; }
+ .properties-panel.collapsed { transform: translateY(calc(100% + 40px)); }
+ .panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 1px solid var(--bg-light); }
+ .panel-header h2 { margin: 0; color: var(--accent-green); font-size: 16px; }
+ .panel-toggle { background: transparent; border: 1px solid var(--border-color); color: var(--text-primary); padding: 4px 8px; border-radius: 3px; cursor: pointer; font-size: 12px; }
+ .panel-toggle:hover { background: var(--bg-light); }
+ .panel-collapse-btn { position: fixed; bottom: 20px; left: 20px; background: var(--bg-medium); border: 1px solid var(--border-color); color: var(--text-primary); padding: 8px 12px; border-radius: var(--radius); cursor: pointer; z-index: 999; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); display: none; }
+ .panel-collapse-btn:hover { background: var(--bg-light); }
+ .panel-collapse-btn.visible { display: block; }
- .effect {
- position: absolute;
- background: #3a3d41;
- border: 1px solid #858585;
- 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: #ce9178;
- box-shadow: 0 0 8px rgba(206, 145, 120, 0.5);
- }
-
- .effect small {
- font-size: 11px;
- color: #d4d4d4;
- }
-
- .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;
- }
+ .property-group { margin-bottom: 15px; }
+ .property-group label { display: block; margin-bottom: 5px; color: var(--text-muted); font-size: 14px; }
+ .property-group input, .property-group select { width: 100%; padding: 8px; background: var(--bg-light); border: 1px solid var(--border-color); border-radius: var(--radius); color: var(--text-primary); font-size: 14px; }
- .effect.selected .effect-handle {
- display: block;
- }
-
- .effect-handle.left {
- left: 0;
- border-radius: 3px 0 0 3px;
- }
-
- .effect-handle.right {
- right: 0;
- border-radius: 0 3px 3px 0;
- }
-
- .effect-handle:hover {
- background: rgba(78, 201, 176, 1);
- width: 8px;
- }
-
- .properties-panel {
- position: fixed;
- bottom: 20px;
- left: 20px;
- width: 350px;
- max-height: 80vh;
- background: #252526;
- padding: 15px;
- border-radius: 8px;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
- z-index: 1000;
- overflow-y: auto;
- transition: transform 0.3s ease;
- }
-
- .properties-panel.collapsed {
- transform: translateY(calc(100% + 40px));
- }
-
- .panel-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 15px;
- padding-bottom: 10px;
- border-bottom: 1px solid #3c3c3c;
- }
-
- .panel-header h2 {
- margin: 0;
- color: #4ec9b0;
- font-size: 16px;
- }
-
- .panel-toggle {
- background: transparent;
- border: 1px solid #858585;
- color: #d4d4d4;
- padding: 4px 8px;
- border-radius: 3px;
- cursor: pointer;
- font-size: 12px;
- }
-
- .panel-toggle:hover {
- background: #3c3c3c;
- }
-
- .panel-collapse-btn {
- position: fixed;
- bottom: 20px;
- left: 20px;
- background: #252526;
- border: 1px solid #858585;
- color: #d4d4d4;
- padding: 8px 12px;
- border-radius: 4px;
- cursor: pointer;
- z-index: 999;
- box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
- display: none;
- }
-
- .panel-collapse-btn:hover {
- background: #3c3c3c;
- }
-
- .panel-collapse-btn.visible {
- display: block;
- }
-
- .property-group {
- margin-bottom: 15px;
- }
-
- .property-group label {
- display: block;
- margin-bottom: 5px;
- color: #858585;
- font-size: 14px;
- }
-
- .property-group input,
- .property-group select {
- width: 100%;
- padding: 8px;
- background: #3c3c3c;
- border: 1px solid #858585;
- border-radius: 4px;
- color: #d4d4d4;
- font-size: 14px;
- }
-
- .zoom-controls {
- margin-bottom: 10px;
- }
-
- .stats {
- background: #1e1e1e;
- padding: 10px;
- border-radius: 4px;
- margin-top: 10px;
- font-size: 12px;
- color: #858585;
- }
-
- .error {
- background: #5a1d1d;
- color: #f48771;
- padding: 10px;
- border-radius: 4px;
- margin-bottom: 10px;
- }
-
- .success {
- background: #1e5231;
- color: #89d185;
- padding: 10px;
- border-radius: 4px;
- margin-bottom: 10px;
- }
+ .stats { background: var(--bg-dark); padding: 10px; border-radius: var(--radius); margin-top: 10px; font-size: 12px; color: var(--text-muted); }
+ #messageArea { position: fixed; top: 80px; right: 20px; z-index: 2000; max-width: 400px; }
+ .error { background: #5a1d1d; color: var(--accent-red); padding: 10px; border-radius: var(--radius); box-shadow: 0 2px 8px rgba(0,0,0,0.3); }
+ .success { background: #1e5231; color: #89d185; padding: 10px; border-radius: var(--radius); box-shadow: 0 2px 8px rgba(0,0,0,0.3); }
</style>
</head>
<body>
@@ -498,17 +110,12 @@
<header>
<h1>📊 Timeline Editor</h1>
<div class="controls">
- <label class="file-label">
- 📂 Load timeline.seq
- <input type="file" id="fileInput" accept=".seq">
- </label>
+ <label class="file-label">📂 Load timeline.seq<input type="file" id="fileInput" accept=".seq"></label>
<button id="saveBtn" disabled>💾 Save timeline.seq</button>
- <label class="file-label">
- 🎵 Load Audio (WAV)
- <input type="file" id="audioInput" accept=".wav">
- </label>
+ <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>
@@ -520,20 +127,34 @@
<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>
<label class="checkbox-label" style="margin-left: 20px">
- <input type="checkbox" id="showBeatsCheckbox" checked>
- Show Beats
+ <input type="checkbox" id="showBeatsCheckbox" checked>Show Beats
+ </label>
+ <label style="margin-left: 20px">Quantize:
+ <select id="quantizeSelect">
+ <option value="0">Off</option>
+ <option value="32">1/32</option>
+ <option value="16">1/16</option>
+ <option value="8">1/8</option>
+ <option value="4">1/4</option>
+ <option value="2">1/2</option>
+ <option value="1" selected>1 beat</option>
+ </select>
</label>
+ <div id="playbackControls" style="display: none; margin-left: 20px; gap: 10px; align-items: center;">
+ <span id="playbackTime">0.00s (0.00b)</span>
+ <button id="playPauseBtn">▶ Play</button>
+ </div>
</div>
<div id="messageArea"></div>
<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">
+ <canvas id="cpuLoadCanvas"></canvas>
+ <canvas id="waveformCanvas"></canvas>
+ <div class="playback-indicator" id="waveformPlaybackIndicator"></div>
</div>
- <canvas id="waveformCanvas" style="display: none;"></canvas>
<div class="time-markers" id="timeMarkers"></div>
</div>
<div class="timeline-content" id="timelineContent">
@@ -556,196 +177,159 @@
</div>
<script>
- // Global state
- let sequences = [];
- let currentFile = null;
- let selectedItem = null;
- let pixelsPerSecond = 100;
- let showBeats = true;
- let bpm = 120;
- let isDragging = false;
- let dragOffset = { x: 0, y: 0 };
- let lastActiveSeqIndex = -1;
- let isDraggingHandle = false;
- 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;
+ // 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, handleDragOffset: 0,
+ audioBuffer: null, audioDuration: 0, audioSource: null, audioContext: null,
+ isPlaying: false, playbackStartTime: 0, playbackOffset: 0, animationFrameId: null,
+ lastExpandedSeqIndex: -1, dragMoved: false
+ };
- // 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');
- const clearAudioBtn = document.getElementById('clearAudioBtn');
- const waveformCanvas = document.getElementById('waveformCanvas');
- const addSequenceBtn = document.getElementById('addSequenceBtn');
- const deleteBtn = document.getElementById('deleteBtn');
- const reorderBtn = document.getElementById('reorderBtn');
- const propertiesPanel = document.getElementById('propertiesPanel');
- const propertiesContent = document.getElementById('propertiesContent');
- const messageArea = document.getElementById('messageArea');
- 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');
+ // DOM
+ const dom = {
+ timeline: document.getElementById('timeline'),
+ timelineContent: document.getElementById('timelineContent'),
+ fileInput: document.getElementById('fileInput'),
+ saveBtn: document.getElementById('saveBtn'),
+ audioInput: document.getElementById('audioInput'),
+ 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'),
+ propertiesContent: document.getElementById('propertiesContent'),
+ messageArea: document.getElementById('messageArea'),
+ zoomSlider: document.getElementById('zoomSlider'),
+ zoomLevel: document.getElementById('zoomLevel'),
+ stats: document.getElementById('stats'),
+ playbackControls: document.getElementById('playbackControls'),
+ playPauseBtn: document.getElementById('playPauseBtn'),
+ playbackTime: document.getElementById('playbackTime'),
+ playbackIndicator: document.getElementById('playbackIndicator'),
+ waveformPlaybackIndicator: document.getElementById('waveformPlaybackIndicator'),
+ panelToggle: document.getElementById('panelToggle'),
+ panelCollapseBtn: document.getElementById('panelCollapseBtn'),
+ bpmSlider: document.getElementById('bpmSlider'),
+ currentBPM: document.getElementById('currentBPM'),
+ showBeatsCheckbox: document.getElementById('showBeatsCheckbox'),
+ quantizeSelect: document.getElementById('quantizeSelect')
+ };
- // Parser: timeline.seq → JavaScript objects
- // Format specification: doc/SEQUENCE.md
+ // Parser
function parseSeqFile(content) {
const sequences = [];
- const lines = content.split('\n');
- let currentSequence = null;
- let bpm = 120; // Default BPM
- let currentPriority = 0; // Track priority for + = - modifiers
+ let currentSequence = null, bpm = 120, currentPriority = 0;
- // 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')) {
- // Explicit beats: "4b" = 4 beats
- return parseFloat(timeStr.slice(0, -1));
- }
- // Default: beats
+ const parseTime = (timeStr) => {
+ if (timeStr.endsWith('s')) return parseFloat(timeStr.slice(0, -1)) * bpm / 60.0;
+ if (timeStr.endsWith('b')) return parseFloat(timeStr.slice(0, -1));
return parseFloat(timeStr);
- }
+ };
- // Helper: Strip inline comments
- function stripComment(line) {
- const commentIdx = line.indexOf('#');
- if (commentIdx >= 0) {
- return line.slice(0, commentIdx).trim();
- }
- return line;
- }
+ const stripComment = (line) => {
+ const idx = line.indexOf('#');
+ return idx >= 0 ? line.slice(0, idx).trim() : line;
+ };
- for (let line of lines) {
+ for (let line of content.split('\n')) {
line = line.trim();
-
- // Skip empty lines
- if (!line) continue;
-
- // Parse BPM comment
- if (line.startsWith('# BPM ')) {
- const bpmMatch = line.match(/# BPM (\d+)/);
- if (bpmMatch) {
- bpm = parseInt(bpmMatch[1]);
+ if (!line || line.startsWith('#')) {
+ if (line.startsWith('# BPM ')) {
+ const m = line.match(/# BPM (\d+)/);
+ if (m) bpm = parseInt(m[1]);
}
continue;
}
-
- // Skip other comments
- if (line.startsWith('#')) continue;
-
- // Strip inline comments
line = stripComment(line);
if (!line) continue;
- // Parse SEQUENCE line: SEQUENCE <time> <priority> [name] [end]
const seqMatch = line.match(/^SEQUENCE\s+(\S+)\s+(\d+)(?:\s+"([^"]+)")?(?:\s+(\S+))?$/);
if (seqMatch) {
- currentSequence = {
- type: 'sequence',
- startTime: parseTime(seqMatch[1]),
- priority: parseInt(seqMatch[2]),
- effects: [],
- name: seqMatch[3] || '',
- _collapsed: true
- };
+ currentSequence = { type: 'sequence', startTime: parseTime(seqMatch[1]), priority: parseInt(seqMatch[2]), effects: [], name: seqMatch[3] || '', _collapsed: true };
sequences.push(currentSequence);
- currentPriority = -1; // Reset effect priority for new sequence
+ currentPriority = -1;
continue;
}
- // Parse EFFECT line: EFFECT <modifier> <ClassName> <start> <end> [args]
const effectMatch = line.match(/^EFFECT\s+([+=-])\s+(\w+)\s+(\S+)\s+(\S+)(?:\s+(.*))?$/);
if (effectMatch && currentSequence) {
const modifier = effectMatch[1];
+ if (modifier === '+') currentPriority++;
+ else if (modifier === '-') currentPriority--;
+ currentSequence.effects.push({
+ type: 'effect', className: effectMatch[2],
+ startTime: parseTime(effectMatch[3]), endTime: parseTime(effectMatch[4]),
+ priority: currentPriority, priorityModifier: modifier, args: effectMatch[5] || ''
+ });
+ }
+ }
+ return { sequences, bpm };
+ }
- // Update priority based on modifier
- if (modifier === '+') {
- currentPriority++;
- } else if (modifier === '-') {
- currentPriority--;
- }
- // '=' keeps current priority
+ // 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)`;
+ };
- const effect = {
- type: 'effect',
- className: effectMatch[2],
- startTime: parseTime(effectMatch[3]),
- endTime: parseTime(effectMatch[4]),
- priority: currentPriority,
- priorityModifier: modifier,
- args: effectMatch[5] || ''
- };
- currentSequence.effects.push(effect);
+ 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 { sequences, bpm };
+ return conflicts;
}
- // Serializer: JavaScript objects → timeline.seq (outputs beats)
function serializeSeqFile(sequences) {
- let output = '# Demo Timeline\n';
- output += '# Generated by Timeline Editor\n';
- output += `# BPM ${bpm}\n\n`;
-
+ 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) {
- // Strip priority comments from 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';
}
-
return output;
}
- // Audio waveform visualization
+ // Audio
async function loadAudioFile(file) {
try {
const arrayBuffer = await file.arrayBuffer();
- if (!audioContext) {
- audioContext = new (window.AudioContext || window.webkitAudioContext)();
- }
- audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
- audioDuration = audioBuffer.duration;
-
+ if (!state.audioContext) state.audioContext = new (window.AudioContext || window.webkitAudioContext)();
+ state.audioBuffer = await state.audioContext.decodeAudioData(arrayBuffer);
+ state.audioDuration = state.audioBuffer.duration;
renderWaveform();
- waveformCanvas.style.display = 'block';
- playbackControls.style.display = 'flex';
- clearAudioBtn.disabled = false;
- showMessage(`Audio loaded: ${audioDuration.toFixed(2)}s`, 'success');
-
- // Extend timeline if audio is longer than current max time
+ dom.playbackControls.style.display = 'flex';
+ dom.clearAudioBtn.disabled = false;
+ showMessage(`Audio loaded: ${state.audioDuration.toFixed(2)}s`, 'success');
renderTimeline();
} catch (err) {
showMessage(`Error loading audio: ${err.message}`, 'error');
@@ -753,995 +337,656 @@
}
function renderWaveform() {
- if (!audioBuffer) return;
-
- const canvas = waveformCanvas;
- const ctx = canvas.getContext('2d');
-
- // 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)
- canvas.width = canvasWidth;
- canvas.height = canvasHeight;
-
- // Set CSS size to match
- canvas.style.width = `${canvasWidth}px`;
- canvas.style.height = `${canvasHeight}px`;
+ if (!state.audioBuffer) return;
+ const canvas = dom.waveformCanvas, ctx = canvas.getContext('2d');
+ 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);
- // Clear canvas
- ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
- ctx.fillRect(0, 0, canvasWidth, canvasHeight);
+ const channelData = state.audioBuffer.getChannelData(0);
+ const samplesPerPixel = Math.ceil(channelData.length / w);
+ const centerY = h / 2, amplitudeScale = h * 0.4;
- // Get audio data (use first channel for mono, or mix for stereo)
- const channelData = audioBuffer.getChannelData(0);
- const sampleRate = audioBuffer.sampleRate;
- const samplesPerPixel = Math.ceil(channelData.length / canvasWidth);
-
- // Draw waveform
- ctx.strokeStyle = '#4ec9b0';
- ctx.lineWidth = 1;
- ctx.beginPath();
-
- const centerY = canvasHeight / 2;
- const amplitudeScale = canvasHeight * 0.4; // Use 80% of height
-
- for (let x = 0; x < canvasWidth; x++) {
- const startSample = Math.floor(x * samplesPerPixel);
- const endSample = Math.min(startSample + samplesPerPixel, channelData.length);
-
- // Find min and max amplitude in this pixel range (for better visualization)
- let min = 1.0;
- let max = -1.0;
- for (let i = startSample; i < endSample; i++) {
- const sample = channelData[i];
- if (sample < min) min = sample;
- if (sample > max) max = sample;
- }
-
- // Draw vertical line from min to max
- const yMin = centerY - min * amplitudeScale;
- const yMax = centerY - max * amplitudeScale;
-
- if (x === 0) {
- ctx.moveTo(x, yMin);
- } else {
- ctx.lineTo(x, yMin);
+ ctx.strokeStyle = '#4ec9b0'; ctx.lineWidth = 1; ctx.beginPath();
+ 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 = 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;
+ x === 0 ? ctx.moveTo(x, yMin) : ctx.lineTo(x, yMin);
ctx.lineTo(x, yMax);
}
-
ctx.stroke();
-
- // Draw center line
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
- ctx.lineWidth = 1;
- ctx.beginPath();
- ctx.moveTo(0, centerY);
- ctx.lineTo(canvasWidth, centerY);
- ctx.stroke();
- }
-
- function clearAudio() {
- stopPlayback();
- audioBuffer = null;
- audioDuration = 0;
- waveformCanvas.style.display = 'none';
- playbackControls.style.display = 'none';
- clearAudioBtn.disabled = true;
- renderTimeline();
- showMessage('Audio cleared', 'success');
+ ctx.beginPath(); ctx.moveTo(0, centerY); ctx.lineTo(w, centerY); ctx.stroke();
}
- // 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);
+ 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));
- playbackStartTime = audioContext.currentTime;
- isPlaying = true;
- playPauseBtn.textContent = '⏸ Pause';
- playbackIndicator.classList.add('playing');
+ const resolution = 0.1, numSamples = Math.ceil(maxTime / resolution);
+ const loads = new Array(numSamples).fill(0);
+ const conflicts = new Array(numSamples).fill(false);
- // Start animation loop
- updatePlaybackPosition();
-
- audioSource.onended = () => {
- if (isPlaying) {
- stopPlayback();
- }
+ 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;
};
- }
- function stopPlayback() {
- if (audioSource) {
- try {
- audioSource.stop();
- } catch (e) {
- // Already stopped
- }
- audioSource = null;
- }
+ // 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;
+ }));
- if (animationFrameId) {
- cancelAnimationFrame(animationFrameId);
- animationFrameId = null;
- }
-
- if (isPlaying) {
- // Save current position for resume
- const elapsed = audioContext.currentTime - playbackStartTime;
- playbackOffset = Math.min(playbackOffset + elapsed, audioDuration);
- }
+ // 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));
+ });
+ });
- isPlaying = false;
- playPauseBtn.textContent = '▶ Play';
- playbackIndicator.classList.remove('playing');
- }
+ // Detect cross-sequence conflicts
+ const timeGroups = {};
+ state.sequences.forEach((seq, idx) =>
+ (timeGroups[seq.startTime.toFixed(2)] ??= []).push(idx));
- function updatePlaybackPosition() {
- if (!isPlaying) return;
+ 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));
+ });
+ });
- const elapsed = audioContext.currentTime - playbackStartTime;
- const currentTime = playbackOffset + elapsed;
+ return { maxTime, loads, conflicts, resolution };
+ }
- // Update time display
- playbackTime.textContent = `${currentTime.toFixed(2)}s`;
+ 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;
- // Convert to beats for position calculation
- const currentBeats = currentTime * bpm / 60.0;
+ 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);
+ });
+ }
- // Update playback indicator position
- const indicatorX = currentBeats * pixelsPerSecond;
- playbackIndicator.style.left = `${indicatorX}px`;
+ function clearAudio() {
+ stopPlayback(); state.audioBuffer = null; state.audioDuration = 0;
+ 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');
+ }
- // Auto-scroll timeline to follow playback
- const viewportWidth = timelineContent.clientWidth;
- const scrollX = timelineContent.scrollLeft;
- const relativeX = indicatorX - scrollX;
+ async function startPlayback() {
+ if (!state.audioBuffer || !state.audioContext) return;
+ if (state.audioSource) try { state.audioSource.stop(); } catch (e) {} state.audioSource = null;
+ if (state.audioContext.state === 'suspended') await state.audioContext.resume();
+ try {
+ state.audioSource = state.audioContext.createBufferSource();
+ state.audioSource.buffer = state.audioBuffer;
+ state.audioSource.connect(state.audioContext.destination);
+ state.audioSource.start(0, state.playbackOffset);
+ state.playbackStartTime = state.audioContext.currentTime;
+ state.isPlaying = true; dom.playPauseBtn.textContent = '⏸ Pause';
+ updatePlaybackPosition();
+ state.audioSource.onended = () => { if (state.isPlaying) stopPlayback(); };
+ } catch (e) {
+ console.error('Failed to start playback:', e); showMessage('Playback failed: ' + e.message, 'error');
+ state.audioSource = null; state.isPlaying = false;
+ }
+ }
- // Keep indicator in middle third of viewport
- if (relativeX < viewportWidth * 0.33 || relativeX > viewportWidth * 0.67) {
- timelineContent.scrollLeft = indicatorX - viewportWidth * 0.5;
+ function stopPlayback(savePosition = true) {
+ if (state.audioSource) try { state.audioSource.stop(); } catch (e) {} state.audioSource = null;
+ if (state.animationFrameId) { cancelAnimationFrame(state.animationFrameId); state.animationFrameId = null; }
+ if (state.isPlaying && savePosition) {
+ const elapsed = state.audioContext.currentTime - state.playbackStartTime;
+ state.playbackOffset = Math.min(state.playbackOffset + elapsed, state.audioDuration);
}
+ state.isPlaying = false; dom.playPauseBtn.textContent = '▶ Play';
+ }
- // Auto-expand/collapse sequences
+ function updatePlaybackPosition() {
+ if (!state.isPlaying) return;
+ const elapsed = state.audioContext.currentTime - state.playbackStartTime;
+ 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 = 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);
-
- // Continue animation
- animationFrameId = requestAnimationFrame(updatePlaybackPosition);
+ state.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;
- }
+ for (let i = 0; i < state.sequences.length; i++) {
+ const seq = state.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;
+ if (activeSeqIndex !== state.lastExpandedSeqIndex) {
+ const seqDivs = dom.timeline.querySelectorAll('.sequence');
+ if (state.lastExpandedSeqIndex >= 0 && seqDivs[state.lastExpandedSeqIndex]) {
+ seqDivs[state.lastExpandedSeqIndex].classList.remove('active-playing');
}
-
- // 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);
- }
+ if (activeSeqIndex >= 0 && seqDivs[activeSeqIndex]) {
+ seqDivs[activeSeqIndex].classList.add('active-playing');
}
-
- // Re-render to show collapse/expand changes
- renderTimeline();
+ state.lastExpandedSeqIndex = activeSeqIndex;
}
}
- // Render timeline
+ // Render
function renderTimeline() {
- timeline.innerHTML = '';
- const timeMarkers = document.getElementById('timeMarkers');
- timeMarkers.innerHTML = '';
-
- // Calculate max time (in beats)
- let maxTime = 60; // Default 60 beats (15 bars)
- for (const seq of sequences) {
- const seqEnd = seq.startTime + 16; // Default 4 bars
- maxTime = Math.max(maxTime, seqEnd);
-
- for (const effect of seq.effects) {
- maxTime = Math.max(maxTime, seq.startTime + effect.endTime);
- }
- }
-
- // Extend timeline to fit audio if loaded
- if (audioDuration > 0) {
- const audioBeats = audioDuration * bpm / 60.0;
- maxTime = Math.max(maxTime, audioBeats);
+ renderCPULoad();
+ dom.timeline.innerHTML = ''; document.getElementById('timeMarkers').innerHTML = '';
+ let maxTime = 60;
+ for (const seq of state.sequences) {
+ maxTime = Math.max(maxTime, seq.startTime + 16);
+ for (const effect of seq.effects) maxTime = Math.max(maxTime, seq.startTime + effect.endTime);
}
-
- // Render time markers
- const timelineWidth = maxTime * pixelsPerSecond;
- timeline.style.width = `${timelineWidth}px`;
-
- if (showBeats) {
- // Show beats (default)
+ if (state.audioDuration > 0) maxTime = Math.max(maxTime, state.audioDuration * state.bpm / 60.0);
+ const timelineWidth = maxTime * state.pixelsPerSecond;
+ dom.timeline.style.width = `${timelineWidth}px`;
+ let totalTimelineHeight = 0;
+ const timeMarkers = document.getElementById('timeMarkers');
+ if (state.showBeats) {
for (let beat = 0; beat <= maxTime; beat += 4) {
const marker = document.createElement('div');
- marker.className = 'time-marker';
- marker.style.left = `${beat * pixelsPerSecond}px`;
- marker.textContent = `${beat}b`;
- timeMarkers.appendChild(marker);
+ marker.className = 'time-marker'; marker.style.left = `${beat * state.pixelsPerSecond}px`;
+ marker.textContent = `${beat}b`; timeMarkers.appendChild(marker);
}
} else {
- // Show seconds
- const maxSeconds = maxTime * 60.0 / bpm;
+ const maxSeconds = maxTime * 60.0 / state.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 = `${beatPos * pixelsPerSecond}px`;
- marker.textContent = `${t}s`;
- timeMarkers.appendChild(marker);
+ const beatPos = t * state.bpm / 60.0, marker = document.createElement('div');
+ marker.className = 'time-marker'; marker.style.left = `${beatPos * state.pixelsPerSecond}px`;
+ marker.textContent = `${t}s`; timeMarkers.appendChild(marker);
}
}
-
- // Render sequences (with dynamic Y positioning to prevent overlap)
- let cumulativeY = 0;
- const sequenceGap = 10; // Gap between sequences
-
- sequences.forEach((seq, seqIndex) => {
+ let cumulativeY = 0, sequenceGap = 10;
+ state.sequences.forEach((seq, seqIndex) => {
const seqDiv = document.createElement('div');
- seqDiv.className = 'sequence';
- seqDiv.dataset.index = seqIndex;
-
- // Calculate sequence bounds based on effects (dynamic start/end)
- let seqVisualStart = seq.startTime;
- let seqVisualEnd = seq.startTime + 10; // Default 10s duration
-
+ seqDiv.className = 'sequence'; seqDiv.dataset.index = seqIndex;
+ let seqVisualStart = seq.startTime, seqVisualEnd = seq.startTime + 10;
if (seq.effects.length > 0) {
- const minEffectStart = Math.min(...seq.effects.map(e => e.startTime));
- const maxEffectEnd = Math.max(...seq.effects.map(e => e.endTime));
- seqVisualStart = seq.startTime + minEffectStart;
- seqVisualEnd = seq.startTime + maxEffectEnd;
- }
-
- const seqVisualWidth = seqVisualEnd - seqVisualStart;
-
- // Initialize collapsed state if undefined
- if (seq._collapsed === undefined) {
- seq._collapsed = false;
+ seqVisualStart = seq.startTime + Math.min(...seq.effects.map(e => e.startTime));
+ seqVisualEnd = seq.startTime + Math.max(...seq.effects.map(e => e.endTime));
}
-
- // Calculate sequence height based on number of effects (stacked vertically)
- const numEffects = seq.effects.length;
- const effectSpacing = 30;
+ if (seq._collapsed === undefined) seq._collapsed = false;
+ const numEffects = seq.effects.length, effectSpacing = 30;
const fullHeight = Math.max(70, 20 + numEffects * effectSpacing + 5);
const seqHeight = seq._collapsed ? 35 : fullHeight;
-
- seqDiv.style.left = `${seqVisualStart * pixelsPerSecond}px`;
+ seqDiv.style.left = `${seqVisualStart * state.pixelsPerSecond}px`;
seqDiv.style.top = `${cumulativeY}px`;
- seqDiv.style.width = `${seqVisualWidth * pixelsPerSecond}px`;
- seqDiv.style.height = `${seqHeight}px`;
- seqDiv.style.minHeight = `${seqHeight}px`;
- seqDiv.style.maxHeight = `${seqHeight}px`;
-
- // Store Y position for this sequence (used by effects and scroll)
- seq._yPosition = cumulativeY;
- cumulativeY += seqHeight + sequenceGap;
-
- // Create sequence header (double-click to collapse)
- const seqHeaderDiv = document.createElement('div');
- seqHeaderDiv.className = 'sequence-header';
-
- const headerName = document.createElement('span');
- headerName.className = 'sequence-header-name';
+ seqDiv.style.width = `${(seqVisualEnd - seqVisualStart) * state.pixelsPerSecond}px`;
+ seqDiv.style.height = `${seqHeight}px`; seqDiv.style.minHeight = `${seqHeight}px`; seqDiv.style.maxHeight = `${seqHeight}px`;
+ seq._yPosition = cumulativeY; cumulativeY += seqHeight + sequenceGap; totalTimelineHeight = cumulativeY;
+ const seqHeaderDiv = document.createElement('div'); seqHeaderDiv.className = 'sequence-header';
+ const headerName = document.createElement('span'); headerName.className = 'sequence-header-name';
headerName.textContent = seq.name || `Sequence ${seqIndex + 1}`;
-
seqHeaderDiv.appendChild(headerName);
-
- // Prevent drag on header
- seqHeaderDiv.addEventListener('mousedown', (e) => {
- e.stopPropagation();
- });
-
- // Double-click to toggle collapse
- seqHeaderDiv.addEventListener('dblclick', (e) => {
- e.stopPropagation();
- e.preventDefault();
- seq._collapsed = !seq._collapsed;
- renderTimeline();
- });
-
+ seqHeaderDiv.addEventListener('dblclick', e => { e.stopPropagation(); e.preventDefault(); seq._collapsed = !seq._collapsed; renderTimeline(); });
seqDiv.appendChild(seqHeaderDiv);
-
- // Create sequence name overlay (large, centered, fades on hover)
- const seqNameDiv = document.createElement('div');
- seqNameDiv.className = 'sequence-name';
- seqNameDiv.textContent = seq.name || `Sequence ${seqIndex + 1}`;
-
- seqDiv.appendChild(seqNameDiv);
-
- // Apply collapsed state
- if (seq._collapsed) {
- seqDiv.classList.add('collapsed');
- }
-
- if (selectedItem && selectedItem.type === 'sequence' && selectedItem.index === seqIndex) {
- seqDiv.classList.add('selected');
- }
-
- // Fade name on hover
- seqDiv.addEventListener('mouseenter', () => {
- seqDiv.classList.add('hovered');
- });
- seqDiv.addEventListener('mouseleave', () => {
- seqDiv.classList.remove('hovered');
- });
-
- seqDiv.addEventListener('mousedown', (e) => startDrag(e, 'sequence', seqIndex));
- seqDiv.addEventListener('click', (e) => {
- e.stopPropagation();
- selectItem('sequence', seqIndex);
- });
-
- timeline.appendChild(seqDiv);
-
- // Render effects within sequence (skip if collapsed)
+ const seqNameDiv = document.createElement('div'); seqNameDiv.className = 'sequence-name';
+ seqNameDiv.textContent = seq.name || `Sequence ${seqIndex + 1}`; seqDiv.appendChild(seqNameDiv);
+ if (seq._collapsed) seqDiv.classList.add('collapsed');
+ if (state.selectedItem && state.selectedItem.type === 'sequence' && state.selectedItem.index === seqIndex) seqDiv.classList.add('selected');
+ seqDiv.addEventListener('mouseenter', () => seqDiv.classList.add('hovered'));
+ seqDiv.addEventListener('mouseleave', () => seqDiv.classList.remove('hovered'));
+ seqDiv.addEventListener('mousedown', e => startDrag(e, 'sequence', seqIndex));
+ seqDiv.addEventListener('click', e => { e.stopPropagation(); selectItem('sequence', seqIndex); });
+ seqDiv.addEventListener('dblclick', e => { e.stopPropagation(); e.preventDefault(); seq._collapsed = !seq._collapsed; renderTimeline(); });
+ dom.timeline.appendChild(seqDiv);
if (!seq._collapsed) {
- 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) * pixelsPerSecond;
- const effectWidth = (effect.endTime - effect.startTime) * pixelsPerSecond;
-
- effectDiv.style.left = `${effectStart}px`;
- effectDiv.style.top = `${seq._yPosition + 20 + effectIndex * 30}px`;
- effectDiv.style.width = `${effectWidth}px`;
- effectDiv.style.height = '26px';
-
- // 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 = `
- <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 (selectedItem && selectedItem.type === 'effect' &&
- selectedItem.seqIndex === seqIndex && selectedItem.effectIndex === effectIndex) {
- effectDiv.classList.add('selected');
- }
-
- // Handle resizing (only for selected effects)
- 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);
+ const conflicts = detectConflicts(seq);
+ seq.effects.forEach((effect, effectIndex) => {
+ 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>`;
+ 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);
});
-
- effectDiv.addEventListener('mousedown', (e) => {
- // Only drag if not clicking on a handle
- if (!e.target.classList.contains('effect-handle')) {
- e.stopPropagation();
- startDrag(e, 'effect', seqIndex, effectIndex);
- }
- });
- effectDiv.addEventListener('click', (e) => {
- e.stopPropagation();
- selectItem('effect', seqIndex, effectIndex);
- });
-
- timeline.appendChild(effectDiv);
- });
}
});
-
+ dom.timeline.style.minHeight = `${Math.max(totalTimelineHeight, dom.timelineContent.offsetHeight)}px`;
+ if (dom.playbackIndicator) dom.playbackIndicator.style.height = `${Math.max(totalTimelineHeight, dom.timelineContent.offsetHeight)}px`;
updateStats();
}
- // Drag handling
+ // Drag
function startDrag(e, type, seqIndex, effectIndex = null) {
- e.preventDefault();
- isDragging = true;
-
- // Calculate offset from timeline origin (not from element edge)
- // CRITICAL: Use currentTarget (element with listener) not target (what was clicked)
- const timelineRect = timeline.getBoundingClientRect();
+ state.isDragging = true;
+ state.dragMoved = false;
+ const containerRect = dom.timelineContent.getBoundingClientRect();
const currentLeft = parseFloat(e.currentTarget.style.left) || 0;
- dragOffset.x = e.clientX - timelineRect.left - currentLeft;
- dragOffset.y = e.clientY - e.currentTarget.getBoundingClientRect().top;
-
- selectedItem = { type, index: seqIndex, seqIndex, effectIndex };
- renderTimeline();
- updateProperties();
-
- document.addEventListener('mousemove', onDrag);
- document.addEventListener('mouseup', stopDrag);
+ 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);
}
function onDrag(e) {
- if (!isDragging || !selectedItem) return;
-
- const timelineRect = timeline.getBoundingClientRect();
- const newX = e.clientX - timelineRect.left - dragOffset.x;
- let newTime = Math.max(0, newX / pixelsPerSecond);
-
- // Snap to beat when enabled
- if (showBeats) {
- newTime = Math.round(newTime);
- }
-
- if (selectedItem.type === 'sequence') {
- sequences[selectedItem.index].startTime = Math.round(newTime * 100) / 100;
- } else if (selectedItem.type === 'effect') {
- // Effects have times relative to their parent sequence
- const seq = sequences[selectedItem.seqIndex];
- const effect = seq.effects[selectedItem.effectIndex];
- const duration = effect.endTime - effect.startTime;
-
- // Convert absolute timeline position to relative time within sequence
- const relativeTime = newTime - seq.startTime;
- effect.startTime = Math.round(relativeTime * 100) / 100;
- effect.endTime = effect.startTime + duration;
+ if (!state.isDragging || !state.selectedItem) return;
+ state.dragMoved = true;
+ 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') {
+ const seq = state.sequences[state.selectedItem.seqIndex], effect = seq.effects[state.selectedItem.effectIndex];
+ const duration = effect.endTime - effect.startTime, relativeTime = newTime - seq.startTime;
+ effect.startTime = relativeTime; effect.endTime = effect.startTime + duration;
}
-
- renderTimeline();
- updateProperties();
+ renderTimeline(); updateProperties();
}
function stopDrag() {
- isDragging = false;
- document.removeEventListener('mousemove', onDrag);
- document.removeEventListener('mouseup', stopDrag);
+ state.isDragging = false;
+ document.removeEventListener('mousemove', onDrag); document.removeEventListener('mouseup', stopDrag);
+ if (state.dragMoved) {
+ renderTimeline(); updateProperties();
+ }
}
- // Handle dragging (for resizing effects)
function startHandleDrag(e, type, seqIndex, effectIndex) {
- e.preventDefault();
- isDraggingHandle = true;
- handleType = type;
- selectedItem = { type: 'effect', seqIndex, effectIndex, index: seqIndex };
- renderTimeline();
- updateProperties();
-
- document.addEventListener('mousemove', onHandleDrag);
- document.addEventListener('mouseup', stopHandleDrag);
+ 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 (!isDraggingHandle || !selectedItem) return;
-
- const timelineRect = timeline.getBoundingClientRect();
- const newX = e.clientX - timelineRect.left;
- let newTime = Math.max(0, newX / pixelsPerSecond);
-
- // Snap to beat when enabled
- if (showBeats) {
- newTime = Math.round(newTime);
- }
-
- const seq = sequences[selectedItem.seqIndex];
- const effect = seq.effects[selectedItem.effectIndex];
-
- // Convert to relative time
+ if (!state.isDraggingHandle || !state.selectedItem) return;
+ 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;
-
- if (handleType === 'left') {
- // Adjust start time, keep end time fixed
- // Allow negative times (effect can extend before sequence start)
- const newStartTime = Math.round(relativeTime * 100) / 100;
- effect.startTime = Math.min(newStartTime, effect.endTime - 0.1);
- } else if (handleType === 'right') {
- // Adjust end time, keep start time fixed
- effect.endTime = Math.max(effect.startTime + 0.1, Math.round(relativeTime * 100) / 100);
- }
-
- renderTimeline();
- updateProperties();
+ if (state.handleType === 'left') effect.startTime = Math.min(relativeTime, effect.endTime - 0.1);
+ else if (state.handleType === 'right') effect.endTime = Math.max(effect.startTime + 0.1, relativeTime);
+ renderTimeline(); updateProperties();
}
function stopHandleDrag() {
- isDraggingHandle = false;
- handleType = null;
- document.removeEventListener('mousemove', onHandleDrag);
- document.removeEventListener('mouseup', stopHandleDrag);
+ state.isDraggingHandle = false; state.handleType = null;
+ document.removeEventListener('mousemove', onHandleDrag); document.removeEventListener('mouseup', stopHandleDrag);
+ renderTimeline(); updateProperties();
}
- // Selection
function selectItem(type, seqIndex, effectIndex = null) {
- selectedItem = { type, index: seqIndex, seqIndex, effectIndex };
- renderTimeline();
- updateProperties();
- deleteBtn.disabled = false;
+ state.selectedItem = { type, index: seqIndex, seqIndex, effectIndex };
+ renderTimeline(); updateProperties();
+ dom.deleteBtn.disabled = false;
+ dom.addEffectBtn.disabled = type !== 'sequence';
}
- // Properties panel
+ // Properties
function updateProperties() {
- if (!selectedItem) {
- propertiesPanel.style.display = 'none';
- return;
- }
-
- propertiesPanel.style.display = 'block';
-
- if (selectedItem.type === 'sequence') {
- const seq = sequences[selectedItem.index];
- propertiesContent.innerHTML = `
- <div class="property-group">
- <label>Name</label>
- <input type="text" id="propName" value="${seq.name || ''}" placeholder="Sequence name" oninput="autoApplyProperties()">
- </div>
- <div class="property-group">
- <label>Start Time (seconds)</label>
- <input type="number" id="propStartTime" value="${seq.startTime}" step="0.1" min="0" oninput="autoApplyProperties()">
- </div>
+ if (!state.selectedItem) { dom.propertiesPanel.style.display = 'none'; return; }
+ dom.propertiesPanel.style.display = 'block';
+ 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" 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>
`;
- } else if (selectedItem.type === 'effect') {
- const effect = sequences[selectedItem.seqIndex].effects[selectedItem.effectIndex];
- const effects = sequences[selectedItem.seqIndex].effects;
- const canMoveUp = selectedItem.effectIndex < effects.length - 1;
- const canMoveDown = selectedItem.effectIndex > 0;
+ 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;
+ const canMoveUp = state.selectedItem.effectIndex < effects.length - 1, canMoveDown = state.selectedItem.effectIndex > 0;
const samePriority = effect.priorityModifier === '=';
-
- propertiesContent.innerHTML = `
- <div class="property-group">
- <label>Effect Class</label>
- <input type="text" id="propClassName" value="${effect.className}" oninput="autoApplyProperties()">
- </div>
- <div class="property-group">
- <label>Start Time (relative to sequence)</label>
- <input type="number" id="propStartTime" value="${effect.startTime}" step="0.1" oninput="autoApplyProperties()">
- </div>
- <div class="property-group">
- <label>End Time (relative to sequence)</label>
- <input type="number" id="propEndTime" value="${effect.endTime}" step="0.1" oninput="autoApplyProperties()">
- </div>
- <div class="property-group">
- <label>Constructor Arguments</label>
- <input type="text" id="propArgs" value="${effect.args || ''}" oninput="autoApplyProperties()">
- </div>
- <div class="property-group">
- <label>Stack Position (determines priority)</label>
+ dom.propertiesContent.innerHTML = `
+ <div class="property-group"><label>Effect Class</label><input type="text" id="propClassName" value="${effect.className}"></div>
+ <div class="property-group"><label>Start Time (relative to sequence)</label><input type="number" id="propStartTime" value="${effect.startTime}" step="0.1"></div>
+ <div class="property-group"><label>End Time (relative to sequence)</label><input type="number" id="propEndTime" value="${effect.endTime}" step="0.1"></div>
+ <div class="property-group"><label>Constructor Arguments</label><input type="text" id="propArgs" value="${effect.args || ''}"></div>
+ <div class="property-group"><label>Stack Position (determines priority)</label>
<div style="display: flex; gap: 5px; margin-bottom: 10px;">
- <button onclick="moveEffectUp()" ${!canMoveUp ? 'disabled' : ''} style="flex: 1;">↑ Up</button>
- <button onclick="moveEffectDown()" ${!canMoveDown ? 'disabled' : ''} style="flex: 1;">↓ Down</button>
+ <button id="moveUpBtn" ${!canMoveUp ? 'disabled' : ''} style="flex: 1;">↑ Up</button>
+ <button id="moveDownBtn" ${!canMoveDown ? 'disabled' : ''} style="flex: 1;">↓ Down</button>
</div>
- <button onclick="toggleSamePriority()" style="width: 100%;">
- ${samePriority ? '✓ Same as Above (=)' : 'Increment (+)'}
- </button>
+ <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);
+ document.getElementById('propEndTime').addEventListener('input', applyProperties);
+ document.getElementById('propArgs').addEventListener('input', applyProperties);
+ 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());
}
}
- // Auto-apply properties on input change (no Apply button needed)
- function autoApplyProperties() {
- if (!selectedItem) return;
-
- if (selectedItem.type === 'sequence') {
- const seq = sequences[selectedItem.index];
+ function applyProperties() {
+ if (!state.selectedItem) return;
+ if (state.selectedItem.type === 'sequence') {
+ const seq = state.sequences[state.selectedItem.index];
seq.name = document.getElementById('propName').value;
seq.startTime = parseFloat(document.getElementById('propStartTime').value);
- } else if (selectedItem.type === 'effect') {
- const effect = sequences[selectedItem.seqIndex].effects[selectedItem.effectIndex];
+ } else if (state.selectedItem.type === 'effect') {
+ const effect = state.sequences[state.selectedItem.seqIndex].effects[state.selectedItem.effectIndex];
effect.className = document.getElementById('propClassName').value;
effect.startTime = parseFloat(document.getElementById('propStartTime').value);
effect.endTime = parseFloat(document.getElementById('propEndTime').value);
effect.args = document.getElementById('propArgs').value;
}
-
- // Re-render timeline (recalculates sequence bounds)
renderTimeline();
}
- // Move effect up in stack (higher priority)
function moveEffectUp() {
- if (!selectedItem || selectedItem.type !== 'effect') return;
-
- const effects = sequences[selectedItem.seqIndex].effects;
- const index = selectedItem.effectIndex;
-
+ if (!state.selectedItem || state.selectedItem.type !== 'effect') return;
+ const effects = state.sequences[state.selectedItem.seqIndex].effects, index = state.selectedItem.effectIndex;
if (index < effects.length - 1) {
- // Swap with effect above
[effects[index], effects[index + 1]] = [effects[index + 1], effects[index]];
- selectedItem.effectIndex = index + 1;
- renderTimeline();
- updateProperties();
+ state.selectedItem.effectIndex = index + 1; renderTimeline(); updateProperties();
}
}
- // Move effect down in stack (lower priority)
function moveEffectDown() {
- if (!selectedItem || selectedItem.type !== 'effect') return;
-
- const effects = sequences[selectedItem.seqIndex].effects;
- const index = selectedItem.effectIndex;
-
+ if (!state.selectedItem || state.selectedItem.type !== 'effect') return;
+ const effects = state.sequences[state.selectedItem.seqIndex].effects, index = state.selectedItem.effectIndex;
if (index > 0) {
- // Swap with effect below
[effects[index], effects[index - 1]] = [effects[index - 1], effects[index]];
- selectedItem.effectIndex = index - 1;
- renderTimeline();
- updateProperties();
+ state.selectedItem.effectIndex = index - 1; renderTimeline(); updateProperties();
}
}
- // Toggle same priority as previous effect (= modifier)
function toggleSamePriority() {
- if (!selectedItem || selectedItem.type !== 'effect') return;
-
- const effect = sequences[selectedItem.seqIndex].effects[selectedItem.effectIndex];
+ if (!state.selectedItem || state.selectedItem.type !== 'effect') return;
+ const effect = state.sequences[state.selectedItem.seqIndex].effects[state.selectedItem.effectIndex];
effect.priorityModifier = effect.priorityModifier === '=' ? '+' : '=';
updateProperties();
}
- // File operations
- fileInput.addEventListener('change', (e) => {
+ // Utilities
+ function showMessage(text, type) {
+ if (type === 'error') console.error(text);
+ dom.messageArea.innerHTML = `<div class="${type}">${text}</div>`;
+ setTimeout(() => dom.messageArea.innerHTML = '', 3000);
+ }
+
+ function updateStats() {
+ const effectCount = state.sequences.reduce((sum, seq) => sum + seq.effects.length, 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`;
+ }
+
+ async function loadFromURLParams() {
+ const params = new URLSearchParams(window.location.search);
+ const seqURL = params.get('seq'), 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(), parsed = parseSeqFile(content);
+ state.sequences = parsed.sequences; state.bpm = parsed.bpm;
+ dom.currentBPM.textContent = state.bpm; dom.bpmSlider.value = state.bpm;
+ state.currentFile = seqURL.split('/').pop();
+ renderTimeline(); dom.saveBtn.disabled = false; dom.addSequenceBtn.disabled = false; dom.reorderBtn.disabled = false;
+ showMessage(`Loaded ${state.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(), file = new File([blob], wavURL.split('/').pop(), { type: 'audio/wav' });
+ await loadAudioFile(file);
+ } catch (err) { showMessage(`Error loading audio file: ${err.message}`, 'error'); }
+ }
+ }
+
+ // Event handlers
+ dom.fileInput.addEventListener('change', e => {
const file = e.target.files[0];
if (!file) return;
-
- currentFile = file.name;
+ state.currentFile = file.name;
const reader = new FileReader();
-
- reader.onload = (e) => {
+ reader.onload = e => {
try {
const parsed = parseSeqFile(e.target.result);
- sequences = parsed.sequences;
- bpm = parsed.bpm;
- document.getElementById('currentBPM').textContent = bpm;
- document.getElementById('bpmSlider').value = bpm;
- renderTimeline();
- saveBtn.disabled = false;
- addSequenceBtn.disabled = false;
- reorderBtn.disabled = false;
- showMessage(`Loaded ${currentFile} - ${sequences.length} sequences`, 'success');
- } catch (err) {
- showMessage(`Error parsing file: ${err.message}`, 'error');
- }
+ state.sequences = parsed.sequences; state.bpm = parsed.bpm;
+ dom.currentBPM.textContent = state.bpm; dom.bpmSlider.value = state.bpm;
+ renderTimeline(); dom.saveBtn.disabled = false; dom.addSequenceBtn.disabled = false; dom.reorderBtn.disabled = false;
+ showMessage(`Loaded ${state.currentFile} - ${state.sequences.length} sequences`, 'success');
+ } catch (err) { showMessage(`Error parsing file: ${err.message}`, 'error'); }
};
-
reader.readAsText(file);
});
- saveBtn.addEventListener('click', () => {
- const content = serializeSeqFile(sequences);
- const blob = new Blob([content], { type: 'text/plain' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = currentFile || 'timeline.seq';
- a.click();
- URL.revokeObjectURL(url);
+ dom.saveBtn.addEventListener('click', () => {
+ const content = serializeSeqFile(state.sequences), blob = new Blob([content], { type: 'text/plain' });
+ const url = URL.createObjectURL(blob), a = document.createElement('a');
+ a.href = url; a.download = state.currentFile || 'timeline.seq'; a.click(); URL.revokeObjectURL(url);
showMessage('File saved', 'success');
});
- audioInput.addEventListener('change', (e) => {
- const file = e.target.files[0];
- if (!file) return;
- loadAudioFile(file);
+ dom.audioInput.addEventListener('change', e => { const file = e.target.files[0]; if (file) loadAudioFile(file); });
+ dom.clearAudioBtn.addEventListener('click', () => { clearAudio(); dom.audioInput.value = ''; });
+ dom.playPauseBtn.addEventListener('click', async () => {
+ if (state.isPlaying) stopPlayback();
+ else { if (state.playbackOffset >= state.audioDuration) state.playbackOffset = 0; await startPlayback(); }
});
- clearAudioBtn.addEventListener('click', () => {
- clearAudio();
- audioInput.value = ''; // Reset file input
+ dom.waveformContainer.addEventListener('click', async e => {
+ if (!state.audioBuffer) return;
+ const rect = dom.waveformContainer.getBoundingClientRect();
+ 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 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();
});
- playPauseBtn.addEventListener('click', () => {
- if (isPlaying) {
- stopPlayback();
- } else {
- // Reset to beginning if at end
- if (playbackOffset >= audioDuration) {
- playbackOffset = 0;
- }
- startPlayback();
- }
+ dom.addSequenceBtn.addEventListener('click', () => {
+ state.sequences.push({ type: 'sequence', startTime: 0, priority: 0, effects: [], _collapsed: true });
+ renderTimeline(); showMessage('New sequence added', 'success');
});
- // 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',
- startTime: 0,
- priority: 0,
- effects: [],
- _collapsed: true
- });
- 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');
});
- deleteBtn.addEventListener('click', () => {
- if (!selectedItem) return;
-
- if (selectedItem.type === 'sequence') {
- sequences.splice(selectedItem.index, 1);
- } else if (selectedItem.type === 'effect') {
- sequences[selectedItem.seqIndex].effects.splice(selectedItem.effectIndex, 1);
- }
-
- selectedItem = null;
- deleteBtn.disabled = true;
- renderTimeline();
- updateProperties();
+ 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; dom.addEffectBtn.disabled = true; renderTimeline(); updateProperties();
showMessage('Item deleted', 'success');
});
- // Re-order sequences by time
- reorderBtn.addEventListener('click', () => {
- // Store current active sequence (if any)
- const currentActiveSeq = lastActiveSeqIndex >= 0 ? sequences[lastActiveSeqIndex] : null;
-
- // Sort sequences by start time (ascending)
- sequences.sort((a, b) => a.startTime - b.startTime);
-
- // Re-render timeline
- renderTimeline();
-
- // Restore focus on previously active sequence
+ dom.reorderBtn.addEventListener('click', () => {
+ const currentActiveSeq = state.lastActiveSeqIndex >= 0 ? state.sequences[state.lastActiveSeqIndex] : null;
+ state.sequences.sort((a, b) => a.startTime - b.startTime); renderTimeline();
if (currentActiveSeq) {
- const newIndex = sequences.indexOf(currentActiveSeq);
- if (newIndex >= 0 && sequences[newIndex]._yPosition !== undefined) {
- // Scroll to keep it in view
- timelineContent.scrollTop = sequences[newIndex]._yPosition;
- lastActiveSeqIndex = newIndex;
+ const newIndex = state.sequences.indexOf(currentActiveSeq);
+ if (newIndex >= 0 && state.sequences[newIndex]._yPosition !== undefined) {
+ dom.timelineContent.scrollTop = state.sequences[newIndex]._yPosition; state.lastActiveSeqIndex = newIndex;
}
}
-
showMessage('Sequences re-ordered by start time', 'success');
});
- // Zoom
- zoomSlider.addEventListener('input', (e) => {
- const zoom = parseInt(e.target.value);
- pixelsPerSecond = zoom;
- zoomLevel.textContent = `${zoom}%`;
- if (audioBuffer) {
- renderWaveform(); // Re-render waveform at new zoom
- }
- 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) => {
- showBeats = e.target.checked;
- renderTimeline();
+ dom.zoomSlider.addEventListener('input', e => {
+ state.pixelsPerSecond = parseInt(e.target.value); dom.zoomLevel.textContent = `${state.pixelsPerSecond}%`;
+ if (state.audioBuffer) renderWaveform(); renderTimeline();
});
- // Properties panel collapse/expand
- const panelToggle = document.getElementById('panelToggle');
- const panelCollapseBtn = document.getElementById('panelCollapseBtn');
-
- panelToggle.addEventListener('click', () => {
- propertiesPanel.classList.add('collapsed');
- panelCollapseBtn.classList.add('visible');
- panelToggle.textContent = '▲ Expand';
+ dom.bpmSlider.addEventListener('input', e => {
+ state.bpm = parseInt(e.target.value); dom.currentBPM.textContent = state.bpm;
+ if (state.audioBuffer) renderWaveform(); renderTimeline();
});
- panelCollapseBtn.addEventListener('click', () => {
- propertiesPanel.classList.remove('collapsed');
- panelCollapseBtn.classList.remove('visible');
- panelToggle.textContent = '▼ Collapse';
- });
+ dom.showBeatsCheckbox.addEventListener('change', e => { state.showBeats = e.target.checked; renderTimeline(); });
+ 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; dom.addEffectBtn.disabled = true; renderTimeline(); updateProperties(); });
- // Click outside to deselect
- timeline.addEventListener('click', () => {
- selectedItem = null;
- deleteBtn.disabled = true;
- renderTimeline();
- updateProperties();
+ dom.timeline.addEventListener('dblclick', async e => {
+ if (e.target !== dom.timeline) return;
+ 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 = 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();
+ showMessage(`Seek to ${clickTime.toFixed(2)}s (${clickBeats.toFixed(2)}b)`, 'success');
+ }
});
- // Keyboard shortcuts
- document.addEventListener('keydown', (e) => {
- // Spacebar: play/pause (if audio loaded)
- if (e.code === 'Space' && audioBuffer) {
+ document.addEventListener('keydown', e => {
+ if (e.code === 'Space' && state.audioBuffer) { e.preventDefault(); dom.playPauseBtn.click(); }
+ // Quantize hotkeys: 0=Off, 1=1beat, 2=1/2, 3=1/4, 4=1/8, 5=1/16, 6=1/32
+ const quantizeMap = { '0': '0', '1': '1', '2': '2', '3': '4', '4': '8', '5': '16', '6': '32' };
+ if (quantizeMap[e.key]) {
+ state.quantizeUnit = parseFloat(quantizeMap[e.key]);
+ dom.quantizeSelect.value = quantizeMap[e.key];
e.preventDefault();
- playPauseBtn.click();
}
});
- // Mouse wheel: zoom (with Ctrl/Cmd) or diagonal scroll
- timelineContent.addEventListener('wheel', (e) => {
- e.preventDefault();
+ dom.timelineContent.addEventListener('scroll', () => {
+ const scrollLeft = dom.timelineContent.scrollLeft;
+ dom.cpuLoadCanvas.style.left = `-${scrollLeft}px`;
+ dom.waveformCanvas.style.left = `-${scrollLeft}px`;
+ dom.waveformPlaybackIndicator.style.transform = `translateX(-${scrollLeft}px)`;
+ });
- // Zoom mode: Ctrl/Cmd + wheel
+ dom.timelineContent.addEventListener('wheel', e => {
+ e.preventDefault();
if (e.ctrlKey || e.metaKey) {
- // 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 = timelineContent.scrollLeft;
- const timeUnderCursor = (scrollLeft + mouseX) / pixelsPerSecond;
-
- // Calculate new zoom level
- const zoomDelta = e.deltaY > 0 ? -10 : 10; // Wheel down = zoom out, wheel up = zoom in
- const oldPixelsPerSecond = pixelsPerSecond;
- const newPixelsPerSecond = Math.max(10, Math.min(500, pixelsPerSecond + zoomDelta));
-
+ const rect = dom.timelineContent.getBoundingClientRect(), mouseX = e.clientX - rect.left;
+ const scrollLeft = dom.timelineContent.scrollLeft, timeUnderCursor = (scrollLeft + mouseX) / state.pixelsPerSecond;
+ const zoomDelta = e.deltaY > 0 ? -10 : 10, oldPixelsPerSecond = state.pixelsPerSecond;
+ const newPixelsPerSecond = Math.max(10, Math.min(500, state.pixelsPerSecond + zoomDelta));
if (newPixelsPerSecond !== oldPixelsPerSecond) {
- pixelsPerSecond = newPixelsPerSecond;
-
- // Update zoom slider and labels
- zoomSlider.value = pixelsPerSecond;
- zoomLevel.textContent = `${pixelsPerSecond}%`;
-
- // Re-render waveform and timeline at new zoom
- if (audioBuffer) {
- renderWaveform();
- }
- renderTimeline();
-
- // 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;
- timelineContent.scrollLeft = newScrollLeft;
+ state.pixelsPerSecond = newPixelsPerSecond; dom.zoomSlider.value = state.pixelsPerSecond; dom.zoomLevel.textContent = `${state.pixelsPerSecond}%`;
+ if (state.audioBuffer) renderWaveform(); renderTimeline();
+ dom.timelineContent.scrollLeft = timeUnderCursor * newPixelsPerSecond - mouseX;
}
return;
}
-
- // Normal mode: diagonal scroll
- timelineContent.scrollLeft += e.deltaY;
-
- // Calculate current time position with 10% headroom for visual comfort
- 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;
-
- // Find the closest sequence that should be visible at current time
- // (the last sequence that starts before or at current time + slack)
+ dom.timelineContent.scrollLeft += e.deltaY;
+ const currentScrollLeft = dom.timelineContent.scrollLeft, viewportWidth = dom.timelineContent.clientWidth;
+ const slack = (viewportWidth / state.pixelsPerSecond) * 0.1, currentTime = (currentScrollLeft / state.pixelsPerSecond) + slack;
let targetSeqIndex = 0;
- for (let i = 0; i < sequences.length; i++) {
- if (sequences[i].startTime <= currentTime) {
- targetSeqIndex = i;
- } else {
- break;
- }
+ for (let i = 0; i < state.sequences.length; i++) {
+ if (state.sequences[i].startTime <= currentTime) targetSeqIndex = i; else break;
}
-
- // Flash effect when active sequence changes
- if (targetSeqIndex !== lastActiveSeqIndex && sequences.length > 0) {
- lastActiveSeqIndex = targetSeqIndex;
-
- // Add flash class to target sequence
- const seqDivs = timeline.querySelectorAll('.sequence');
+ if (targetSeqIndex !== state.lastActiveSeqIndex && state.sequences.length > 0) {
+ state.lastActiveSeqIndex = targetSeqIndex;
+ const seqDivs = dom.timeline.querySelectorAll('.sequence');
if (seqDivs[targetSeqIndex]) {
seqDivs[targetSeqIndex].classList.add('active-flash');
- // Remove class after animation completes
- setTimeout(() => {
- seqDivs[targetSeqIndex]?.classList.remove('active-flash');
- }, 600);
+ setTimeout(() => seqDivs[targetSeqIndex]?.classList.remove('active-flash'), 600);
}
}
-
- // Smooth vertical scroll to bring target sequence to top of viewport
- const targetScrollTop = sequences[targetSeqIndex]?._yPosition || 0;
- const currentScrollTop = timelineContent.scrollTop;
- const scrollDiff = targetScrollTop - currentScrollTop;
-
- // Smooth transition (don't jump instantly)
- if (Math.abs(scrollDiff) > 5) {
- timelineContent.scrollTop += scrollDiff * 0.3;
- }
+ const targetScrollTop = state.sequences[targetSeqIndex]?._yPosition || 0;
+ const currentScrollTop = dom.timelineContent.scrollTop, scrollDiff = targetScrollTop - currentScrollTop;
+ if (Math.abs(scrollDiff) > 5) dom.timelineContent.scrollTop += scrollDiff * 0.3;
}, { passive: false });
- // Window resize handler
- window.addEventListener('resize', () => {
- renderTimeline();
- });
-
- // Utilities
- function showMessage(text, type) {
- messageArea.innerHTML = `<div class="${type}">${text}</div>`;
- setTimeout(() => messageArea.innerHTML = '', 3000);
- }
-
- function updateStats() {
- const effectCount = sequences.reduce((sum, seq) => sum + seq.effects.length, 0);
- const maxTime = 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);
-
- stats.innerHTML = `
- 📊 Sequences: ${sequences.length} |
- 🎬 Effects: ${effectCount} |
- ⏱️ Duration: ${maxTime.toFixed(2)}s
- `;
- }
-
- // Initial render
- renderTimeline();
+ window.addEventListener('resize', renderTimeline);
+ renderTimeline(); loadFromURLParams();
</script>
</body>
</html>