diff options
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/cnn_test.cc | 53 | ||||
| -rw-r--r-- | tools/seq_compiler.cc | 28 | ||||
| -rw-r--r-- | tools/timeline_editor/README.md | 180 | ||||
| -rw-r--r-- | tools/timeline_editor/ROADMAP.md | 31 | ||||
| -rw-r--r-- | tools/timeline_editor/index.html | 543 |
5 files changed, 595 insertions, 240 deletions
diff --git a/tools/cnn_test.cc b/tools/cnn_test.cc index 39ed436..c2983a9 100644 --- a/tools/cnn_test.cc +++ b/tools/cnn_test.cc @@ -42,6 +42,8 @@ struct Args { float blend = 1.0f; bool output_png = true; // Default to PNG const char* save_intermediates = nullptr; + int num_layers = 3; // Default to 3 layers + bool debug_hex = false; // Print first 8 pixels as hex }; // Parse command-line arguments @@ -73,6 +75,14 @@ static bool parse_args(int argc, char** argv, Args* args) { } } else if (strcmp(argv[i], "--save-intermediates") == 0 && i + 1 < argc) { args->save_intermediates = argv[++i]; + } else if (strcmp(argv[i], "--layers") == 0 && i + 1 < argc) { + args->num_layers = atoi(argv[++i]); + if (args->num_layers < 1 || args->num_layers > 10) { + fprintf(stderr, "Error: layers must be in range [1, 10]\n"); + return false; + } + } else if (strcmp(argv[i], "--debug-hex") == 0) { + args->debug_hex = true; } else if (strcmp(argv[i], "--help") == 0) { return false; } else { @@ -90,7 +100,9 @@ static void print_usage(const char* prog) { fprintf(stderr, "\nOPTIONS:\n"); fprintf(stderr, " --blend F Final blend amount (0.0-1.0, default: 1.0)\n"); fprintf(stderr, " --format ppm|png Output format (default: png)\n"); + fprintf(stderr, " --layers N Number of CNN layers (1-10, default: 3)\n"); fprintf(stderr, " --save-intermediates DIR Save intermediate layers to directory\n"); + fprintf(stderr, " --debug-hex Print first 8 pixels as hex (debug)\n"); fprintf(stderr, " --help Show this help\n"); } @@ -273,6 +285,7 @@ int main(int argc, char** argv) { WGPUTexture input_texture = load_texture(device, queue, args.input_path, &width, &height); if (!input_texture) { + SamplerCache::Get().clear(); fixture.shutdown(); return 1; } @@ -303,6 +316,7 @@ int main(int argc, char** argv) { if (pipeline_final) wgpuRenderPipelineRelease(pipeline_final); wgpuTextureViewRelease(input_view); wgpuTextureRelease(input_texture); + SamplerCache::Get().clear(); fixture.shutdown(); return 1; } @@ -360,8 +374,8 @@ int main(int argc, char** argv) { WGPUSampler sampler = SamplerCache::Get().get_or_create(device, SamplerCache::clamp()); - // Multi-layer processing (fixed 3 layers) - const int NUM_LAYERS = 3; + // Multi-layer processing + const int NUM_LAYERS = args.num_layers; int dst_idx = 0; // Index of texture to render to // First layer reads from input, subsequent layers read from previous output @@ -373,11 +387,12 @@ int main(int argc, char** argv) { // Update uniforms CommonPostProcessUniforms common_u = { .resolution = {static_cast<float>(width), static_cast<float>(height)}, - ._pad = {0.0f, 0.0f}, .aspect_ratio = static_cast<float>(width) / static_cast<float>(height), .time = 0.0f, - .beat = 0.0f, + .beat_time = 0.0f, + .beat_phase = 0.0f, .audio_intensity = 0.0f, + ._pad = 0.0f, }; wgpuQueueWriteBuffer(queue, common_uniform_buffer, 0, &common_u, sizeof(common_u)); @@ -427,6 +442,18 @@ int main(int argc, char** argv) { printf("Reading pixels from GPU...\n"); std::vector<uint8_t> pixels = rt.read_pixels(); + // Debug: print first 8 pixels as hex + if (args.debug_hex && !pixels.empty()) { + printf("First 8 pixels (BGRA hex):\n"); + for (int i = 0; i < 8 && i < width * height; ++i) { + const uint8_t b = pixels[i * 4 + 0]; + const uint8_t g = pixels[i * 4 + 1]; + const uint8_t r = pixels[i * 4 + 2]; + const uint8_t a = pixels[i * 4 + 3]; + printf(" [%d] 0x%02X%02X%02X%02X (RGBA)\n", i, r, g, b, a); + } + } + if (pixels.empty()) { fprintf(stderr, "Error: GPU readback failed\n"); wgpuTextureViewRelease(intermediate_views[0]); @@ -440,6 +467,7 @@ int main(int argc, char** argv) { wgpuBindGroupLayoutRelease(bgl); wgpuRenderPipelineRelease(pipeline_final); wgpuRenderPipelineRelease(pipeline_intermediate); + SamplerCache::Get().clear(); fixture.shutdown(); return 1; } @@ -466,6 +494,7 @@ int main(int argc, char** argv) { wgpuBindGroupLayoutRelease(bgl); wgpuRenderPipelineRelease(pipeline_final); wgpuRenderPipelineRelease(pipeline_intermediate); + SamplerCache::Get().clear(); fixture.shutdown(); return 1; } @@ -501,6 +530,18 @@ int main(int argc, char** argv) { std::vector<uint8_t> pixels = texture_readback_fp16_to_u8( device, queue, intermediate_textures[dst_idx], width, height); + // Debug: print first 8 pixels as hex + if (args.debug_hex && !pixels.empty()) { + printf("Layer %d first 8 pixels (BGRA hex):\n", layer); + for (int i = 0; i < 8 && i < width * height; ++i) { + const uint8_t b = pixels[i * 4 + 0]; + const uint8_t g = pixels[i * 4 + 1]; + const uint8_t r = pixels[i * 4 + 2]; + const uint8_t a = pixels[i * 4 + 3]; + printf(" [%d] 0x%02X%02X%02X%02X (RGBA)\n", i, r, g, b, a); + } + } + if (!pixels.empty()) { save_png(layer_path, pixels, width, height); } else { @@ -517,6 +558,9 @@ int main(int argc, char** argv) { } } + // Wait for all GPU work to complete before cleanup + wgpuDevicePoll(device, true, nullptr); + // Cleanup wgpuTextureViewRelease(intermediate_views[0]); wgpuTextureViewRelease(intermediate_views[1]); @@ -529,6 +573,7 @@ int main(int argc, char** argv) { wgpuRenderPipelineRelease(pipeline_final); wgpuTextureViewRelease(input_view); wgpuTextureRelease(input_texture); + SamplerCache::Get().clear(); fixture.shutdown(); return 0; diff --git a/tools/seq_compiler.cc b/tools/seq_compiler.cc index ecb9908..069122a 100644 --- a/tools/seq_compiler.cc +++ b/tools/seq_compiler.cc @@ -633,30 +633,22 @@ void generate_gantt_html(const std::string& output_file, // (seconds) std::string convert_to_time(const std::string& value, float bpm) { std::string val = value; - bool is_beat = false; - // Check for explicit 'b' suffix (beat) - if (!val.empty() && val.back() == 'b') { - is_beat = true; - val.pop_back(); - } - // Check for explicit 's' suffix (seconds) - else if (!val.empty() && val.back() == 's') { + // Check for explicit 's' suffix (seconds) - return as-is + if (!val.empty() && val.back() == 's') { val.pop_back(); - return val; // Already in seconds - } - // If no suffix and no decimal point, assume beats - else if (val.find('.') == std::string::npos) { - is_beat = true; + return val; } - if (is_beat) { - float beat = std::stof(val); - float time = beat * 60.0f / bpm; - return std::to_string(time); + // Check for explicit 'b' suffix (beats) - strip and convert + if (!val.empty() && val.back() == 'b') { + val.pop_back(); } - return val; // Return as-is (seconds) + // DEFAULT: All numbers (with or without 'b' suffix) are beats + float beat = std::stof(val); + float time = beat * 60.0f / bpm; + return std::to_string(time); } int main(int argc, char* argv[]) { diff --git a/tools/timeline_editor/README.md b/tools/timeline_editor/README.md index adf9d4e..6e368cf 100644 --- a/tools/timeline_editor/README.md +++ b/tools/timeline_editor/README.md @@ -1,73 +1,43 @@ # Timeline Editor -Interactive web-based editor for `demo.seq` timeline files. +Interactive web-based editor for `timeline.seq` files. ## Features -✅ **Implemented:** -- 📂 Load/save `demo.seq` files -- 📊 Visual Gantt-style timeline -- 🎯 Drag & drop sequences along timeline -- 🎯 Drag & drop effects along timeline -- 🎯 Resize effects with left/right handles -- ⏱️ Edit timing (start/end times) +- 📂 Load/save `timeline.seq` files +- 📊 Visual Gantt-style timeline with sticky time markers (beat-based) +- 🎯 Drag & drop sequences and effects +- 🎯 Resize effects with handles +- 📦 Collapsible sequences (double-click to collapse) +- 📏 Vertical grid lines synchronized with time ticks +- ⏱️ Edit timing and properties (in beats) - ⚙️ Stack-order based priority system -- ⚙️ Edit effect class names and constructor arguments -- 🔍 Zoom in/out (10% - 200%) -- 🎵 Audio waveform visualization (WAV files) -- 📋 Real-time statistics +- 🔍 Zoom (10%-200%) with mouse wheel + Ctrl/Cmd +- 🎵 Audio waveform visualization (aligned to beats) +- 🎼 Snap-to-beat mode (enabled by default) +- 🎛️ BPM slider (60-200 BPM) +- 🔄 Re-order sequences by time - 🗑️ Delete sequences/effects -- ➕ Add new sequences -- 🎼 Snap-to-beat mode with beat markers +- ▶️ **Audio playback with auto-expand/collapse** (NEW) +- 🎚️ **Sticky audio track and timeline ticks** (NEW) ## Usage -1. **Open the editor:** - ```bash - open tools/timeline_editor/index.html - ``` - Or double-click `index.html` in Finder. - -2. **Load a timeline:** - - Click "📂 Load demo.seq" - - Select your `assets/demo.seq` file - -3. **Edit the timeline:** - - **Drag sequences/effects** to move them along the timeline - - **Click an item** to select it and view properties - - **Edit properties** in the panel below - - **Click "Apply"** to save property changes - -4. **Save your changes:** - - Click "💾 Save demo.seq" - - Choose where to save the modified file - -5. **Load audio waveform (optional):** - - Click "🎵 Load Audio (WAV)" to visualize your music track - - The waveform appears above the timeline for visual reference - - Use it to align sequences with beats, drops, and musical phrases - - Click "✖ Clear Audio" to remove the waveform - - **Tip:** Generate a WAV file from your demo using: - ```bash - ./build/demo64k --dump_wav output.wav - ``` - Then load `output.wav` in the timeline editor to align sequences with the actual audio output. - -6. **Zoom controls:** - - Use the zoom slider to adjust timeline scale - - Higher zoom = more pixels per second - - Waveform scales automatically with zoom - -7. **Snap-to-beat mode:** - - Enable "Show Beats" checkbox to display beat markers - - Sequences and effects snap to beat boundaries when dragged - - Helps maintain musical timing - -## Keyboard Shortcuts - -- **Delete key**: Delete selected item (when implemented) -- **Escape**: Deselect current item +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. **Playback:** + - Click "▶ Play" or press **Spacebar** to play/pause + - Click waveform to seek + - Watch sequences auto-expand/collapse during playback + - Red playback indicator shows current position +5. **Edit:** + - Drag sequences/effects to reposition + - Double-click sequence header to collapse/expand + - Click item to edit properties in side panel + - Drag effect handles to resize +6. **Zoom:** Ctrl/Cmd + mouse wheel (zooms at cursor position) +7. **Save:** Click "💾 Save timeline.seq" ## File Format @@ -87,71 +57,43 @@ SEQUENCE <start_time> <priority> ["optional_name"] [optional_end] - `=` = Keep same priority as previous - `-` = Decrement priority (background layers) -**Time Notation:** -- `0b`, `4b`, `64b` = Beats (converted using BPM) -- `0.0`, `2.0`, `32.0` = Seconds -- Integer without 'b': treated as beats -- Decimal point: treated as seconds +**Time Notation (Beat-Based):** +- **Default:** All numbers are beats (e.g., `4`, `16.5` = beats) +- `4b`, `16b` = Explicit beats (optional 'b' suffix for clarity) +- `2.0s`, `8.25s` = Explicit seconds (rare, for physical timing) -Example: +Example (Beat-Based): ``` # BPM 120 -SEQUENCE 0b 0 "Opening Scene" - EFFECT - FlashCubeEffect .2 3 # Background (priority -1) - EFFECT + FlashEffect 0.0 1.0 # Foreground (priority 0) - EFFECT + FadeEffect 0.5 2.0 # Overlay (priority 1) - -SEQUENCE 4b 1 "Beat Drop" - EFFECT + HeptagonEffect 0.0 0.5 # Priority 0 - EFFECT = ParticlesEffect 0.0 2.0 # Priority 0 (same layer) -``` - -## Color Coding - -- **Blue boxes**: Sequences (container for effects) -- **Gray boxes**: Effects (visual elements) -- **Green highlight**: Selected sequence -- **Orange highlight**: Selected effect - -## Tips - -- **Sequences** have absolute start times -- **Effects** have start/end times **relative to their sequence** -- Priority determines rendering order (higher = rendered later = on top) -- Effect constructor arguments are passed as-is to the C++ code - -## Limitations +SEQUENCE 0 0 "Opening Scene" # Start at beat 0 + EFFECT - FlashCubeEffect 0 4 # Beats 0-4 (0-2s @ 120 BPM) + EFFECT + FlashEffect 0 2 # Beats 0-2 (0-1s) + EFFECT + FadeEffect 1 4 # Beats 1-4 (0.5-2s) -- No preview rendering (this is intentional - it's just a timeline editor) -- No automatic overlap detection yet -- No undo/redo (coming soon) -- Cannot add effects to sequences (manually edit properties for now) +SEQUENCE 8 1 "Beat Drop" # Start at beat 8 (bar 3) + EFFECT + HeptagonEffect 0 1 # First beat of sequence + EFFECT = ParticlesEffect 0 4 # Full bar (4 beats) -## Future Enhancements - -- [ ] Undo/redo functionality -- [ ] Add effect button (create new effects within sequences) -- [ ] Overlap detection warnings -- [ ] Timeline playback indicator -- [ ] Multiple file comparison -- [ ] Export to different formats -- [ ] **Music.track visualization**: Parse `music.track` file and overlay tracker patterns/samples on timeline for alignment assistance - -## Technical Details - -- Pure HTML/CSS/JavaScript (no dependencies) -- No backend required -- Works offline -- All processing happens in the browser -- Files are saved via browser download API +SEQUENCE 2.5s 0 "Explicit seconds" # Rare: start at 2.5 physical seconds + EFFECT + Fade 0 4 # Still uses beats for duration +``` -## Integration +## Keyboard Shortcuts -After editing in the timeline editor: +- **Spacebar**: Play/pause audio playback +- **Ctrl/Cmd + Wheel**: Zoom in/out at cursor position -1. Save the modified `demo.seq` -2. Copy it to `assets/demo.seq` -3. Rebuild the project: `cmake --build build` -4. The new timeline will be compiled into the demo +## Technical Notes -No code changes needed - the `seq_compiler` automatically processes the updated file. +- Pure HTML/CSS/JavaScript (no dependencies, works offline) +- **Internal representation uses beats** (not seconds) +- Sequences have absolute times (beats), effects are relative to parent sequence +- BPM used for seconds conversion (tooltips, audio waveform alignment) +- Priority determines render order (higher = on top) +- Collapsed sequences show 35px title bar, expanded show full effect stack +- Time markers show beats by default (4-beat/bar increments) +- **Waveform and time markers are sticky** at top during scroll/zoom +- Vertical grid lines aid alignment +- Snap-to-beat enabled by default for musical alignment +- **Auto-expand/collapse**: Active sequence expands during playback, previous collapses +- **Auto-scroll**: Timeline follows playback indicator (keeps it in middle third of viewport) diff --git a/tools/timeline_editor/ROADMAP.md b/tools/timeline_editor/ROADMAP.md index 4bfc35c..216adbf 100644 --- a/tools/timeline_editor/ROADMAP.md +++ b/tools/timeline_editor/ROADMAP.md @@ -4,6 +4,37 @@ This document outlines planned enhancements for the interactive timeline editor. --- +## Known Bugs (High Priority) + +### Audio Playback Integration Issues + +1. **Audio waveform doesn't scale with zoom nor follow timeline** + - Waveform should horizontally sync with timeline ticks/sequences + - Should scale to match `pixelsPerSecond` zoom level + - Currently remains static regardless of zoom + +2. **Playback indicator doesn't follow zoom and height issues** + - Vertical red bar position calculation doesn't account for `pixelsPerSecond` + - Doesn't reach bottom when sequences have scrolled + - Needs to span full `timeline-content` height dynamically + +3. **Sequences overlap timeline at scroll origin** + - Some sequences still go behind timeline ticks + - Notably when wheel pans back to beginning (scrollLeft = 0) + - Need proper clipping or z-index management + +4. **Timeline and waveform should be fixed, not floating** + - Currently using sticky positioning + - Should use true fixed positioning at top + - Should remain stationary regardless of scroll + +5. **Status indicator causes reflow** + - Green status text appears/disappears causing layout shift + - Should be relocated to top or bottom as fixed/always-visible + - Prevents jarring reflow when messages appear + +--- + ## Phase 1: Core Editing Features (High Priority) ### 1.1 Snap-to-Beat ⭐ Priority: HIGH diff --git a/tools/timeline_editor/index.html b/tools/timeline_editor/index.html index db71beb..c9385ad 100644 --- a/tools/timeline_editor/index.html +++ b/tools/timeline_editor/index.html @@ -3,7 +3,7 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>Timeline Editor - demo.seq</title> + <title>Timeline Editor - timeline.seq</title> <style> * { margin: 0; @@ -33,11 +33,17 @@ padding: 20px; border-radius: 8px; margin-bottom: 20px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + flex-wrap: wrap; } h1 { - margin-bottom: 10px; + margin: 0; color: #4ec9b0; + white-space: nowrap; } .controls { @@ -45,7 +51,6 @@ gap: 10px; flex-wrap: wrap; align-items: center; - margin-bottom: 20px; } .checkbox-label { @@ -101,17 +106,24 @@ background: #252526; border-radius: 8px; padding: 20px; - overflow-x: auto; - overflow-y: auto; position: relative; height: calc(100vh - 280px); min-height: 500px; + display: flex; + flex-direction: column; + } + + .timeline-content { + flex: 1; + overflow-x: auto; + overflow-y: auto; + position: relative; /* Hide scrollbars while keeping scroll functionality */ scrollbar-width: none; /* Firefox */ -ms-overflow-style: none; /* IE/Edge */ } - .timeline-container::-webkit-scrollbar { + .timeline-content::-webkit-scrollbar { display: none; /* Chrome/Safari/Opera */ } @@ -121,20 +133,56 @@ border-left: 2px solid #3c3c3c; } + .sticky-header { + position: relative; + background: #252526; + z-index: 100; + padding-bottom: 10px; + border-bottom: 2px solid #3c3c3c; + flex-shrink: 0; + } + + .playback-controls { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 0; + } + + #playPauseBtn { + width: 60px; + padding: 8px 12px; + } + #waveformCanvas { position: relative; height: 80px; width: 100%; - margin-bottom: 10px; background: rgba(0, 0, 0, 0.3); border-radius: 4px; cursor: crosshair; } + .playback-indicator { + position: absolute; + top: 0; + width: 2px; + height: 100%; + background: #f48771; + box-shadow: 0 0 4px rgba(244, 135, 113, 0.8); + pointer-events: none; + z-index: 90; + display: none; + } + + .playback-indicator.playing { + display: block; + } + .time-markers { position: relative; height: 30px; - margin-bottom: 10px; + margin-top: 10px; border-bottom: 1px solid #3c3c3c; } @@ -155,6 +203,17 @@ background: #3c3c3c; } + .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; @@ -190,6 +249,36 @@ } } + .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; + } + .sequence-name { position: absolute; top: 50%; @@ -283,8 +372,8 @@ .properties-panel { position: fixed; - top: 80px; - right: 20px; + bottom: 20px; + left: 20px; width: 350px; max-height: 80vh; background: #252526; @@ -297,7 +386,7 @@ } .properties-panel.collapsed { - transform: translateX(370px); + transform: translateY(calc(100% + 40px)); } .panel-header { @@ -331,8 +420,8 @@ .panel-collapse-btn { position: fixed; - top: 80px; - right: 20px; + bottom: 20px; + left: 20px; background: #252526; border: 1px solid #858585; color: #d4d4d4; @@ -408,49 +497,57 @@ <div class="container"> <header> <h1>📊 Timeline Editor</h1> - <p>Interactive editor for demo.seq files</p> + <div class="controls"> + <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> + <button id="clearAudioBtn" disabled>✖ Clear Audio</button> + <button id="addSequenceBtn" disabled>➕ Add Sequence</button> + <button id="deleteBtn" disabled>🗑️ Delete Selected</button> + <button id="reorderBtn" disabled>🔄 Re-order by Time</button> + </div> </header> - <div class="controls"> - <label class="file-label"> - 📂 Load demo.seq - <input type="file" id="fileInput" accept=".seq"> - </label> - <button id="saveBtn" disabled>💾 Save demo.seq</button> - <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="deleteBtn" disabled>🗑️ Delete Selected</button> - <button id="reorderBtn" disabled>🔄 Re-order by Time</button> - </div> - <div class="zoom-controls"> <label>Zoom: <input type="range" id="zoomSlider" min="10" max="200" value="100" step="10"></label> <span id="zoomLevel">100%</span> - <label style="margin-left: 20px">Pixels per second: <span id="pixelsPerSec">100</span></label> + <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"> - Show Beats (BPM: <span id="currentBPM">120</span>) + <input type="checkbox" id="showBeatsCheckbox" checked> + Show Beats </label> </div> <div id="messageArea"></div> <div class="timeline-container"> - <canvas id="waveformCanvas" style="display: none;"></canvas> - <div class="time-markers" id="timeMarkers"></div> - <div class="timeline" id="timeline"></div> + <div class="sticky-header"> + <div class="playback-controls" id="playbackControls" style="display: none;"> + <button id="playPauseBtn">▶ Play</button> + <span id="playbackTime">0.00s</span> + </div> + <canvas id="waveformCanvas" style="display: none;"></canvas> + <div class="time-markers" id="timeMarkers"></div> + </div> + <div class="timeline-content" id="timelineContent"> + <div class="playback-indicator" id="playbackIndicator"></div> + <div class="timeline" id="timeline"></div> + </div> </div> - <button class="panel-collapse-btn" id="panelCollapseBtn">◀ Properties</button> + <button class="panel-collapse-btn" id="panelCollapseBtn">▲ Properties</button> <div class="properties-panel" id="propertiesPanel" style="display: none;"> <div class="panel-header"> <h2>Properties</h2> - <button class="panel-toggle" id="panelToggle">▶ Collapse</button> + <button class="panel-toggle" id="panelToggle">▼ Collapse</button> </div> <div id="propertiesContent"></div> </div> @@ -464,7 +561,7 @@ let currentFile = null; let selectedItem = null; let pixelsPerSecond = 100; - let showBeats = false; + let showBeats = true; let bpm = 120; let isDragging = false; let dragOffset = { x: 0, y: 0 }; @@ -473,10 +570,18 @@ let handleType = null; // 'left' or 'right' let audioBuffer = null; // Decoded audio data let audioDuration = 0; // Duration in seconds + let audioSource = null; // Current playback source + let audioContext = null; // Audio context for playback + let isPlaying = false; + let playbackStartTime = 0; // When playback started (audioContext.currentTime) + let playbackOffset = 0; // Offset into audio (seconds) + let animationFrameId = null; + let lastExpandedSeqIndex = -1; // DOM elements const timeline = document.getElementById('timeline'); const timelineContainer = document.querySelector('.timeline-container'); + const timelineContent = document.getElementById('timelineContent'); const fileInput = document.getElementById('fileInput'); const saveBtn = document.getElementById('saveBtn'); const audioInput = document.getElementById('audioInput'); @@ -490,10 +595,13 @@ const messageArea = document.getElementById('messageArea'); const zoomSlider = document.getElementById('zoomSlider'); const zoomLevel = document.getElementById('zoomLevel'); - const pixelsPerSecLabel = document.getElementById('pixelsPerSec'); const stats = document.getElementById('stats'); + const playPauseBtn = document.getElementById('playPauseBtn'); + const playbackControls = document.getElementById('playbackControls'); + const playbackTime = document.getElementById('playbackTime'); + const playbackIndicator = document.getElementById('playbackIndicator'); - // Parser: demo.seq → JavaScript objects + // Parser: timeline.seq → JavaScript objects // Format specification: doc/SEQUENCE.md function parseSeqFile(content) { const sequences = []; @@ -502,13 +610,18 @@ let bpm = 120; // Default BPM let currentPriority = 0; // Track priority for + = - modifiers - // Helper: Convert time notation to seconds + // Helper: Parse time notation (returns beats) function parseTime(timeStr) { + if (timeStr.endsWith('s')) { + // Explicit seconds: "2.5s" = convert to beats + const seconds = parseFloat(timeStr.slice(0, -1)); + return seconds * bpm / 60.0; + } if (timeStr.endsWith('b')) { - // Beat notation: "4b" = 4 beats - const beats = parseFloat(timeStr.slice(0, -1)); - return beats * (60.0 / bpm); + // Explicit beats: "4b" = 4 beats + return parseFloat(timeStr.slice(0, -1)); } + // Default: beats return parseFloat(timeStr); } @@ -551,7 +664,8 @@ startTime: parseTime(seqMatch[1]), priority: parseInt(seqMatch[2]), effects: [], - name: seqMatch[3] || '' + name: seqMatch[3] || '', + _collapsed: true }; sequences.push(currentSequence); currentPriority = -1; // Reset effect priority for new sequence @@ -587,7 +701,7 @@ return { sequences, bpm }; } - // Serializer: JavaScript objects → demo.seq + // Serializer: JavaScript objects → timeline.seq (outputs beats) function serializeSeqFile(sequences) { let output = '# Demo Timeline\n'; output += '# Generated by Timeline Editor\n'; @@ -619,12 +733,15 @@ async function loadAudioFile(file) { try { const arrayBuffer = await file.arrayBuffer(); - const audioContext = new (window.AudioContext || window.webkitAudioContext)(); + if (!audioContext) { + audioContext = new (window.AudioContext || window.webkitAudioContext)(); + } audioBuffer = await audioContext.decodeAudioData(arrayBuffer); audioDuration = audioBuffer.duration; renderWaveform(); waveformCanvas.style.display = 'block'; + playbackControls.style.display = 'flex'; clearAudioBtn.disabled = false; showMessage(`Audio loaded: ${audioDuration.toFixed(2)}s`, 'success'); @@ -641,8 +758,9 @@ const canvas = waveformCanvas; const ctx = canvas.getContext('2d'); - // Set canvas size based on audio duration and zoom - const canvasWidth = audioDuration * pixelsPerSecond; + // Set canvas size based on audio duration (convert to beats) and zoom + const audioDurationBeats = audioDuration * bpm / 60.0; + const canvasWidth = audioDurationBeats * pixelsPerSecond; const canvasHeight = 80; // Set actual canvas resolution (for sharp rendering) @@ -707,24 +825,157 @@ } function clearAudio() { + stopPlayback(); audioBuffer = null; audioDuration = 0; waveformCanvas.style.display = 'none'; + playbackControls.style.display = 'none'; clearAudioBtn.disabled = true; renderTimeline(); showMessage('Audio cleared', 'success'); } + // Playback functions + function startPlayback() { + if (!audioBuffer || !audioContext) return; + + // Resume audio context if suspended + if (audioContext.state === 'suspended') { + audioContext.resume(); + } + + // Create and start audio source + audioSource = audioContext.createBufferSource(); + audioSource.buffer = audioBuffer; + audioSource.connect(audioContext.destination); + audioSource.start(0, playbackOffset); + + playbackStartTime = audioContext.currentTime; + isPlaying = true; + playPauseBtn.textContent = '⏸ Pause'; + playbackIndicator.classList.add('playing'); + + // Start animation loop + updatePlaybackPosition(); + + audioSource.onended = () => { + if (isPlaying) { + stopPlayback(); + } + }; + } + + function stopPlayback() { + if (audioSource) { + try { + audioSource.stop(); + } catch (e) { + // Already stopped + } + audioSource = null; + } + + if (animationFrameId) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } + + if (isPlaying) { + // Save current position for resume + const elapsed = audioContext.currentTime - playbackStartTime; + playbackOffset = Math.min(playbackOffset + elapsed, audioDuration); + } + + isPlaying = false; + playPauseBtn.textContent = '▶ Play'; + playbackIndicator.classList.remove('playing'); + } + + function updatePlaybackPosition() { + if (!isPlaying) return; + + const elapsed = audioContext.currentTime - playbackStartTime; + const currentTime = playbackOffset + elapsed; + + // Update time display + playbackTime.textContent = `${currentTime.toFixed(2)}s`; + + // Convert to beats for position calculation + const currentBeats = currentTime * bpm / 60.0; + + // Update playback indicator position + const indicatorX = currentBeats * pixelsPerSecond; + playbackIndicator.style.left = `${indicatorX}px`; + + // Auto-scroll timeline to follow playback + const viewportWidth = timelineContent.clientWidth; + const scrollX = timelineContent.scrollLeft; + const relativeX = indicatorX - scrollX; + + // Keep indicator in middle third of viewport + if (relativeX < viewportWidth * 0.33 || relativeX > viewportWidth * 0.67) { + timelineContent.scrollLeft = indicatorX - viewportWidth * 0.5; + } + + // Auto-expand/collapse sequences + expandSequenceAtTime(currentBeats); + + // Continue animation + animationFrameId = requestAnimationFrame(updatePlaybackPosition); + } + + function expandSequenceAtTime(currentBeats) { + // Find which sequence is active at current time + let activeSeqIndex = -1; + for (let i = 0; i < sequences.length; i++) { + const seq = sequences[i]; + const seqEndBeats = seq.startTime + (seq.effects.length > 0 + ? Math.max(...seq.effects.map(e => e.endTime)) + : 0); + + if (currentBeats >= seq.startTime && currentBeats <= seqEndBeats) { + activeSeqIndex = i; + break; + } + } + + // Changed sequence - collapse old, expand new + if (activeSeqIndex !== lastExpandedSeqIndex) { + // Collapse previous sequence + if (lastExpandedSeqIndex >= 0 && lastExpandedSeqIndex < sequences.length) { + sequences[lastExpandedSeqIndex]._collapsed = true; + } + + // Expand new sequence + if (activeSeqIndex >= 0) { + sequences[activeSeqIndex]._collapsed = false; + lastExpandedSeqIndex = activeSeqIndex; + + // Flash animation + const seqDivs = timeline.querySelectorAll('.sequence'); + if (seqDivs[activeSeqIndex]) { + seqDivs[activeSeqIndex].classList.add('active-flash'); + setTimeout(() => { + seqDivs[activeSeqIndex]?.classList.remove('active-flash'); + }, 600); + } + } + + // Re-render to show collapse/expand changes + renderTimeline(); + } + } + // Render timeline function renderTimeline() { timeline.innerHTML = ''; const timeMarkers = document.getElementById('timeMarkers'); timeMarkers.innerHTML = ''; - // Calculate max time - let maxTime = 30; // Default 30 seconds + // Calculate max time (in beats) + let maxTime = 60; // Default 60 beats (15 bars) for (const seq of sequences) { - const seqEnd = seq.startTime + 10; // Default sequence duration + const seqEnd = seq.startTime + 16; // Default 4 bars maxTime = Math.max(maxTime, seqEnd); for (const effect of seq.effects) { @@ -734,7 +985,8 @@ // Extend timeline to fit audio if loaded if (audioDuration > 0) { - maxTime = Math.max(maxTime, audioDuration); + const audioBeats = audioDuration * bpm / 60.0; + maxTime = Math.max(maxTime, audioBeats); } // Render time markers @@ -742,23 +994,22 @@ timeline.style.width = `${timelineWidth}px`; if (showBeats) { - // Show beats - const beatDuration = 60.0 / bpm; // seconds per beat - const maxBeats = Math.ceil(maxTime / beatDuration); - for (let beat = 0; beat <= maxBeats; beat++) { - const timeSec = beat * beatDuration; + // Show beats (default) + for (let beat = 0; beat <= maxTime; beat += 4) { const marker = document.createElement('div'); marker.className = 'time-marker'; - marker.style.left = `${timeSec * pixelsPerSecond}px`; + marker.style.left = `${beat * pixelsPerSecond}px`; marker.textContent = `${beat}b`; timeMarkers.appendChild(marker); } } else { // Show seconds - for (let t = 0; t <= maxTime; t += 1) { + const maxSeconds = maxTime * 60.0 / bpm; + for (let t = 0; t <= maxSeconds; t += 1) { + const beatPos = t * bpm / 60.0; const marker = document.createElement('div'); marker.className = 'time-marker'; - marker.style.left = `${t * pixelsPerSecond}px`; + marker.style.left = `${beatPos * pixelsPerSecond}px`; marker.textContent = `${t}s`; timeMarkers.appendChild(marker); } @@ -786,20 +1037,53 @@ const seqVisualWidth = seqVisualEnd - seqVisualStart; + // Initialize collapsed state if undefined + if (seq._collapsed === undefined) { + seq._collapsed = false; + } + // Calculate sequence height based on number of effects (stacked vertically) const numEffects = seq.effects.length; const effectSpacing = 30; - const seqHeight = Math.max(70, 20 + numEffects * effectSpacing + 5); + const fullHeight = Math.max(70, 20 + numEffects * effectSpacing + 5); + const seqHeight = seq._collapsed ? 35 : fullHeight; seqDiv.style.left = `${seqVisualStart * 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'; + 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(); + }); + + seqDiv.appendChild(seqHeaderDiv); + // Create sequence name overlay (large, centered, fades on hover) const seqNameDiv = document.createElement('div'); seqNameDiv.className = 'sequence-name'; @@ -807,6 +1091,11 @@ 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'); } @@ -827,7 +1116,8 @@ timeline.appendChild(seqDiv); - // Render effects within sequence + // Render effects within sequence (skip if collapsed) + if (!seq._collapsed) { seq.effects.forEach((effect, effectIndex) => { const effectDiv = document.createElement('div'); effectDiv.className = 'effect'; @@ -842,16 +1132,14 @@ effectDiv.style.width = `${effectWidth}px`; effectDiv.style.height = '26px'; - // Format time display based on mode (for tooltip) - let timeDisplay; - if (showBeats) { - const beatDuration = 60.0 / bpm; - const startBeat = (effect.startTime / beatDuration).toFixed(1); - const endBeat = (effect.endTime / beatDuration).toFixed(1); - timeDisplay = `${startBeat}-${endBeat}b`; - } else { - timeDisplay = `${effect.startTime.toFixed(1)}-${effect.endTime.toFixed(1)}s`; - } + // Format time display (beats primary, seconds in tooltip) + const startBeat = effect.startTime.toFixed(1); + const endBeat = effect.endTime.toFixed(1); + const startSec = (effect.startTime * 60.0 / bpm).toFixed(1); + const endSec = (effect.endTime * 60.0 / bpm).toFixed(1); + const timeDisplay = showBeats + ? `${startBeat}-${endBeat}b (${startSec}-${endSec}s)` + : `${startSec}-${endSec}s (${startBeat}-${endBeat}b)`; // Show only class name, full info on hover effectDiv.innerHTML = ` @@ -894,6 +1182,7 @@ timeline.appendChild(effectDiv); }); + } }); updateStats(); @@ -926,11 +1215,9 @@ const newX = e.clientX - timelineRect.left - dragOffset.x; let newTime = Math.max(0, newX / pixelsPerSecond); - // Snap to beat when in beat mode + // Snap to beat when enabled if (showBeats) { - const beatDuration = 60.0 / bpm; - const nearestBeat = Math.round(newTime / beatDuration); - newTime = nearestBeat * beatDuration; + newTime = Math.round(newTime); } if (selectedItem.type === 'sequence') { @@ -977,11 +1264,9 @@ const newX = e.clientX - timelineRect.left; let newTime = Math.max(0, newX / pixelsPerSecond); - // Snap to beat when in beat mode + // Snap to beat when enabled if (showBeats) { - const beatDuration = 60.0 / bpm; - const nearestBeat = Math.round(newTime / beatDuration); - newTime = nearestBeat * beatDuration; + newTime = Math.round(newTime); } const seq = sequences[selectedItem.seqIndex]; @@ -1153,6 +1438,7 @@ sequences = parsed.sequences; bpm = parsed.bpm; document.getElementById('currentBPM').textContent = bpm; + document.getElementById('bpmSlider').value = bpm; renderTimeline(); saveBtn.disabled = false; addSequenceBtn.disabled = false; @@ -1172,7 +1458,7 @@ const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = currentFile || 'demo.seq'; + a.download = currentFile || 'timeline.seq'; a.click(); URL.revokeObjectURL(url); showMessage('File saved', 'success'); @@ -1189,12 +1475,52 @@ audioInput.value = ''; // Reset file input }); + playPauseBtn.addEventListener('click', () => { + if (isPlaying) { + stopPlayback(); + } else { + // Reset to beginning if at end + if (playbackOffset >= audioDuration) { + playbackOffset = 0; + } + startPlayback(); + } + }); + + // Waveform click to seek + waveformCanvas.addEventListener('click', (e) => { + if (!audioBuffer) return; + + const rect = waveformCanvas.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + const audioDurationBeats = audioDuration * bpm / 60.0; + const clickBeats = (clickX / waveformCanvas.width) * audioDurationBeats; + const clickTime = clickBeats * 60.0 / bpm; + + const wasPlaying = isPlaying; + if (wasPlaying) { + stopPlayback(); + } + + playbackOffset = Math.max(0, Math.min(clickTime, audioDuration)); + + if (wasPlaying) { + startPlayback(); + } else { + // Update display even when paused + playbackTime.textContent = `${playbackOffset.toFixed(2)}s`; + const indicatorX = (playbackOffset * bpm / 60.0) * pixelsPerSecond; + playbackIndicator.style.left = `${indicatorX}px`; + } + }); + addSequenceBtn.addEventListener('click', () => { sequences.push({ type: 'sequence', startTime: 0, priority: 0, - effects: [] + effects: [], + _collapsed: true }); renderTimeline(); showMessage('New sequence added', 'success'); @@ -1232,7 +1558,7 @@ const newIndex = sequences.indexOf(currentActiveSeq); if (newIndex >= 0 && sequences[newIndex]._yPosition !== undefined) { // Scroll to keep it in view - timelineContainer.scrollTop = sequences[newIndex]._yPosition; + timelineContent.scrollTop = sequences[newIndex]._yPosition; lastActiveSeqIndex = newIndex; } } @@ -1245,13 +1571,24 @@ const zoom = parseInt(e.target.value); pixelsPerSecond = zoom; zoomLevel.textContent = `${zoom}%`; - pixelsPerSecLabel.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) => { @@ -1266,13 +1603,13 @@ panelToggle.addEventListener('click', () => { propertiesPanel.classList.add('collapsed'); panelCollapseBtn.classList.add('visible'); - panelToggle.textContent = '◀ Expand'; + panelToggle.textContent = '▲ Expand'; }); panelCollapseBtn.addEventListener('click', () => { propertiesPanel.classList.remove('collapsed'); panelCollapseBtn.classList.remove('visible'); - panelToggle.textContent = '▶ Collapse'; + panelToggle.textContent = '▼ Collapse'; }); // Click outside to deselect @@ -1283,18 +1620,27 @@ updateProperties(); }); + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + // Spacebar: play/pause (if audio loaded) + if (e.code === 'Space' && audioBuffer) { + e.preventDefault(); + playPauseBtn.click(); + } + }); + // Mouse wheel: zoom (with Ctrl/Cmd) or diagonal scroll - timelineContainer.addEventListener('wheel', (e) => { + timelineContent.addEventListener('wheel', (e) => { e.preventDefault(); // Zoom mode: Ctrl/Cmd + wheel if (e.ctrlKey || e.metaKey) { - // Get mouse position relative to timeline container - const rect = timelineContainer.getBoundingClientRect(); + // Get mouse position relative to timeline content + const rect = timelineContent.getBoundingClientRect(); const mouseX = e.clientX - rect.left; // Mouse X in viewport coordinates // Calculate time position under cursor BEFORE zoom - const scrollLeft = timelineContainer.scrollLeft; + const scrollLeft = timelineContent.scrollLeft; const timeUnderCursor = (scrollLeft + mouseX) / pixelsPerSecond; // Calculate new zoom level @@ -1308,7 +1654,6 @@ // Update zoom slider and labels zoomSlider.value = pixelsPerSecond; zoomLevel.textContent = `${pixelsPerSecond}%`; - pixelsPerSecLabel.textContent = pixelsPerSecond; // Re-render waveform and timeline at new zoom if (audioBuffer) { @@ -1319,17 +1664,17 @@ // Adjust scroll position so time under cursor stays in same place // After zoom: new_scrollLeft = time_under_cursor * newPixelsPerSecond - mouseX const newScrollLeft = timeUnderCursor * newPixelsPerSecond - mouseX; - timelineContainer.scrollLeft = newScrollLeft; + timelineContent.scrollLeft = newScrollLeft; } return; } // Normal mode: diagonal scroll - timelineContainer.scrollLeft += e.deltaY; + timelineContent.scrollLeft += e.deltaY; // Calculate current time position with 10% headroom for visual comfort - const currentScrollLeft = timelineContainer.scrollLeft; - const viewportWidth = timelineContainer.clientWidth; + const currentScrollLeft = timelineContent.scrollLeft; + const viewportWidth = timelineContent.clientWidth; const slack = (viewportWidth / pixelsPerSecond) * 0.1; // 10% of viewport width in seconds const currentTime = (currentScrollLeft / pixelsPerSecond) + slack; @@ -1361,12 +1706,12 @@ // Smooth vertical scroll to bring target sequence to top of viewport const targetScrollTop = sequences[targetSeqIndex]?._yPosition || 0; - const currentScrollTop = timelineContainer.scrollTop; + const currentScrollTop = timelineContent.scrollTop; const scrollDiff = targetScrollTop - currentScrollTop; // Smooth transition (don't jump instantly) if (Math.abs(scrollDiff) > 5) { - timelineContainer.scrollTop += scrollDiff * 0.3; + timelineContent.scrollTop += scrollDiff * 0.3; } }, { passive: false }); |
