diff options
Diffstat (limited to 'tools/timeline_editor/index.html')
| -rw-r--r-- | tools/timeline_editor/index.html | 255 |
1 files changed, 203 insertions, 52 deletions
diff --git a/tools/timeline_editor/index.html b/tools/timeline_editor/index.html index c9385ad..b6e9223 100644 --- a/tools/timeline_editor/index.html +++ b/tools/timeline_editor/index.html @@ -105,7 +105,7 @@ .timeline-container { background: #252526; border-radius: 8px; - padding: 20px; + padding: 0; position: relative; height: calc(100vh - 280px); min-height: 500px; @@ -118,6 +118,7 @@ overflow-x: auto; overflow-y: auto; position: relative; + padding: 0 20px 20px 20px; /* Hide scrollbars while keeping scroll functionality */ scrollbar-width: none; /* Firefox */ -ms-overflow-style: none; /* IE/Edge */ @@ -134,10 +135,11 @@ } .sticky-header { - position: relative; + position: sticky; + top: 0; background: #252526; z-index: 100; - padding-bottom: 10px; + padding: 20px 20px 10px 20px; border-bottom: 2px solid #3c3c3c; flex-shrink: 0; } @@ -154,20 +156,27 @@ padding: 8px 12px; } - #waveformCanvas { + .waveform-container { position: relative; height: 80px; - width: 100%; + overflow: hidden; background: rgba(0, 0, 0, 0.3); border-radius: 4px; cursor: crosshair; } + #waveformCanvas { + position: absolute; + left: 0; + top: 0; + height: 80px; + display: block; + } + .playback-indicator { position: absolute; top: 0; width: 2px; - height: 100%; background: #f48771; box-shadow: 0 0 4px rgba(244, 135, 113, 0.8); pointer-events: none; @@ -476,12 +485,20 @@ color: #858585; } + #messageArea { + position: fixed; + top: 80px; + right: 20px; + z-index: 2000; + max-width: 400px; + } + .error { background: #5a1d1d; color: #f48771; padding: 10px; border-radius: 4px; - margin-bottom: 10px; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); } .success { @@ -489,7 +506,7 @@ color: #89d185; padding: 10px; border-radius: 4px; - margin-bottom: 10px; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); } </style> </head> @@ -519,6 +536,10 @@ <span id="zoomLevel">100%</span> <label style="margin-left: 20px">BPM: <input type="range" id="bpmSlider" min="60" max="200" value="120" step="1"></label> <span id="currentBPM">120</span> + <div id="playbackControls" style="display: none; margin-left: 20px;"> + <button id="playPauseBtn">▶ Play</button> + <span id="playbackTime">0.00s (0.00b)</span> + </div> <label class="checkbox-label" style="margin-left: 20px"> <input type="checkbox" id="showBeatsCheckbox" checked> Show Beats @@ -529,11 +550,10 @@ <div class="timeline-container"> <div class="sticky-header"> - <div class="playback-controls" id="playbackControls" style="display: none;"> - <button id="playPauseBtn">▶ Play</button> - <span id="playbackTime">0.00s</span> + <div class="waveform-container" id="waveformContainer" style="display: none;"> + <div class="playback-indicator" id="waveformPlaybackIndicator"></div> + <canvas id="waveformCanvas"></canvas> </div> - <canvas id="waveformCanvas" style="display: none;"></canvas> <div class="time-markers" id="timeMarkers"></div> </div> <div class="timeline-content" id="timelineContent"> @@ -587,6 +607,7 @@ const audioInput = document.getElementById('audioInput'); const clearAudioBtn = document.getElementById('clearAudioBtn'); const waveformCanvas = document.getElementById('waveformCanvas'); + const waveformContainer = document.getElementById('waveformContainer'); const addSequenceBtn = document.getElementById('addSequenceBtn'); const deleteBtn = document.getElementById('deleteBtn'); const reorderBtn = document.getElementById('reorderBtn'); @@ -600,6 +621,7 @@ const playbackControls = document.getElementById('playbackControls'); const playbackTime = document.getElementById('playbackTime'); const playbackIndicator = document.getElementById('playbackIndicator'); + const waveformPlaybackIndicator = document.getElementById('waveformPlaybackIndicator'); // Parser: timeline.seq → JavaScript objects // Format specification: doc/SEQUENCE.md @@ -740,7 +762,7 @@ audioDuration = audioBuffer.duration; renderWaveform(); - waveformCanvas.style.display = 'block'; + waveformContainer.style.display = 'block'; playbackControls.style.display = 'flex'; clearAudioBtn.disabled = false; showMessage(`Audio loaded: ${audioDuration.toFixed(2)}s`, 'success'); @@ -771,6 +793,9 @@ canvas.style.width = `${canvasWidth}px`; canvas.style.height = `${canvasHeight}px`; + // Set waveform playback indicator height + waveformPlaybackIndicator.style.height = `${canvasHeight}px`; + // Clear canvas ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; ctx.fillRect(0, 0, canvasWidth, canvasHeight); @@ -828,44 +853,70 @@ stopPlayback(); audioBuffer = null; audioDuration = 0; - waveformCanvas.style.display = 'none'; + waveformContainer.style.display = 'none'; playbackControls.style.display = 'none'; clearAudioBtn.disabled = true; renderTimeline(); showMessage('Audio cleared', 'success'); } + // Sync waveform scroll with timeline scroll + timelineContent.addEventListener('scroll', () => { + if (waveformCanvas) { + waveformCanvas.style.left = `-${timelineContent.scrollLeft}px`; + waveformPlaybackIndicator.style.transform = `translateX(-${timelineContent.scrollLeft}px)`; + } + }); + // Playback functions - function startPlayback() { + async function startPlayback() { if (!audioBuffer || !audioContext) return; + // Stop any existing source first + if (audioSource) { + try { + audioSource.stop(); + } catch (e) { + // Already stopped + } + audioSource = null; + } + // Resume audio context if suspended if (audioContext.state === 'suspended') { - audioContext.resume(); + await audioContext.resume(); } - // Create and start audio source - audioSource = audioContext.createBufferSource(); - audioSource.buffer = audioBuffer; - audioSource.connect(audioContext.destination); - audioSource.start(0, playbackOffset); + try { + // Create and start audio source + audioSource = audioContext.createBufferSource(); + audioSource.buffer = audioBuffer; + audioSource.connect(audioContext.destination); + audioSource.start(0, playbackOffset); - playbackStartTime = audioContext.currentTime; - isPlaying = true; - playPauseBtn.textContent = '⏸ Pause'; - playbackIndicator.classList.add('playing'); + playbackStartTime = audioContext.currentTime; + isPlaying = true; + playPauseBtn.textContent = '⏸ Pause'; + playbackIndicator.classList.add('playing'); + waveformPlaybackIndicator.classList.add('playing'); - // Start animation loop - updatePlaybackPosition(); + // Start animation loop + updatePlaybackPosition(); - audioSource.onended = () => { - if (isPlaying) { - stopPlayback(); - } - }; + audioSource.onended = () => { + if (isPlaying) { + stopPlayback(); + } + }; + } catch (e) { + console.error('Failed to start playback:', e); + showMessage('Playback failed: ' + e.message, 'error'); + audioSource = null; + isPlaying = false; + } } - function stopPlayback() { + function stopPlayback(savePosition = true) { if (audioSource) { try { audioSource.stop(); @@ -880,7 +931,7 @@ animationFrameId = null; } - if (isPlaying) { + if (isPlaying && savePosition) { // Save current position for resume const elapsed = audioContext.currentTime - playbackStartTime; playbackOffset = Math.min(playbackOffset + elapsed, audioDuration); @@ -889,6 +940,7 @@ isPlaying = false; playPauseBtn.textContent = '▶ Play'; playbackIndicator.classList.remove('playing'); + waveformPlaybackIndicator.classList.remove('playing'); } function updatePlaybackPosition() { @@ -896,16 +948,15 @@ const elapsed = audioContext.currentTime - playbackStartTime; const currentTime = playbackOffset + elapsed; + const currentBeats = currentTime * bpm / 60.0; // Update time display - playbackTime.textContent = `${currentTime.toFixed(2)}s`; - - // Convert to beats for position calculation - const currentBeats = currentTime * bpm / 60.0; + playbackTime.textContent = `${currentTime.toFixed(2)}s (${currentBeats.toFixed(2)}b)`; // Update playback indicator position const indicatorX = currentBeats * pixelsPerSecond; playbackIndicator.style.left = `${indicatorX}px`; + waveformPlaybackIndicator.style.left = `${indicatorX}px`; // Auto-scroll timeline to follow playback const viewportWidth = timelineContent.clientWidth; @@ -993,6 +1044,9 @@ const timelineWidth = maxTime * pixelsPerSecond; timeline.style.width = `${timelineWidth}px`; + // Track timeline height for playback indicator + let totalTimelineHeight = 0; + if (showBeats) { // Show beats (default) for (let beat = 0; beat <= maxTime; beat += 4) { @@ -1058,6 +1112,7 @@ // Store Y position for this sequence (used by effects and scroll) seq._yPosition = cumulativeY; cumulativeY += seqHeight + sequenceGap; + totalTimelineHeight = cumulativeY; // Create sequence header (double-click to collapse) const seqHeaderDiv = document.createElement('div'); @@ -1185,6 +1240,14 @@ } }); + // Set timeline minimum height to fit all sequences + timeline.style.minHeight = `${Math.max(totalTimelineHeight, timelineContent.offsetHeight)}px`; + + // Update playback indicator height to match + if (playbackIndicator) { + playbackIndicator.style.height = `${Math.max(totalTimelineHeight, timelineContent.offsetHeight)}px`; + } + updateStats(); } @@ -1475,7 +1538,7 @@ audioInput.value = ''; // Reset file input }); - playPauseBtn.addEventListener('click', () => { + playPauseBtn.addEventListener('click', async () => { if (isPlaying) { stopPlayback(); } else { @@ -1483,34 +1546,35 @@ if (playbackOffset >= audioDuration) { playbackOffset = 0; } - startPlayback(); + await startPlayback(); } }); // Waveform click to seek - waveformCanvas.addEventListener('click', (e) => { + waveformContainer.addEventListener('click', async (e) => { if (!audioBuffer) return; - const rect = waveformCanvas.getBoundingClientRect(); - const clickX = e.clientX - rect.left; - const audioDurationBeats = audioDuration * bpm / 60.0; - const clickBeats = (clickX / waveformCanvas.width) * audioDurationBeats; + const rect = waveformContainer.getBoundingClientRect(); + const clickX = e.clientX - rect.left + timelineContent.scrollLeft; + const clickBeats = clickX / pixelsPerSecond; const clickTime = clickBeats * 60.0 / bpm; const wasPlaying = isPlaying; if (wasPlaying) { - stopPlayback(); + stopPlayback(false); // Don't save position, we're jumping } playbackOffset = Math.max(0, Math.min(clickTime, audioDuration)); + // Update display and position + const clickBeats = playbackOffset * bpm / 60.0; + playbackTime.textContent = `${playbackOffset.toFixed(2)}s (${clickBeats.toFixed(2)}b)`; + const indicatorX = clickBeats * pixelsPerSecond; + playbackIndicator.style.left = `${indicatorX}px`; + waveformPlaybackIndicator.style.left = `${indicatorX}px`; + if (wasPlaying) { - startPlayback(); - } else { - // Update display even when paused - playbackTime.textContent = `${playbackOffset.toFixed(2)}s`; - const indicatorX = (playbackOffset * bpm / 60.0) * pixelsPerSecond; - playbackIndicator.style.left = `${indicatorX}px`; + await startPlayback(); } }); @@ -1620,6 +1684,52 @@ updateProperties(); }); + // Double-click to seek (jump playback position) + timeline.addEventListener('dblclick', async (e) => { + // Only handle clicks on timeline background (not on sequences/effects) + if (e.target !== timeline) return; + + const timelineRect = timeline.getBoundingClientRect(); + const clickX = e.clientX - timelineRect.left + timelineContent.scrollLeft; + const clickBeats = clickX / pixelsPerSecond; + const clickTime = clickBeats * 60.0 / bpm; + + if (audioBuffer) { + const wasPlaying = isPlaying; + if (wasPlaying) { + stopPlayback(false); // Don't save position, we're jumping + } + + playbackOffset = Math.max(0, Math.min(clickTime, audioDuration)); + + // Update display and position + const pausedBeats = playbackOffset * bpm / 60.0; + playbackTime.textContent = `${playbackOffset.toFixed(2)}s (${pausedBeats.toFixed(2)}b)`; + const indicatorX = pausedBeats * pixelsPerSecond; + const indicatorHeight = Math.max(timeline.offsetHeight, timelineContent.offsetHeight); + playbackIndicator.style.left = `${indicatorX}px`; + playbackIndicator.style.height = `${indicatorHeight}px`; + waveformPlaybackIndicator.style.left = `${indicatorX}px`; + + if (wasPlaying) { + // Resume playback at new position + await startPlayback(); + } else { + // Brief flash when paused + playbackIndicator.classList.add('playing'); + waveformPlaybackIndicator.classList.add('playing'); + setTimeout(() => { + if (!isPlaying) { + playbackIndicator.classList.remove('playing'); + waveformPlaybackIndicator.classList.remove('playing'); + } + }, 500); + } + + showMessage(`Seek to ${clickTime.toFixed(2)}s (${clickBeats.toFixed(2)}b)`, 'success'); + } + }); + // Keyboard shortcuts document.addEventListener('keydown', (e) => { // Spacebar: play/pause (if audio loaded) @@ -1740,8 +1850,49 @@ `; } + // Load files from URL parameters + async function loadFromURLParams() { + const params = new URLSearchParams(window.location.search); + const seqURL = params.get('seq'); + const wavURL = params.get('wav'); + + if (seqURL) { + try { + const response = await fetch(seqURL); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const content = await response.text(); + const parsed = parseSeqFile(content); + sequences = parsed.sequences; + bpm = parsed.bpm; + document.getElementById('currentBPM').textContent = bpm; + document.getElementById('bpmSlider').value = bpm; + currentFile = seqURL.split('/').pop(); + renderTimeline(); + saveBtn.disabled = false; + addSequenceBtn.disabled = false; + reorderBtn.disabled = false; + showMessage(`Loaded ${currentFile} from URL`, 'success'); + } catch (err) { + showMessage(`Error loading seq file: ${err.message}`, 'error'); + } + } + + if (wavURL) { + try { + const response = await fetch(wavURL); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const blob = await response.blob(); + const file = new File([blob], wavURL.split('/').pop(), { type: 'audio/wav' }); + await loadAudioFile(file); + } catch (err) { + showMessage(`Error loading audio file: ${err.message}`, 'error'); + } + } + } + // Initial render renderTimeline(); + loadFromURLParams(); </script> </body> </html> |
