diff options
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/cnn_v2_test/index.html | 39 | ||||
| -rw-r--r-- | tools/common/style.css | 117 | ||||
| -rw-r--r-- | tools/shader_editor/index.html | 42 | ||||
| -rw-r--r-- | tools/spectral_editor/index.html | 1 | ||||
| -rw-r--r-- | tools/spectral_editor/style.css | 106 | ||||
| -rw-r--r-- | tools/timeline_editor/README.md | 13 | ||||
| -rw-r--r-- | tools/timeline_editor/index.html | 961 | ||||
| -rw-r--r-- | tools/timeline_editor/timeline-playback.js | 322 | ||||
| -rw-r--r-- | tools/timeline_editor/timeline-viewport.js | 170 | ||||
| -rw-r--r-- | tools/track_visualizer/index.html | 21 |
10 files changed, 1229 insertions, 563 deletions
diff --git a/tools/cnn_v2_test/index.html b/tools/cnn_v2_test/index.html index e226d0c..84702d5 100644 --- a/tools/cnn_v2_test/index.html +++ b/tools/cnn_v2_test/index.html @@ -32,32 +32,21 @@ <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>CNN v2 Testing Tool</title> + <link rel="stylesheet" href="../common/style.css"> <style> - * { margin: 0; padding: 0; box-sizing: border-box; } body { - font-family: 'Courier New', monospace; - background: #1a1a1a; - color: #e0e0e0; display: flex; flex-direction: column; height: 100vh; - overflow: hidden; } .header { - background: #2a2a2a; padding: 16px; border-bottom: 1px solid #404040; - display: flex; - align-items: center; gap: 24px; - flex-wrap: wrap; } h1 { font-size: 18px; } .controls { - display: flex; gap: 16px; - align-items: center; - flex-wrap: wrap; } .control-group { display: flex; @@ -66,7 +55,7 @@ } .control-group label { font-size: 12px; } input[type="range"] { width: 120px; } - input[type="number"] { width: 60px; background: #1a1a1a; color: #e0e0e0; border: 1px solid #404040; padding: 4px; } + input[type="number"] { width: 60px; padding: 4px; } .drop-zone { border: 3px dashed #606060; padding: 20px; @@ -80,18 +69,10 @@ color: #4a9eff; } button { - background: #1a1a1a; - border: 1px solid #404040; - color: #e0e0e0; padding: 6px 12px; font-size: 12px; - font-family: 'Courier New', monospace; - cursor: pointer; - transition: all 0.2s; - border-radius: 4px; } button:hover { border-color: #606060; background: #252525; } - button:disabled { opacity: 0.3; cursor: not-allowed; } video { display: none; } .drop-zone:hover { border-color: #4a9eff; background: #2a3545; } .drop-zone.active { border-color: #4a9eff; background: #1a2a3a; } @@ -120,7 +101,6 @@ padding: 24px; overflow: auto; position: relative; - background: #1a1a1a; } .video-controls-float { position: absolute; @@ -185,7 +165,6 @@ padding: 16px; } .panel { - border: 1px solid #404040; border-radius: 4px; overflow: hidden; } @@ -228,28 +207,14 @@ margin-bottom: 12px; } .layer-buttons button { - background: #1a1a1a; - border: 1px solid #404040; - color: #e0e0e0; padding: 6px 12px; font-size: 10px; - font-family: 'Courier New', monospace; - cursor: pointer; - transition: all 0.2s; - } - .layer-buttons button:hover { - border-color: #606060; - background: #252525; } .layer-buttons button.active { background: #4a9eff; border-color: #4a9eff; color: #1a1a1a; } - .layer-buttons button:disabled { - opacity: 0.3; - cursor: not-allowed; - } .layer-buttons button:disabled:hover { border-color: #404040; background: #1a1a1a; diff --git a/tools/common/style.css b/tools/common/style.css new file mode 100644 index 0000000..1ba4bad --- /dev/null +++ b/tools/common/style.css @@ -0,0 +1,117 @@ +: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; +} + +* { + 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); + overflow: hidden; +} + +button, .btn, .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; + text-align: center; +} + +button:hover, .btn:hover, .file-label:hover { + background: var(--accent-blue-hover); +} + +button:disabled, .btn:disabled { + background: var(--bg-light); + cursor: not-allowed; +} + +input[type="file"] { + display: none; +} + +input, select { + background: var(--bg-light); + border: 1px solid var(--border-color); + border-radius: var(--radius); + color: var(--text-primary); + padding: 8px; + font-size: 14px; +} + +.container { + width: 100%; + height: 100vh; + display: flex; + flex-direction: column; +} + +.header { + background: var(--bg-medium); + padding: 15px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + gap: 20px; + flex-wrap: wrap; +} + +h1 { + color: var(--accent-green); + font-size: 18px; + white-space: nowrap; +} + +.controls { + display: flex; + gap: var(--gap); + flex-wrap: wrap; + align-items: center; +} + +.panel { + background: var(--bg-medium); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 15px; +} + +.error-message { + background: #5a1d1d; + color: var(--accent-red); + padding: 10px; + border-radius: var(--radius); + box-shadow: 0 2px 8px rgba(0,0,0,0.3); + margin: 10px 0; +} + +.success-message { + background: #1e5231; + color: #89d185; + padding: 10px; + border-radius: var(--radius); + box-shadow: 0 2px 8px rgba(0,0,0,0.3); + margin: 10px 0; +} diff --git a/tools/shader_editor/index.html b/tools/shader_editor/index.html index bad0abb..d93a595 100644 --- a/tools/shader_editor/index.html +++ b/tools/shader_editor/index.html @@ -4,26 +4,8 @@ <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>WGSL Shader Editor</title> + <link rel="stylesheet" href="../common/style.css"> <style> -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", monospace; - background: #1e1e1e; - color: #d4d4d4; - overflow: hidden; - height: 100vh; -} - -.container { - display: flex; - height: 100vh; -} - .preview-pane { flex: 0 0 57%; background: #252526; @@ -89,26 +71,13 @@ body { } .control-group button { - background: #0e639c; - color: #fff; - border: none; padding: 6px 12px; - border-radius: 3px; - cursor: pointer; font-size: 13px; } -.control-group button:hover { - background: #1177bb; -} - .control-group input[type="number"], .control-group select { - background: #3c3c3c; - color: #d4d4d4; - border: 1px solid #3e3e42; padding: 4px 8px; - border-radius: 3px; font-size: 13px; } @@ -153,19 +122,10 @@ body { } .editor-header button { - background: #0e639c; - color: #fff; - border: none; padding: 6px 12px; - border-radius: 3px; - cursor: pointer; font-size: 13px; } -.editor-header button:hover { - background: #1177bb; -} - .editor-container { flex: 1; position: relative; diff --git a/tools/spectral_editor/index.html b/tools/spectral_editor/index.html index 75658ae..2d5f3e5 100644 --- a/tools/spectral_editor/index.html +++ b/tools/spectral_editor/index.html @@ -4,6 +4,7 @@ <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Spectral Brush Editor</title> + <link rel="stylesheet" href="../common/style.css"> <link rel="stylesheet" href="style.css"> </head> <body> diff --git a/tools/spectral_editor/style.css b/tools/spectral_editor/style.css index 48f7463..87fb54e 100644 --- a/tools/spectral_editor/style.css +++ b/tools/spectral_editor/style.css @@ -1,18 +1,4 @@ -/* Spectral Brush Editor Styles */ - -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - background: #1e1e1e; - color: #d4d4d4; - overflow: hidden; - height: 100vh; -} +/* Spectral Brush Editor Specific Styles */ #app { display: flex; @@ -20,41 +6,12 @@ body { height: 100vh; } -/* Header */ -header { - background: #252526; - padding: 12px 20px; - border-bottom: 1px solid #3e3e42; - display: flex; - justify-content: space-between; - align-items: center; -} - -header h1 { - font-size: 18px; - font-weight: 600; - color: #cccccc; -} - -.header-controls { - display: flex; - align-items: center; - gap: 15px; -} - -.file-info { - font-size: 13px; - color: #858585; -} - -/* Main content area */ .main-content { display: flex; flex: 1; overflow: hidden; } -/* Canvas container (80% width) */ .canvas-container { flex: 1; position: relative; @@ -89,7 +46,6 @@ header h1 { display: none; } -/* Mini spectrum viewer (bottom-right overlay) */ .spectrum-viewer { position: absolute; bottom: 10px; @@ -99,12 +55,8 @@ header h1 { background: rgba(30, 30, 30, 0.9); border: 1px solid #3e3e42; border-radius: 3px; - display: block; /* Always visible */ - pointer-events: none; /* Don't interfere with mouse events */ -} - -.spectrum-viewer.active { - display: block; /* Keep for backward compatibility */ + display: block; + pointer-events: none; } #spectrumCanvas { @@ -123,7 +75,6 @@ header h1 { color: #858585; } -/* Toolbar (20% width) */ .toolbar { width: 250px; background: #252526; @@ -155,16 +106,6 @@ header h1 { transition: background 0.2s; } -.btn-toolbar:hover { - background: #1177bb; -} - -.btn-toolbar:disabled { - background: #3e3e42; - color: #858585; - cursor: not-allowed; -} - .btn-toolbar.btn-danger { background: #a82d2d; } @@ -199,7 +140,6 @@ header h1 { border-color: #0e639c; } -/* Point info panel */ .point-info { margin-top: 10px; padding: 10px; @@ -224,7 +164,6 @@ header h1 { font-family: monospace; } -/* Control panel (bottom) */ .control-panel { background: #252526; border-top: 1px solid #3e3e42; @@ -314,16 +253,6 @@ header h1 { transition: background 0.2s; } -.btn-playback:hover:not(:disabled) { - background: #1177bb; -} - -.btn-playback:disabled { - background: #3e3e42; - color: #858585; - cursor: not-allowed; -} - .btn-playback kbd { background: rgba(255, 255, 255, 0.1); padding: 2px 5px; @@ -331,7 +260,6 @@ header h1 { font-size: 11px; } -/* Action bar (bottom) */ .action-bar { background: #2d2d30; border-top: 1px solid #3e3e42; @@ -365,11 +293,6 @@ header h1 { border-color: #0e639c; } -.btn-action:disabled { - color: #858585; - cursor: not-allowed; -} - .btn-primary { padding: 6px 16px; background: #0e639c; @@ -381,17 +304,11 @@ header h1 { transition: background 0.2s; } -.btn-primary:hover { - background: #1177bb; -} - -/* Icon styling */ .icon { font-size: 14px; line-height: 1; } -/* Modal */ .modal { position: fixed; z-index: 1000; @@ -490,7 +407,6 @@ header h1 { color: #cccccc; } -/* Scrollbar styling */ ::-webkit-scrollbar { width: 10px; height: 10px; @@ -509,7 +425,6 @@ header h1 { background: #4e4e52; } -/* Waveform intensity viewer */ .waveform-container { position: relative; height: 120px; @@ -570,24 +485,9 @@ header h1 { transition: background 0.2s; } -.btn-copy:hover, .btn-snap:hover { - background: #1177bb; -} - -.btn-copy:active, .btn-snap:active { - background: #0d5a8f; -} - .spectrogram-wrapper { flex: 1; position: relative; overflow: hidden; z-index: 1; } - -#spectrogramCanvas { - width: 100%; - height: 100%; - display: block; - cursor: crosshair; -} diff --git a/tools/timeline_editor/README.md b/tools/timeline_editor/README.md index 72b5ae0..66e39bd 100644 --- a/tools/timeline_editor/README.md +++ b/tools/timeline_editor/README.md @@ -39,7 +39,12 @@ This helps identify performance hotspots in your timeline. ## Usage -1. **Open:** `open tools/timeline_editor/index.html` or double-click in browser +1. **Open:** Requires HTTP server (ES6 modules): + ```bash + cd tools/timeline_editor + python3 -m http.server 8080 + ``` + Then open: `http://localhost:8080` 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` @@ -125,7 +130,11 @@ open "tools/timeline_editor/index.html?seq=../../workspaces/main/timeline.seq" ## Technical Notes -- Pure HTML/CSS/JavaScript (no dependencies, works offline) +- Modular ES6 structure (requires HTTP server, not file://) + - `index.html` - Main editor and rendering + - `timeline-viewport.js` - Zoom/scroll/indicator control + - `timeline-playback.js` - Audio playback and waveform +- No external dependencies - **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) diff --git a/tools/timeline_editor/index.html b/tools/timeline_editor/index.html index eca7b97..c5e0264 100644 --- a/tools/timeline_editor/index.html +++ b/tools/timeline_editor/index.html @@ -4,105 +4,466 @@ <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Timeline Editor - timeline.seq</title> + <link rel="stylesheet" href="../common/style.css"> <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> - :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 { + padding: 20px; + min-height: 100vh; } - * { 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; } + .container { + max-width: 100%; + width: 100%; + margin: 0 auto; + } + + 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; + } + + .zoom-controls { + display: flex; + gap: var(--gap); + flex-wrap: wrap; + align-items: center; + margin-bottom: var(--gap); + } + + .checkbox-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + user-select: none; + } + + .checkbox-label input[type="checkbox"] { + cursor: pointer; + } + + .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%; + } + + .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.5); + 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; + } + + .waveform-cursor { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: rgba(78, 201, 176, 0.6); + pointer-events: none; + z-index: 3; + display: none; + } + + .waveform-tooltip { + position: absolute; + background: rgba(30, 30, 30, 0.95); + color: var(--text-primary); + padding: 6px 10px; + border-radius: 4px; + font-size: 12px; + pointer-events: none; + z-index: 4; + display: none; + white-space: nowrap; + border: 1px solid var(--border-color); + box-shadow: 0 2px 8px rgba(0,0,0,0.3); + } + + .playback-indicator { + position: absolute; + top: 0; + bottom: 0; + left: 20px; + width: 2px; + background: var(--accent-red); + box-shadow: 0 0 4px rgba(244, 135, 113, 0.8); + pointer-events: none; + z-index: 110; + display: none; + } + + .time-markers { + position: relative; + height: 30px; + margin-top: var(--gap); + border-bottom: 1px solid var(--bg-light); + } - 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); } + .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); + } - 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; } + .time-marker::after { + content: ''; + position: absolute; + left: 0; + top: 30px; + width: 1px; + height: 10000px; + background: rgba(100, 100, 60, 0.9); + pointer-events: none; + } - .checkbox-label { display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; } - .checkbox-label input[type="checkbox"] { cursor: pointer; } + .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; + } - .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); } + .sequence:hover { + box-shadow: 0 0 10px rgba(14, 99, 156, 0.5); + } - .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; } + .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; + } - .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; } + .sequence.active-playing { + border-color: var(--accent-green); + background: #2a5f4a; + } - .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.active-flash { + animation: sequenceFlash 0.6s ease-out; + } - .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: var(--accent-green); } - 100% { box-shadow: 0 0 10px rgba(14, 99, 156, 0.5); border-color: var(--accent-blue); } + 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-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; + } + + .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); } - .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; } + .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; + } - .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; } + .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)); + } - .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; } + .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; + } - .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; } + .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; + } - .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); } + .panel-collapse-btn:hover { + background: var(--bg-light); + } + + .panel-collapse-btn.visible { + display: block; + } + + .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%; + } + + .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> @@ -125,7 +486,7 @@ <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">BPM: <input type="range" id="bpmSlider" min="60" max="200" value="120" step="1"></label> - <span id="currentBPM">120</span> + <input type="number" id="currentBPM" value="120" min="60" max="200" step="1" style="width: 60px; padding: 4px; background: var(--bg-light); border: 1px solid var(--border-color); border-radius: var(--radius); color: var(--text-primary); text-align: center;"> <label class="checkbox-label" style="margin-left: 20px"> <input type="checkbox" id="showBeatsCheckbox" checked>Show Beats </label> @@ -143,22 +504,24 @@ <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> + <button id="replayBtn" disabled>↻ Replay</button> </div> </div> <div id="messageArea"></div> - <div class="timeline-container"> + <div class="timeline-container" id="timelineContainer"> + <div class="playback-indicator" id="playbackIndicator"></div> <div class="sticky-header"> <div class="waveform-container" id="waveformContainer"> <canvas id="cpuLoadCanvas"></canvas> <canvas id="waveformCanvas"></canvas> - <div class="playback-indicator" id="waveformPlaybackIndicator"></div> + <div class="waveform-cursor" id="waveformCursor"></div> + <div class="waveform-tooltip" id="waveformTooltip"></div> </div> <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> @@ -176,25 +539,47 @@ <div class="stats" id="stats"></div> </div> - <script> + <script type="module"> + import { ViewportController } from './timeline-viewport.js'; + import { PlaybackController } from './timeline-playback.js'; + // Constants const POST_PROCESS_EFFECTS = new Set(['FadeEffect', 'FlashEffect', 'GaussianBlurEffect', 'SolarizeEffect', 'VignetteEffect', 'ChromaAberrationEffect', 'DistortEffect', 'ThemeModulationEffect', 'CNNEffect', 'CNNv2Effect']); + const SEQUENCE_GAP = 10; + const SEQUENCE_DEFAULT_WIDTH = 10; + const SEQUENCE_DEFAULT_DURATION = 16; + const SEQUENCE_MIN_HEIGHT = 70; + const SEQUENCE_COLLAPSED_HEIGHT = 35; + const SEQUENCE_TOP_PADDING = 20; + const SEQUENCE_BOTTOM_PADDING = 5; + const EFFECT_SPACING = 30; + const EFFECT_HEIGHT = 26; + + // BPM computation helper + const computeBPMValues = (bpm) => ({ + secondsPerBeat: 60.0 / bpm, + beatsPerSecond: bpm / 60.0 + }); + // State + const DEFAULT_BPM = 120; const state = { - sequences: [], currentFile: null, selectedItem: null, pixelsPerSecond: 100, - showBeats: true, quantizeUnit: 1, bpm: 120, isDragging: false, dragOffset: { x: 0, y: 0 }, + sequences: [], currentFile: null, selectedItem: null, pixelsPerBeat: 100, + showBeats: true, quantizeUnit: 1, bpm: DEFAULT_BPM, 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 + audioBuffer: null, audioDurationSeconds: 0, audioSource: null, audioContext: null, + isPlaying: false, playbackStartTime: 0, playbackOffset: 0, playStartPosition: 0, animationFrameId: null, + lastExpandedSeqIndex: -1, dragMoved: false, + ...computeBPMValues(DEFAULT_BPM) }; // DOM const dom = { timeline: document.getElementById('timeline'), + timelineContainer: document.getElementById('timelineContainer'), timelineContent: document.getElementById('timelineContent'), fileInput: document.getElementById('fileInput'), saveBtn: document.getElementById('saveBtn'), @@ -215,15 +600,17 @@ stats: document.getElementById('stats'), playbackControls: document.getElementById('playbackControls'), playPauseBtn: document.getElementById('playPauseBtn'), + replayBtn: document.getElementById('replayBtn'), 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') + quantizeSelect: document.getElementById('quantizeSelect'), + waveformCursor: document.getElementById('waveformCursor'), + waveformTooltip: document.getElementById('waveformTooltip') }; // Parser @@ -232,7 +619,7 @@ let currentSequence = null, bpm = 120, currentPriority = 0; const parseTime = (timeStr) => { - if (timeStr.endsWith('s')) return parseFloat(timeStr.slice(0, -1)) * bpm / 60.0; + if (timeStr.endsWith('s')) return parseFloat(timeStr.slice(0, -1)) * bpm / 60.0; // Local bpm during parsing if (timeStr.endsWith('b')) return parseFloat(timeStr.slice(0, -1)); return parseFloat(timeStr); }; @@ -278,14 +665,25 @@ } // Helpers - const beatsToTime = (beats) => beats * 60.0 / state.bpm; - const timeToBeats = (seconds) => seconds * state.bpm / 60.0; + const updateBPM = (newBPM) => { + state.bpm = newBPM; + Object.assign(state, computeBPMValues(newBPM)); + }; + const beatsToTime = (beats) => beats * state.secondsPerBeat; + const timeToBeats = (seconds) => seconds * state.beatsPerSecond; 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)`; }; + // 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 detectConflicts(seq) { const conflicts = new Set(); const priorityGroups = {}; @@ -319,61 +717,16 @@ return output; } - // Audio - async function loadAudioFile(file) { - try { - const arrayBuffer = await file.arrayBuffer(); - if (!state.audioContext) state.audioContext = new (window.AudioContext || window.webkitAudioContext)(); - state.audioBuffer = await state.audioContext.decodeAudioData(arrayBuffer); - state.audioDuration = state.audioBuffer.duration; - renderWaveform(); - 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'); - } - } - - function renderWaveform() { - 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); - - const channelData = state.audioBuffer.getChannelData(0); - const samplesPerPixel = Math.ceil(channelData.length / w); - const centerY = h / 2, amplitudeScale = h * 0.4; - - 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(); - ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; - ctx.beginPath(); ctx.moveTo(0, centerY); ctx.lineTo(w, centerY); ctx.stroke(); - } + // Controllers - initialized after DOM setup + let viewportController, playbackController; function computeCPULoad() { - if (state.sequences.length === 0) return { maxTime: 60, loads: [], conflicts: [] }; - let maxTime = Math.max(60, ...state.sequences.flatMap(seq => + if (state.sequences.length === 0) return { maxTimeBeats: 60, loads: [], conflicts: [] }; + let maxTimeBeats = 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)); + if (state.audioDurationSeconds > 0) maxTimeBeats = Math.max(maxTimeBeats, timeToBeats(state.audioDurationSeconds)); - const resolution = 0.1, numSamples = Math.ceil(maxTime / resolution); + const resolution = 0.1, numSamples = Math.ceil(maxTimeBeats / resolution); const loads = new Array(numSamples).fill(0); const conflicts = new Array(numSamples).fill(false); @@ -424,19 +777,19 @@ }); }); - return { maxTime, loads, conflicts, resolution }; + return { maxTimeBeats, loads, conflicts, resolution }; } function renderCPULoad() { const canvas = dom.cpuLoadCanvas, ctx = canvas.getContext('2d'); - const { maxTime, loads, conflicts, resolution } = computeCPULoad(); - const w = maxTime * state.pixelsPerSecond, h = 10; + const { maxTimeBeats, loads, conflicts, resolution } = computeCPULoad(); + const w = maxTimeBeats * state.pixelsPerBeat, 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; - const barWidth = resolution * state.pixelsPerSecond; + const barWidth = resolution * state.pixelsPerBeat; loads.forEach((load, i) => { if (load === 0) return; const n = Math.min(load / 8, 1.0); @@ -449,123 +802,52 @@ }); } - 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'); - } - - 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; - } - } - - 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'; - } - - 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); - state.animationFrameId = requestAnimationFrame(updatePlaybackPosition); - } - - function expandSequenceAtTime(currentBeats) { - let activeSeqIndex = -1; - 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; } - } - if (activeSeqIndex !== state.lastExpandedSeqIndex) { - const seqDivs = dom.timeline.querySelectorAll('.sequence'); - if (state.lastExpandedSeqIndex >= 0 && seqDivs[state.lastExpandedSeqIndex]) { - seqDivs[state.lastExpandedSeqIndex].classList.remove('active-playing'); - } - if (activeSeqIndex >= 0 && seqDivs[activeSeqIndex]) { - seqDivs[activeSeqIndex].classList.add('active-playing'); - } - state.lastExpandedSeqIndex = activeSeqIndex; - } - } - // Render function renderTimeline() { renderCPULoad(); dom.timeline.innerHTML = ''; document.getElementById('timeMarkers').innerHTML = ''; - let maxTime = 60; + let maxTimeBeats = 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); + maxTimeBeats = Math.max(maxTimeBeats, seq.startTime + SEQUENCE_DEFAULT_DURATION); + for (const effect of seq.effects) maxTimeBeats = Math.max(maxTimeBeats, seq.startTime + effect.endTime); } - if (state.audioDuration > 0) maxTime = Math.max(maxTime, state.audioDuration * state.bpm / 60.0); - const timelineWidth = maxTime * state.pixelsPerSecond; + if (state.audioDurationSeconds > 0) maxTimeBeats = Math.max(maxTimeBeats, state.audioDurationSeconds * state.beatsPerSecond); + const timelineWidth = maxTimeBeats * state.pixelsPerBeat; 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) { + for (let beat = 0; beat <= maxTimeBeats; beat += 4) { const marker = document.createElement('div'); - marker.className = 'time-marker'; marker.style.left = `${beat * state.pixelsPerSecond}px`; + marker.className = 'time-marker'; marker.style.left = `${beat * state.pixelsPerBeat}px`; marker.textContent = `${beat}b`; timeMarkers.appendChild(marker); } } else { - const maxSeconds = maxTime * 60.0 / state.bpm; + const maxSeconds = maxTimeBeats * state.secondsPerBeat; for (let t = 0; t <= maxSeconds; t += 1) { - const beatPos = t * state.bpm / 60.0, marker = document.createElement('div'); - marker.className = 'time-marker'; marker.style.left = `${beatPos * state.pixelsPerSecond}px`; + const beatPos = t * state.beatsPerSecond, marker = document.createElement('div'); + marker.className = 'time-marker'; marker.style.left = `${beatPos * state.pixelsPerBeat}px`; marker.textContent = `${t}s`; timeMarkers.appendChild(marker); } } - let cumulativeY = 0, sequenceGap = 10; + let cumulativeY = 0; state.sequences.forEach((seq, seqIndex) => { const seqDiv = document.createElement('div'); seqDiv.className = 'sequence'; seqDiv.dataset.index = seqIndex; - let seqVisualStart = seq.startTime, seqVisualEnd = seq.startTime + 10; + let seqVisualStart = seq.startTime, seqVisualEnd = seq.startTime + SEQUENCE_DEFAULT_WIDTH; if (seq.effects.length > 0) { seqVisualStart = seq.startTime + Math.min(...seq.effects.map(e => e.startTime)); seqVisualEnd = seq.startTime + Math.max(...seq.effects.map(e => e.endTime)); } 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 * state.pixelsPerSecond}px`; + const numEffects = seq.effects.length; + const fullHeight = Math.max(SEQUENCE_MIN_HEIGHT, SEQUENCE_TOP_PADDING + numEffects * EFFECT_SPACING + SEQUENCE_BOTTOM_PADDING); + const seqHeight = seq._collapsed ? SEQUENCE_COLLAPSED_HEIGHT : fullHeight; + seqDiv.style.left = `${seqVisualStart * state.pixelsPerBeat}px`; seqDiv.style.top = `${cumulativeY}px`; - seqDiv.style.width = `${(seqVisualEnd - seqVisualStart) * state.pixelsPerSecond}px`; + seqDiv.style.width = `${(seqVisualEnd - seqVisualStart) * state.pixelsPerBeat}px`; seqDiv.style.height = `${seqHeight}px`; seqDiv.style.minHeight = `${seqHeight}px`; seqDiv.style.maxHeight = `${seqHeight}px`; - seq._yPosition = cumulativeY; cumulativeY += seqHeight + sequenceGap; totalTimelineHeight = cumulativeY; + seq._yPosition = cumulativeY; cumulativeY += seqHeight + SEQUENCE_GAP; 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}`; @@ -590,10 +872,10 @@ 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' + left: `${(seq.startTime + effect.startTime) * state.pixelsPerBeat}px`, + top: `${seq._yPosition + SEQUENCE_TOP_PADDING + effectIndex * EFFECT_SPACING}px`, + width: `${(effect.endTime - effect.startTime) * state.pixelsPerBeat}px`, + height: `${EFFECT_HEIGHT}px` }); effectDiv.innerHTML = `<div class="effect-handle left"></div><small>${effect.className}</small><div class="effect-handle right"></div>`; const conflictWarning = conflicts.has(effectIndex) ? @@ -616,7 +898,6 @@ } }); 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(); } @@ -636,13 +917,13 @@ 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; + let newTimeBeats = Math.max(0, (e.clientX - containerRect.left + dom.timelineContent.scrollLeft - state.dragOffset.x) / state.pixelsPerBeat); + if (state.quantizeUnit > 0) newTimeBeats = Math.round(newTimeBeats * state.quantizeUnit) / state.quantizeUnit; + if (state.selectedItem.type === 'sequence') state.sequences[state.selectedItem.index].startTime = newTimeBeats; 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; + const durationBeats = effect.endTime - effect.startTime, relativeTimeBeats = newTimeBeats - seq.startTime; + effect.startTime = relativeTimeBeats; effect.endTime = effect.startTime + durationBeats; } renderTimeline(); updateProperties(); } @@ -660,7 +941,7 @@ 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 mouseTimeBeats = (e.clientX - containerRect.left + dom.timelineContent.scrollLeft) / state.pixelsPerBeat; const handleTimeBeats = seq.startTime + (type === 'left' ? effect.startTime : effect.endTime); state.handleDragOffset = handleTimeBeats - mouseTimeBeats; document.addEventListener('mousemove', onHandleDrag); document.addEventListener('mouseup', stopHandleDrag); @@ -669,13 +950,13 @@ function onHandleDrag(e) { 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; + let newTimeBeats = (e.clientX - containerRect.left + dom.timelineContent.scrollLeft) / state.pixelsPerBeat + state.handleDragOffset; + newTimeBeats = Math.max(0, newTimeBeats); + if (state.quantizeUnit > 0) newTimeBeats = Math.round(newTimeBeats * state.quantizeUnit) / state.quantizeUnit; const seq = state.sequences[state.selectedItem.seqIndex], effect = seq.effects[state.selectedItem.effectIndex]; - const relativeTime = newTime - seq.startTime; - 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); + const relativeTimeBeats = newTimeBeats - seq.startTime; + if (state.handleType === 'left') effect.startTime = Math.min(relativeTimeBeats, effect.endTime - 0.1); + else if (state.handleType === 'right') effect.endTime = Math.max(effect.startTime + 0.1, relativeTimeBeats); renderTimeline(); updateProperties(); } @@ -713,8 +994,8 @@ const samePriority = effect.priorityModifier === '='; 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>Start Time (beats, relative to sequence)</label><input type="number" id="propStartTime" value="${effect.startTime}" step="0.1"></div> + <div class="property-group"><label>End Time (beats, 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;"> @@ -777,18 +1058,11 @@ updateProperties(); } - // 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 => + const maxTimeBeats = 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`; + dom.stats.innerHTML = `📊 Sequences: ${state.sequences.length} | 🎬 Effects: ${effectCount} | ⏱️ Duration: ${maxTimeBeats.toFixed(2)}b (${beatsToTime(maxTimeBeats).toFixed(2)}s)`; } async function loadFromURLParams() { @@ -799,19 +1073,22 @@ 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.sequences = parsed.sequences; + updateBPM(parsed.bpm); + dom.currentBPM.value = state.bpm; dom.bpmSlider.value = state.bpm; state.currentFile = seqURL.split('/').pop(); + state.playbackOffset = 0; renderTimeline(); dom.saveBtn.disabled = false; dom.addSequenceBtn.disabled = false; dom.reorderBtn.disabled = false; + if (viewportController) viewportController.updateIndicatorPosition(0, false); showMessage(`Loaded ${state.currentFile} from URL`, 'success'); } catch (err) { showMessage(`Error loading seq file: ${err.message}`, 'error'); } } - if (wavURL) { + if (wavURL && playbackController) { 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); + await playbackController.loadAudioFile(file); } catch (err) { showMessage(`Error loading audio file: ${err.message}`, 'error'); } } } @@ -825,9 +1102,12 @@ reader.onload = e => { try { const parsed = parseSeqFile(e.target.result); - state.sequences = parsed.sequences; state.bpm = parsed.bpm; - dom.currentBPM.textContent = state.bpm; dom.bpmSlider.value = state.bpm; + state.sequences = parsed.sequences; + updateBPM(parsed.bpm); + dom.currentBPM.value = state.bpm; dom.bpmSlider.value = state.bpm; + state.playbackOffset = 0; renderTimeline(); dom.saveBtn.disabled = false; dom.addSequenceBtn.disabled = false; dom.reorderBtn.disabled = false; + if (viewportController) viewportController.updateIndicatorPosition(0, false); showMessage(`Loaded ${state.currentFile} - ${state.sequences.length} sequences`, 'success'); } catch (err) { showMessage(`Error parsing file: ${err.message}`, 'error'); } }; @@ -841,29 +1121,7 @@ showMessage('File saved', 'success'); }); - 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(); } - }); - - 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(); - }); + // Audio/playback event handlers - managed by PlaybackController dom.addSequenceBtn.addEventListener('click', () => { state.sequences.push({ type: 'sequence', startTime: 0, priority: 0, effects: [], _collapsed: true }); @@ -898,14 +1156,27 @@ showMessage('Sequences re-ordered by start time', 'success'); }); - dom.zoomSlider.addEventListener('input', e => { - state.pixelsPerSecond = parseInt(e.target.value); dom.zoomLevel.textContent = `${state.pixelsPerSecond}%`; - if (state.audioBuffer) renderWaveform(); renderTimeline(); - }); + // Zoom handler - managed by ViewportController dom.bpmSlider.addEventListener('input', e => { - state.bpm = parseInt(e.target.value); dom.currentBPM.textContent = state.bpm; - if (state.audioBuffer) renderWaveform(); renderTimeline(); + updateBPM(parseInt(e.target.value)); + dom.currentBPM.value = state.bpm; + if (state.audioBuffer && playbackController) playbackController.renderWaveform(); + renderTimeline(); + if (viewportController) viewportController.updateIndicatorPosition(timeToBeats(state.playbackOffset), false); + }); + + dom.currentBPM.addEventListener('change', e => { + const bpm = parseInt(e.target.value); + if (!isNaN(bpm) && bpm >= 60 && bpm <= 200) { + updateBPM(bpm); + dom.bpmSlider.value = bpm; + if (state.audioBuffer && playbackController) playbackController.renderWaveform(); + renderTimeline(); + if (viewportController) viewportController.updateIndicatorPosition(timeToBeats(state.playbackOffset), false); + } else { + e.target.value = state.bpm; + } }); dom.showBeatsCheckbox.addEventListener('change', e => { state.showBeats = e.target.checked; renderTimeline(); }); @@ -914,76 +1185,46 @@ 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(); }); - dom.timeline.addEventListener('dblclick', async e => { + dom.timeline.addEventListener('dblclick', e => { if (e.target !== dom.timeline) return; + if (!playbackController || !state.audioBuffer) return; const containerRect = dom.timelineContent.getBoundingClientRect(); - const clickX = e.clientX - containerRect.left + dom.timelineContent.scrollLeft; - const clickBeats = clickX / state.pixelsPerSecond; + const clickX = e.clientX - containerRect.left + dom.timelineContent.scrollLeft - viewportController.TIMELINE_LEFT_PADDING; + const clickBeats = clickX / state.pixelsPerBeat; 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'); - } + const result = playbackController.seekTo(clickBeats, clickTime); + if (result) showMessage(`Seek to ${result.clickTime.toFixed(2)}s (${result.clickBeats.toFixed(2)}b)`, 'success'); }); document.addEventListener('keydown', e => { - if (e.code === 'Space' && state.audioBuffer) { e.preventDefault(); dom.playPauseBtn.click(); } + const isTyping = document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA'; + if (e.code === 'Space' && state.audioBuffer && !isTyping) { 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(); + if (!isTyping) { + 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(); + } } }); - 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)`; - }); + // Scroll/wheel handlers - managed by ViewportController - dom.timelineContent.addEventListener('wheel', e => { - e.preventDefault(); - if (e.ctrlKey || e.metaKey) { - 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) { - 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; - } - 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 < state.sequences.length; i++) { - if (state.sequences[i].startTime <= currentTime) targetSeqIndex = i; else break; - } - 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'); - setTimeout(() => seqDivs[targetSeqIndex]?.classList.remove('active-flash'), 600); - } + // Initialize controllers + const renderCallback = (trigger) => { + if (trigger === 'zoom' || trigger === 'zoomWheel') { + if (state.audioBuffer && playbackController) playbackController.renderWaveform(); + renderTimeline(); + if (viewportController) viewportController.updateIndicatorPosition(timeToBeats(state.playbackOffset), false); + } else { + renderTimeline(); } - 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 }); + }; + + viewportController = new ViewportController(state, dom, renderCallback); + playbackController = new PlaybackController(state, dom, viewportController, renderCallback, showMessage); window.addEventListener('resize', renderTimeline); renderTimeline(); loadFromURLParams(); diff --git a/tools/timeline_editor/timeline-playback.js b/tools/timeline_editor/timeline-playback.js new file mode 100644 index 0000000..a1c50ab --- /dev/null +++ b/tools/timeline_editor/timeline-playback.js @@ -0,0 +1,322 @@ +// timeline-playback.js - Audio playback and waveform rendering + +export class PlaybackController { + constructor(state, dom, viewportController, renderCallback, showMessage) { + this.state = state; + this.dom = dom; + this.viewport = viewportController; + this.renderCallback = renderCallback; + this.showMessage = showMessage; + + // Constants + this.WAVEFORM_AMPLITUDE_SCALE = 0.4; + this.SEQUENCE_DEFAULT_DURATION = 16; + + this.init(); + } + + init() { + this.dom.audioInput.addEventListener('change', e => { + const file = e.target.files[0]; + if (file) this.loadAudioFile(file); + }); + + this.dom.clearAudioBtn.addEventListener('click', () => { + this.clearAudio(); + this.dom.audioInput.value = ''; + }); + + this.dom.playPauseBtn.addEventListener('click', async () => { + if (this.state.isPlaying) this.stopPlayback(); + else { + if (this.state.playbackOffset >= this.state.audioDurationSeconds) { + this.state.playbackOffset = 0; + } + this.state.playStartPosition = this.state.playbackOffset; + await this.startPlayback(); + } + }); + + this.dom.replayBtn.addEventListener('click', async () => { + this.stopPlayback(false); + this.state.playbackOffset = this.state.playStartPosition; + const replayBeats = this.timeToBeats(this.state.playbackOffset); + this.dom.playbackTime.textContent = `${this.state.playbackOffset.toFixed(2)}s (${replayBeats.toFixed(2)}b)`; + this.viewport.updateIndicatorPosition(replayBeats, false); + await this.startPlayback(); + }); + + this.dom.waveformContainer.addEventListener('click', async e => { + if (!this.state.audioBuffer) return; + const rect = this.dom.waveformContainer.getBoundingClientRect(); + const canvasOffset = parseFloat(this.dom.waveformCanvas.style.left) || 0; + const clickX = e.clientX - rect.left - canvasOffset; + const clickBeats = clickX / this.state.pixelsPerBeat; + const clickTime = this.beatsToTime(clickBeats); + + const wasPlaying = this.state.isPlaying; + if (wasPlaying) this.stopPlayback(false); + this.state.playbackOffset = Math.max(0, Math.min(clickTime, this.state.audioDurationSeconds)); + const pausedBeats = this.timeToBeats(this.state.playbackOffset); + this.dom.playbackTime.textContent = `${this.state.playbackOffset.toFixed(2)}s (${pausedBeats.toFixed(2)}b)`; + this.viewport.updateIndicatorPosition(pausedBeats, false); + if (wasPlaying) await this.startPlayback(); + }); + } + + async loadAudioFile(file) { + try { + const arrayBuffer = await file.arrayBuffer(); + + // Detect original WAV sample rate before decoding + const dataView = new DataView(arrayBuffer); + let originalSampleRate = 32000; // Default assumption + + // Parse WAV header to get original sample rate + // "RIFF" at 0, "WAVE" at 8, "fmt " at 12, sample rate at 24 + if (dataView.getUint32(0, false) === 0x52494646 && // "RIFF" + dataView.getUint32(8, false) === 0x57415645) { // "WAVE" + originalSampleRate = dataView.getUint32(24, true); // Little-endian + console.log(`Detected WAV sample rate: ${originalSampleRate}Hz`); + } + + if (!this.state.audioContext) { + this.state.audioContext = new (window.AudioContext || window.webkitAudioContext)(); + } + + this.state.audioBuffer = await this.state.audioContext.decodeAudioData(arrayBuffer); + this.state.audioDurationSeconds = this.state.audioBuffer.duration; + this.state.originalSampleRate = originalSampleRate; + this.state.resampleRatio = this.state.audioContext.sampleRate / originalSampleRate; + + console.log(`AudioContext rate: ${this.state.audioContext.sampleRate}Hz, resample ratio: ${this.state.resampleRatio.toFixed(3)}x`); + + this.renderWaveform(); + this.dom.playbackControls.style.display = 'flex'; + this.dom.playbackIndicator.style.display = 'block'; + this.dom.clearAudioBtn.disabled = false; + this.dom.replayBtn.disabled = false; + this.showMessage(`Audio loaded: ${this.state.audioDurationSeconds.toFixed(2)}s @ ${originalSampleRate}Hz`, 'success'); + this.renderCallback('audioLoaded'); + } catch (err) { + this.showMessage(`Error loading audio: ${err.message}`, 'error'); + } + } + + renderWaveform() { + if (!this.state.audioBuffer) return; + const canvas = this.dom.waveformCanvas; + const ctx = canvas.getContext('2d'); + + // Calculate maxTimeBeats same as timeline + let maxTimeBeats = 60; + for (const seq of this.state.sequences) { + maxTimeBeats = Math.max(maxTimeBeats, seq.startTime + this.SEQUENCE_DEFAULT_DURATION); + for (const effect of seq.effects) { + maxTimeBeats = Math.max(maxTimeBeats, seq.startTime + effect.endTime); + } + } + if (this.state.audioDurationSeconds > 0) { + maxTimeBeats = Math.max(maxTimeBeats, this.state.audioDurationSeconds * this.state.beatsPerSecond); + } + + const w = maxTimeBeats * this.state.pixelsPerBeat; + const h = 80; + 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); + + const channelData = this.state.audioBuffer.getChannelData(0); + const audioBeats = this.timeToBeats(this.state.audioDurationSeconds); + const audioPixelWidth = audioBeats * this.state.pixelsPerBeat; + const samplesPerPixel = Math.ceil(channelData.length / audioPixelWidth); + const centerY = h / 2; + const amplitudeScale = h * this.WAVEFORM_AMPLITUDE_SCALE; + + ctx.strokeStyle = '#4ec9b0'; + ctx.lineWidth = 1; + ctx.beginPath(); + + for (let x = 0; x < audioPixelWidth; 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; + const yMax = centerY - max * amplitudeScale; + + if (x === 0) ctx.moveTo(x, yMin); + else ctx.lineTo(x, yMin); + ctx.lineTo(x, yMax); + } + ctx.stroke(); + + // Center line + ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; + ctx.beginPath(); + ctx.moveTo(0, centerY); + ctx.lineTo(audioPixelWidth, centerY); + ctx.stroke(); + + // Beat markers + ctx.strokeStyle = 'rgba(255, 255, 255, 0.50)'; + ctx.lineWidth = 1; + for (let beat = 0; beat <= maxTimeBeats; beat++) { + const x = beat * this.state.pixelsPerBeat; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + } + } + + clearAudio() { + this.stopPlayback(); + this.state.audioBuffer = null; + this.state.audioDurationSeconds = 0; + this.state.playbackOffset = 0; + this.state.playStartPosition = 0; + + this.dom.playbackControls.style.display = 'none'; + this.dom.playbackIndicator.style.display = 'none'; + this.dom.clearAudioBtn.disabled = true; + this.dom.replayBtn.disabled = true; + + const ctx = this.dom.waveformCanvas.getContext('2d'); + ctx.clearRect(0, 0, this.dom.waveformCanvas.width, this.dom.waveformCanvas.height); + + this.renderCallback('audioClear'); + this.showMessage('Audio cleared', 'success'); + } + + async startPlayback() { + if (!this.state.audioBuffer || !this.state.audioContext) return; + + if (this.state.audioSource) { + try { this.state.audioSource.stop(); } catch (e) {} + this.state.audioSource = null; + } + + if (this.state.audioContext.state === 'suspended') { + await this.state.audioContext.resume(); + } + + try { + this.state.audioSource = this.state.audioContext.createBufferSource(); + this.state.audioSource.buffer = this.state.audioBuffer; + this.state.audioSource.connect(this.state.audioContext.destination); + this.state.audioSource.start(0, this.state.playbackOffset); + this.state.playbackStartTime = this.state.audioContext.currentTime; + this.state.isPlaying = true; + this.dom.playPauseBtn.textContent = '⏸ Pause'; + + this.updatePlaybackPosition(); + + this.state.audioSource.onended = () => { + if (this.state.isPlaying) this.stopPlayback(); + }; + } catch (e) { + console.error('Failed to start playback:', e); + this.showMessage('Playback failed: ' + e.message, 'error'); + this.state.audioSource = null; + this.state.isPlaying = false; + } + } + + stopPlayback(savePosition = true) { + if (this.state.audioSource) { + try { this.state.audioSource.stop(); } catch (e) {} + this.state.audioSource = null; + } + + if (this.state.animationFrameId) { + cancelAnimationFrame(this.state.animationFrameId); + this.state.animationFrameId = null; + } + + if (this.state.isPlaying && savePosition) { + const elapsed = this.state.audioContext.currentTime - this.state.playbackStartTime; + this.state.playbackOffset = Math.min(this.state.playbackOffset + elapsed, this.state.audioDurationSeconds); + } + + this.state.isPlaying = false; + this.dom.playPauseBtn.textContent = '▶ Play'; + } + + updatePlaybackPosition() { + if (!this.state.isPlaying) return; + + const elapsed = this.state.audioContext.currentTime - this.state.playbackStartTime; + const currentTime = this.state.playbackOffset + elapsed; + const currentBeats = this.timeToBeats(currentTime); + + this.dom.playbackTime.textContent = `${currentTime.toFixed(2)}s (${currentBeats.toFixed(2)}b)`; + this.viewport.updateIndicatorPosition(currentBeats, true); + this.expandSequenceAtTime(currentBeats); + + this.state.animationFrameId = requestAnimationFrame(() => this.updatePlaybackPosition()); + } + + expandSequenceAtTime(currentBeats) { + let activeSeqIndex = -1; + + for (let i = 0; i < this.state.sequences.length; i++) { + const seq = this.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; + } + } + + if (activeSeqIndex !== this.state.lastExpandedSeqIndex) { + const seqDivs = this.dom.timeline.querySelectorAll('.sequence'); + + if (this.state.lastExpandedSeqIndex >= 0 && seqDivs[this.state.lastExpandedSeqIndex]) { + seqDivs[this.state.lastExpandedSeqIndex].classList.remove('active-playing'); + } + + if (activeSeqIndex >= 0 && seqDivs[activeSeqIndex]) { + seqDivs[activeSeqIndex].classList.add('active-playing'); + } + + this.state.lastExpandedSeqIndex = activeSeqIndex; + } + } + + seekTo(clickBeats, clickTime) { + if (!this.state.audioBuffer) return; + + const wasPlaying = this.state.isPlaying; + if (wasPlaying) this.stopPlayback(false); + + this.state.playbackOffset = Math.max(0, Math.min(clickTime, this.state.audioDurationSeconds)); + const pausedBeats = this.timeToBeats(this.state.playbackOffset); + this.dom.playbackTime.textContent = `${this.state.playbackOffset.toFixed(2)}s (${pausedBeats.toFixed(2)}b)`; + this.viewport.updateIndicatorPosition(pausedBeats, false); + + if (wasPlaying) this.startPlayback(); + + return { clickTime, clickBeats }; + } + + // Helpers + beatsToTime(beats) { + return beats * this.state.secondsPerBeat; + } + + timeToBeats(seconds) { + return seconds * this.state.beatsPerSecond; + } +} diff --git a/tools/timeline_editor/timeline-viewport.js b/tools/timeline_editor/timeline-viewport.js new file mode 100644 index 0000000..dcedb45 --- /dev/null +++ b/tools/timeline_editor/timeline-viewport.js @@ -0,0 +1,170 @@ +// timeline-viewport.js - Viewport zoom/scroll control + +export class ViewportController { + constructor(state, dom, renderCallback) { + this.state = state; + this.dom = dom; + this.renderCallback = renderCallback; + + // Constants + this.TIMELINE_LEFT_PADDING = 20; + this.SCROLL_VIEWPORT_FRACTION = 0.4; + this.SMOOTH_SCROLL_SPEED = 0.1; + this.VERTICAL_SCROLL_SPEED = 0.3; + + this.init(); + } + + init() { + // Zoom controls + this.dom.zoomSlider.addEventListener('input', e => this.handleZoomSlider(e)); + + // Scroll sync + this.dom.timelineContent.addEventListener('scroll', () => this.handleScroll()); + + // Wheel handling - capture at container level to override all child elements + const wheelHandler = e => this.handleWheel(e); + this.dom.timelineContainer.addEventListener('wheel', wheelHandler, { passive: false, capture: true }); + + // Prevent wheel bubbling from UI containers outside timeline + document.querySelector('header').addEventListener('wheel', e => e.stopPropagation()); + this.dom.propertiesPanel.addEventListener('wheel', e => e.stopPropagation()); + document.querySelector('.zoom-controls').addEventListener('wheel', e => e.stopPropagation()); + document.querySelector('.stats').addEventListener('wheel', e => e.stopPropagation()); + + // Waveform hover tracking + this.dom.waveformContainer.addEventListener('mouseenter', () => this.showWaveformCursor()); + this.dom.waveformContainer.addEventListener('mouseleave', () => this.hideWaveformCursor()); + this.dom.waveformContainer.addEventListener('mousemove', e => this.updateWaveformCursor(e)); + } + + handleZoomSlider(e) { + this.state.pixelsPerBeat = parseInt(e.target.value); + this.dom.zoomLevel.textContent = `${this.state.pixelsPerBeat}%`; + this.renderCallback('zoom'); + } + + handleScroll() { + const scrollLeft = this.dom.timelineContent.scrollLeft; + this.dom.cpuLoadCanvas.style.left = `-${scrollLeft}px`; + this.dom.waveformCanvas.style.left = `-${scrollLeft}px`; + document.getElementById('timeMarkers').style.transform = `translateX(-${scrollLeft}px)`; + this.updateIndicatorPosition(this.timeToBeats(this.state.playbackOffset), false); + } + + handleWheel(e) { + e.preventDefault(); + + // Zoom with ctrl/cmd + if (e.ctrlKey || e.metaKey) { + this.handleZoomWheel(e); + return; + } + + // Horizontal scroll + this.dom.timelineContent.scrollLeft += e.deltaY; + + // Auto-scroll to active sequence + this.autoScrollToSequence(); + } + + handleZoomWheel(e) { + const rect = this.dom.timelineContent.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const scrollLeft = this.dom.timelineContent.scrollLeft; + const timeUnderCursor = (scrollLeft + mouseX) / this.state.pixelsPerBeat; + + const zoomDelta = e.deltaY > 0 ? -10 : 10; + const newPixelsPerBeat = Math.max(10, Math.min(500, this.state.pixelsPerBeat + zoomDelta)); + + if (newPixelsPerBeat !== this.state.pixelsPerBeat) { + this.state.pixelsPerBeat = newPixelsPerBeat; + this.dom.zoomSlider.value = this.state.pixelsPerBeat; + this.dom.zoomLevel.textContent = `${this.state.pixelsPerBeat}%`; + this.renderCallback('zoomWheel'); + this.dom.timelineContent.scrollLeft = timeUnderCursor * newPixelsPerBeat - mouseX; + this.updateIndicatorPosition(this.timeToBeats(this.state.playbackOffset), false); + } + } + + autoScrollToSequence() { + const currentScrollLeft = this.dom.timelineContent.scrollLeft; + const viewportWidth = this.dom.timelineContent.clientWidth; + const slack = (viewportWidth / this.state.pixelsPerBeat) * 0.1; + const currentTime = (currentScrollLeft / this.state.pixelsPerBeat) + slack; + + let targetSeqIndex = 0; + for (let i = 0; i < this.state.sequences.length; i++) { + if (this.state.sequences[i].startTime <= currentTime) targetSeqIndex = i; + else break; + } + + if (targetSeqIndex !== this.state.lastActiveSeqIndex && this.state.sequences.length > 0) { + this.state.lastActiveSeqIndex = targetSeqIndex; + const seqDivs = this.dom.timeline.querySelectorAll('.sequence'); + if (seqDivs[targetSeqIndex]) { + seqDivs[targetSeqIndex].classList.add('active-flash'); + setTimeout(() => seqDivs[targetSeqIndex]?.classList.remove('active-flash'), 600); + } + } + + const targetScrollTop = this.state.sequences[targetSeqIndex]?._yPosition || 0; + const currentScrollTop = this.dom.timelineContent.scrollTop; + const scrollDiff = targetScrollTop - currentScrollTop; + if (Math.abs(scrollDiff) > 5) { + this.dom.timelineContent.scrollTop += scrollDiff * this.VERTICAL_SCROLL_SPEED; + } + } + + updateIndicatorPosition(beats, smoothScroll = false) { + const timelineX = beats * this.state.pixelsPerBeat; + const scrollLeft = this.dom.timelineContent.scrollLeft; + this.dom.playbackIndicator.style.left = `${timelineX - scrollLeft + this.TIMELINE_LEFT_PADDING}px`; + + if (smoothScroll) { + const targetScroll = timelineX - this.dom.timelineContent.clientWidth * this.SCROLL_VIEWPORT_FRACTION; + const scrollDiff = targetScroll - scrollLeft; + if (Math.abs(scrollDiff) > 5) { + this.dom.timelineContent.scrollLeft += scrollDiff * this.SMOOTH_SCROLL_SPEED; + } + } + } + + showWaveformCursor() { + if (!this.state.audioBuffer) return; + this.dom.waveformCursor.style.display = 'block'; + this.dom.waveformTooltip.style.display = 'block'; + } + + hideWaveformCursor() { + this.dom.waveformCursor.style.display = 'none'; + this.dom.waveformTooltip.style.display = 'none'; + } + + updateWaveformCursor(e) { + if (!this.state.audioBuffer) return; + const rect = this.dom.waveformContainer.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const scrollLeft = this.dom.timelineContent.scrollLeft; + const timeBeats = (scrollLeft + mouseX) / this.state.pixelsPerBeat; + const timeSeconds = timeBeats * this.state.secondsPerBeat; + + // Position cursor + this.dom.waveformCursor.style.left = `${mouseX}px`; + + // Position and update tooltip + const tooltipText = `${timeSeconds.toFixed(3)}s (${timeBeats.toFixed(2)}b)`; + this.dom.waveformTooltip.textContent = tooltipText; + + // Position tooltip above cursor, offset to the right + const tooltipX = mouseX + 10; + const tooltipY = 5; + this.dom.waveformTooltip.style.left = `${tooltipX}px`; + this.dom.waveformTooltip.style.top = `${tooltipY}px`; + } + + // Helper + timeToBeats(seconds) { + return seconds * this.state.beatsPerSecond; + } +} diff --git a/tools/track_visualizer/index.html b/tools/track_visualizer/index.html index 4a613ec..d1e7480 100644 --- a/tools/track_visualizer/index.html +++ b/tools/track_visualizer/index.html @@ -4,18 +4,8 @@ <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Music Track Visualizer</title> + <link rel="stylesheet" href="../common/style.css"> <style> - * { - margin: 0; - padding: 0; - box-sizing: border-box; - } - body { - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - background: #1e1e1e; - color: #d4d4d4; - overflow: hidden; - } #controls { padding: 15px; background: #2d2d2d; @@ -27,16 +17,8 @@ } button, input[type="file"] { padding: 8px 16px; - background: #0e639c; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; font-size: 14px; } - button:hover { - background: #1177bb; - } input[type="file"] { padding: 6px 12px; } @@ -58,7 +40,6 @@ width: 100%; height: calc(100vh - 70px); overflow: auto; - background: #1e1e1e; } #timeline-canvas { display: block; |
