summaryrefslogtreecommitdiff
path: root/tools/timeline_editor/index.html
diff options
context:
space:
mode:
Diffstat (limited to 'tools/timeline_editor/index.html')
-rw-r--r--tools/timeline_editor/index.html255
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>