diff options
Diffstat (limited to 'tools/timeline_editor')
| -rw-r--r-- | tools/timeline_editor/README.md | 63 | ||||
| -rw-r--r-- | tools/timeline_editor/ROADMAP.md | 32 | ||||
| -rw-r--r-- | tools/timeline_editor/index.html | 2127 |
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> |
