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.html543
1 files changed, 444 insertions, 99 deletions
diff --git a/tools/timeline_editor/index.html b/tools/timeline_editor/index.html
index db71beb..c9385ad 100644
--- a/tools/timeline_editor/index.html
+++ b/tools/timeline_editor/index.html
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Timeline Editor - demo.seq</title>
+ <title>Timeline Editor - timeline.seq</title>
<style>
* {
margin: 0;
@@ -33,11 +33,17 @@
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+ flex-wrap: wrap;
}
h1 {
- margin-bottom: 10px;
+ margin: 0;
color: #4ec9b0;
+ white-space: nowrap;
}
.controls {
@@ -45,7 +51,6 @@
gap: 10px;
flex-wrap: wrap;
align-items: center;
- margin-bottom: 20px;
}
.checkbox-label {
@@ -101,17 +106,24 @@
background: #252526;
border-radius: 8px;
padding: 20px;
- overflow-x: auto;
- overflow-y: auto;
position: relative;
height: calc(100vh - 280px);
min-height: 500px;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .timeline-content {
+ flex: 1;
+ overflow-x: auto;
+ overflow-y: auto;
+ position: relative;
/* Hide scrollbars while keeping scroll functionality */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
- .timeline-container::-webkit-scrollbar {
+ .timeline-content::-webkit-scrollbar {
display: none; /* Chrome/Safari/Opera */
}
@@ -121,20 +133,56 @@
border-left: 2px solid #3c3c3c;
}
+ .sticky-header {
+ position: relative;
+ background: #252526;
+ z-index: 100;
+ padding-bottom: 10px;
+ border-bottom: 2px solid #3c3c3c;
+ flex-shrink: 0;
+ }
+
+ .playback-controls {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 0;
+ }
+
+ #playPauseBtn {
+ width: 60px;
+ padding: 8px 12px;
+ }
+
#waveformCanvas {
position: relative;
height: 80px;
width: 100%;
- margin-bottom: 10px;
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
cursor: crosshair;
}
+ .playback-indicator {
+ position: absolute;
+ top: 0;
+ width: 2px;
+ height: 100%;
+ background: #f48771;
+ box-shadow: 0 0 4px rgba(244, 135, 113, 0.8);
+ pointer-events: none;
+ z-index: 90;
+ display: none;
+ }
+
+ .playback-indicator.playing {
+ display: block;
+ }
+
.time-markers {
position: relative;
height: 30px;
- margin-bottom: 10px;
+ margin-top: 10px;
border-bottom: 1px solid #3c3c3c;
}
@@ -155,6 +203,17 @@
background: #3c3c3c;
}
+ .time-marker::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 30px;
+ width: 1px;
+ height: 10000px;
+ background: rgba(60, 60, 60, 0.2);
+ pointer-events: none;
+ }
+
.sequence {
position: absolute;
background: #264f78;
@@ -190,6 +249,36 @@
}
}
+ .sequence-header {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ padding: 8px;
+ z-index: 5;
+ cursor: pointer;
+ user-select: none;
+ }
+
+ .sequence-header-name {
+ font-size: 14px;
+ font-weight: bold;
+ color: #ffffff;
+ }
+
+ .sequence:not(.collapsed) .sequence-header-name {
+ display: none;
+ }
+
+ .sequence.collapsed {
+ overflow: hidden !important;
+ background: #1a3a4a !important;
+ }
+
+ .sequence.collapsed .sequence-name {
+ display: none !important;
+ }
+
.sequence-name {
position: absolute;
top: 50%;
@@ -283,8 +372,8 @@
.properties-panel {
position: fixed;
- top: 80px;
- right: 20px;
+ bottom: 20px;
+ left: 20px;
width: 350px;
max-height: 80vh;
background: #252526;
@@ -297,7 +386,7 @@
}
.properties-panel.collapsed {
- transform: translateX(370px);
+ transform: translateY(calc(100% + 40px));
}
.panel-header {
@@ -331,8 +420,8 @@
.panel-collapse-btn {
position: fixed;
- top: 80px;
- right: 20px;
+ bottom: 20px;
+ left: 20px;
background: #252526;
border: 1px solid #858585;
color: #d4d4d4;
@@ -408,49 +497,57 @@
<div class="container">
<header>
<h1>📊 Timeline Editor</h1>
- <p>Interactive editor for demo.seq files</p>
+ <div class="controls">
+ <label class="file-label">
+ 📂 Load timeline.seq
+ <input type="file" id="fileInput" accept=".seq">
+ </label>
+ <button id="saveBtn" disabled>💾 Save timeline.seq</button>
+ <label class="file-label">
+ 🎵 Load Audio (WAV)
+ <input type="file" id="audioInput" accept=".wav">
+ </label>
+ <button id="clearAudioBtn" disabled>✖ Clear Audio</button>
+ <button id="addSequenceBtn" disabled>➕ Add Sequence</button>
+ <button id="deleteBtn" disabled>🗑️ Delete Selected</button>
+ <button id="reorderBtn" disabled>🔄 Re-order by Time</button>
+ </div>
</header>
- <div class="controls">
- <label class="file-label">
- 📂 Load demo.seq
- <input type="file" id="fileInput" accept=".seq">
- </label>
- <button id="saveBtn" disabled>💾 Save demo.seq</button>
- <label class="file-label">
- 🎵 Load Audio (WAV)
- <input type="file" id="audioInput" accept=".wav">
- </label>
- <button id="clearAudioBtn" disabled>✖ Clear Audio</button>
- <button id="addSequenceBtn" disabled>➕ Add Sequence</button>
- <button id="deleteBtn" disabled>🗑️ Delete Selected</button>
- <button id="reorderBtn" disabled>🔄 Re-order by Time</button>
- </div>
-
<div class="zoom-controls">
<label>Zoom: <input type="range" id="zoomSlider" min="10" max="200" value="100" step="10"></label>
<span id="zoomLevel">100%</span>
- <label style="margin-left: 20px">Pixels per second: <span id="pixelsPerSec">100</span></label>
+ <label style="margin-left: 20px">BPM: <input type="range" id="bpmSlider" min="60" max="200" value="120" step="1"></label>
+ <span id="currentBPM">120</span>
<label class="checkbox-label" style="margin-left: 20px">
- <input type="checkbox" id="showBeatsCheckbox">
- Show Beats (BPM: <span id="currentBPM">120</span>)
+ <input type="checkbox" id="showBeatsCheckbox" checked>
+ Show Beats
</label>
</div>
<div id="messageArea"></div>
<div class="timeline-container">
- <canvas id="waveformCanvas" style="display: none;"></canvas>
- <div class="time-markers" id="timeMarkers"></div>
- <div class="timeline" id="timeline"></div>
+ <div class="sticky-header">
+ <div class="playback-controls" id="playbackControls" style="display: none;">
+ <button id="playPauseBtn">▶ Play</button>
+ <span id="playbackTime">0.00s</span>
+ </div>
+ <canvas id="waveformCanvas" style="display: none;"></canvas>
+ <div class="time-markers" id="timeMarkers"></div>
+ </div>
+ <div class="timeline-content" id="timelineContent">
+ <div class="playback-indicator" id="playbackIndicator"></div>
+ <div class="timeline" id="timeline"></div>
+ </div>
</div>
- <button class="panel-collapse-btn" id="panelCollapseBtn">◀ Properties</button>
+ <button class="panel-collapse-btn" id="panelCollapseBtn">▲ Properties</button>
<div class="properties-panel" id="propertiesPanel" style="display: none;">
<div class="panel-header">
<h2>Properties</h2>
- <button class="panel-toggle" id="panelToggle">▶ Collapse</button>
+ <button class="panel-toggle" id="panelToggle">▼ Collapse</button>
</div>
<div id="propertiesContent"></div>
</div>
@@ -464,7 +561,7 @@
let currentFile = null;
let selectedItem = null;
let pixelsPerSecond = 100;
- let showBeats = false;
+ let showBeats = true;
let bpm = 120;
let isDragging = false;
let dragOffset = { x: 0, y: 0 };
@@ -473,10 +570,18 @@
let handleType = null; // 'left' or 'right'
let audioBuffer = null; // Decoded audio data
let audioDuration = 0; // Duration in seconds
+ let audioSource = null; // Current playback source
+ let audioContext = null; // Audio context for playback
+ let isPlaying = false;
+ let playbackStartTime = 0; // When playback started (audioContext.currentTime)
+ let playbackOffset = 0; // Offset into audio (seconds)
+ let animationFrameId = null;
+ let lastExpandedSeqIndex = -1;
// DOM elements
const timeline = document.getElementById('timeline');
const timelineContainer = document.querySelector('.timeline-container');
+ const timelineContent = document.getElementById('timelineContent');
const fileInput = document.getElementById('fileInput');
const saveBtn = document.getElementById('saveBtn');
const audioInput = document.getElementById('audioInput');
@@ -490,10 +595,13 @@
const messageArea = document.getElementById('messageArea');
const zoomSlider = document.getElementById('zoomSlider');
const zoomLevel = document.getElementById('zoomLevel');
- const pixelsPerSecLabel = document.getElementById('pixelsPerSec');
const stats = document.getElementById('stats');
+ const playPauseBtn = document.getElementById('playPauseBtn');
+ const playbackControls = document.getElementById('playbackControls');
+ const playbackTime = document.getElementById('playbackTime');
+ const playbackIndicator = document.getElementById('playbackIndicator');
- // Parser: demo.seq → JavaScript objects
+ // Parser: timeline.seq → JavaScript objects
// Format specification: doc/SEQUENCE.md
function parseSeqFile(content) {
const sequences = [];
@@ -502,13 +610,18 @@
let bpm = 120; // Default BPM
let currentPriority = 0; // Track priority for + = - modifiers
- // Helper: Convert time notation to seconds
+ // Helper: Parse time notation (returns beats)
function parseTime(timeStr) {
+ if (timeStr.endsWith('s')) {
+ // Explicit seconds: "2.5s" = convert to beats
+ const seconds = parseFloat(timeStr.slice(0, -1));
+ return seconds * bpm / 60.0;
+ }
if (timeStr.endsWith('b')) {
- // Beat notation: "4b" = 4 beats
- const beats = parseFloat(timeStr.slice(0, -1));
- return beats * (60.0 / bpm);
+ // Explicit beats: "4b" = 4 beats
+ return parseFloat(timeStr.slice(0, -1));
}
+ // Default: beats
return parseFloat(timeStr);
}
@@ -551,7 +664,8 @@
startTime: parseTime(seqMatch[1]),
priority: parseInt(seqMatch[2]),
effects: [],
- name: seqMatch[3] || ''
+ name: seqMatch[3] || '',
+ _collapsed: true
};
sequences.push(currentSequence);
currentPriority = -1; // Reset effect priority for new sequence
@@ -587,7 +701,7 @@
return { sequences, bpm };
}
- // Serializer: JavaScript objects → demo.seq
+ // Serializer: JavaScript objects → timeline.seq (outputs beats)
function serializeSeqFile(sequences) {
let output = '# Demo Timeline\n';
output += '# Generated by Timeline Editor\n';
@@ -619,12 +733,15 @@
async function loadAudioFile(file) {
try {
const arrayBuffer = await file.arrayBuffer();
- const audioContext = new (window.AudioContext || window.webkitAudioContext)();
+ if (!audioContext) {
+ audioContext = new (window.AudioContext || window.webkitAudioContext)();
+ }
audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
audioDuration = audioBuffer.duration;
renderWaveform();
waveformCanvas.style.display = 'block';
+ playbackControls.style.display = 'flex';
clearAudioBtn.disabled = false;
showMessage(`Audio loaded: ${audioDuration.toFixed(2)}s`, 'success');
@@ -641,8 +758,9 @@
const canvas = waveformCanvas;
const ctx = canvas.getContext('2d');
- // Set canvas size based on audio duration and zoom
- const canvasWidth = audioDuration * pixelsPerSecond;
+ // Set canvas size based on audio duration (convert to beats) and zoom
+ const audioDurationBeats = audioDuration * bpm / 60.0;
+ const canvasWidth = audioDurationBeats * pixelsPerSecond;
const canvasHeight = 80;
// Set actual canvas resolution (for sharp rendering)
@@ -707,24 +825,157 @@
}
function clearAudio() {
+ stopPlayback();
audioBuffer = null;
audioDuration = 0;
waveformCanvas.style.display = 'none';
+ playbackControls.style.display = 'none';
clearAudioBtn.disabled = true;
renderTimeline();
showMessage('Audio cleared', 'success');
}
+ // Playback functions
+ function startPlayback() {
+ if (!audioBuffer || !audioContext) return;
+
+ // Resume audio context if suspended
+ if (audioContext.state === 'suspended') {
+ audioContext.resume();
+ }
+
+ // Create and start audio source
+ audioSource = audioContext.createBufferSource();
+ audioSource.buffer = audioBuffer;
+ audioSource.connect(audioContext.destination);
+ audioSource.start(0, playbackOffset);
+
+ playbackStartTime = audioContext.currentTime;
+ isPlaying = true;
+ playPauseBtn.textContent = '⏸ Pause';
+ playbackIndicator.classList.add('playing');
+
+ // Start animation loop
+ updatePlaybackPosition();
+
+ audioSource.onended = () => {
+ if (isPlaying) {
+ stopPlayback();
+ }
+ };
+ }
+
+ function stopPlayback() {
+ if (audioSource) {
+ try {
+ audioSource.stop();
+ } catch (e) {
+ // Already stopped
+ }
+ audioSource = null;
+ }
+
+ if (animationFrameId) {
+ cancelAnimationFrame(animationFrameId);
+ animationFrameId = null;
+ }
+
+ if (isPlaying) {
+ // Save current position for resume
+ const elapsed = audioContext.currentTime - playbackStartTime;
+ playbackOffset = Math.min(playbackOffset + elapsed, audioDuration);
+ }
+
+ isPlaying = false;
+ playPauseBtn.textContent = '▶ Play';
+ playbackIndicator.classList.remove('playing');
+ }
+
+ function updatePlaybackPosition() {
+ if (!isPlaying) return;
+
+ const elapsed = audioContext.currentTime - playbackStartTime;
+ const currentTime = playbackOffset + elapsed;
+
+ // Update time display
+ playbackTime.textContent = `${currentTime.toFixed(2)}s`;
+
+ // Convert to beats for position calculation
+ const currentBeats = currentTime * bpm / 60.0;
+
+ // Update playback indicator position
+ const indicatorX = currentBeats * pixelsPerSecond;
+ playbackIndicator.style.left = `${indicatorX}px`;
+
+ // Auto-scroll timeline to follow playback
+ const viewportWidth = timelineContent.clientWidth;
+ const scrollX = timelineContent.scrollLeft;
+ const relativeX = indicatorX - scrollX;
+
+ // Keep indicator in middle third of viewport
+ if (relativeX < viewportWidth * 0.33 || relativeX > viewportWidth * 0.67) {
+ timelineContent.scrollLeft = indicatorX - viewportWidth * 0.5;
+ }
+
+ // Auto-expand/collapse sequences
+ expandSequenceAtTime(currentBeats);
+
+ // Continue animation
+ animationFrameId = requestAnimationFrame(updatePlaybackPosition);
+ }
+
+ function expandSequenceAtTime(currentBeats) {
+ // Find which sequence is active at current time
+ let activeSeqIndex = -1;
+ for (let i = 0; i < sequences.length; i++) {
+ const seq = sequences[i];
+ const seqEndBeats = seq.startTime + (seq.effects.length > 0
+ ? Math.max(...seq.effects.map(e => e.endTime))
+ : 0);
+
+ if (currentBeats >= seq.startTime && currentBeats <= seqEndBeats) {
+ activeSeqIndex = i;
+ break;
+ }
+ }
+
+ // Changed sequence - collapse old, expand new
+ if (activeSeqIndex !== lastExpandedSeqIndex) {
+ // Collapse previous sequence
+ if (lastExpandedSeqIndex >= 0 && lastExpandedSeqIndex < sequences.length) {
+ sequences[lastExpandedSeqIndex]._collapsed = true;
+ }
+
+ // Expand new sequence
+ if (activeSeqIndex >= 0) {
+ sequences[activeSeqIndex]._collapsed = false;
+ lastExpandedSeqIndex = activeSeqIndex;
+
+ // Flash animation
+ const seqDivs = timeline.querySelectorAll('.sequence');
+ if (seqDivs[activeSeqIndex]) {
+ seqDivs[activeSeqIndex].classList.add('active-flash');
+ setTimeout(() => {
+ seqDivs[activeSeqIndex]?.classList.remove('active-flash');
+ }, 600);
+ }
+ }
+
+ // Re-render to show collapse/expand changes
+ renderTimeline();
+ }
+ }
+
// Render timeline
function renderTimeline() {
timeline.innerHTML = '';
const timeMarkers = document.getElementById('timeMarkers');
timeMarkers.innerHTML = '';
- // Calculate max time
- let maxTime = 30; // Default 30 seconds
+ // Calculate max time (in beats)
+ let maxTime = 60; // Default 60 beats (15 bars)
for (const seq of sequences) {
- const seqEnd = seq.startTime + 10; // Default sequence duration
+ const seqEnd = seq.startTime + 16; // Default 4 bars
maxTime = Math.max(maxTime, seqEnd);
for (const effect of seq.effects) {
@@ -734,7 +985,8 @@
// Extend timeline to fit audio if loaded
if (audioDuration > 0) {
- maxTime = Math.max(maxTime, audioDuration);
+ const audioBeats = audioDuration * bpm / 60.0;
+ maxTime = Math.max(maxTime, audioBeats);
}
// Render time markers
@@ -742,23 +994,22 @@
timeline.style.width = `${timelineWidth}px`;
if (showBeats) {
- // Show beats
- const beatDuration = 60.0 / bpm; // seconds per beat
- const maxBeats = Math.ceil(maxTime / beatDuration);
- for (let beat = 0; beat <= maxBeats; beat++) {
- const timeSec = beat * beatDuration;
+ // Show beats (default)
+ for (let beat = 0; beat <= maxTime; beat += 4) {
const marker = document.createElement('div');
marker.className = 'time-marker';
- marker.style.left = `${timeSec * pixelsPerSecond}px`;
+ marker.style.left = `${beat * pixelsPerSecond}px`;
marker.textContent = `${beat}b`;
timeMarkers.appendChild(marker);
}
} else {
// Show seconds
- for (let t = 0; t <= maxTime; t += 1) {
+ const maxSeconds = maxTime * 60.0 / bpm;
+ for (let t = 0; t <= maxSeconds; t += 1) {
+ const beatPos = t * bpm / 60.0;
const marker = document.createElement('div');
marker.className = 'time-marker';
- marker.style.left = `${t * pixelsPerSecond}px`;
+ marker.style.left = `${beatPos * pixelsPerSecond}px`;
marker.textContent = `${t}s`;
timeMarkers.appendChild(marker);
}
@@ -786,20 +1037,53 @@
const seqVisualWidth = seqVisualEnd - seqVisualStart;
+ // Initialize collapsed state if undefined
+ if (seq._collapsed === undefined) {
+ seq._collapsed = false;
+ }
+
// Calculate sequence height based on number of effects (stacked vertically)
const numEffects = seq.effects.length;
const effectSpacing = 30;
- const seqHeight = Math.max(70, 20 + numEffects * effectSpacing + 5);
+ const fullHeight = Math.max(70, 20 + numEffects * effectSpacing + 5);
+ const seqHeight = seq._collapsed ? 35 : fullHeight;
seqDiv.style.left = `${seqVisualStart * pixelsPerSecond}px`;
seqDiv.style.top = `${cumulativeY}px`;
seqDiv.style.width = `${seqVisualWidth * pixelsPerSecond}px`;
seqDiv.style.height = `${seqHeight}px`;
+ seqDiv.style.minHeight = `${seqHeight}px`;
+ seqDiv.style.maxHeight = `${seqHeight}px`;
// Store Y position for this sequence (used by effects and scroll)
seq._yPosition = cumulativeY;
cumulativeY += seqHeight + sequenceGap;
+ // Create sequence header (double-click to collapse)
+ const seqHeaderDiv = document.createElement('div');
+ seqHeaderDiv.className = 'sequence-header';
+
+ const headerName = document.createElement('span');
+ headerName.className = 'sequence-header-name';
+ headerName.textContent = seq.name || `Sequence ${seqIndex + 1}`;
+
+ seqHeaderDiv.appendChild(headerName);
+
+ // Prevent drag on header
+ seqHeaderDiv.addEventListener('mousedown', (e) => {
+ e.stopPropagation();
+ });
+
+ // Double-click to toggle collapse
+ seqHeaderDiv.addEventListener('dblclick', (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ seq._collapsed = !seq._collapsed;
+ renderTimeline();
+ });
+
+ seqDiv.appendChild(seqHeaderDiv);
+
// Create sequence name overlay (large, centered, fades on hover)
const seqNameDiv = document.createElement('div');
seqNameDiv.className = 'sequence-name';
@@ -807,6 +1091,11 @@
seqDiv.appendChild(seqNameDiv);
+ // Apply collapsed state
+ if (seq._collapsed) {
+ seqDiv.classList.add('collapsed');
+ }
+
if (selectedItem && selectedItem.type === 'sequence' && selectedItem.index === seqIndex) {
seqDiv.classList.add('selected');
}
@@ -827,7 +1116,8 @@
timeline.appendChild(seqDiv);
- // Render effects within sequence
+ // Render effects within sequence (skip if collapsed)
+ if (!seq._collapsed) {
seq.effects.forEach((effect, effectIndex) => {
const effectDiv = document.createElement('div');
effectDiv.className = 'effect';
@@ -842,16 +1132,14 @@
effectDiv.style.width = `${effectWidth}px`;
effectDiv.style.height = '26px';
- // Format time display based on mode (for tooltip)
- let timeDisplay;
- if (showBeats) {
- const beatDuration = 60.0 / bpm;
- const startBeat = (effect.startTime / beatDuration).toFixed(1);
- const endBeat = (effect.endTime / beatDuration).toFixed(1);
- timeDisplay = `${startBeat}-${endBeat}b`;
- } else {
- timeDisplay = `${effect.startTime.toFixed(1)}-${effect.endTime.toFixed(1)}s`;
- }
+ // Format time display (beats primary, seconds in tooltip)
+ const startBeat = effect.startTime.toFixed(1);
+ const endBeat = effect.endTime.toFixed(1);
+ const startSec = (effect.startTime * 60.0 / bpm).toFixed(1);
+ const endSec = (effect.endTime * 60.0 / bpm).toFixed(1);
+ const timeDisplay = showBeats
+ ? `${startBeat}-${endBeat}b (${startSec}-${endSec}s)`
+ : `${startSec}-${endSec}s (${startBeat}-${endBeat}b)`;
// Show only class name, full info on hover
effectDiv.innerHTML = `
@@ -894,6 +1182,7 @@
timeline.appendChild(effectDiv);
});
+ }
});
updateStats();
@@ -926,11 +1215,9 @@
const newX = e.clientX - timelineRect.left - dragOffset.x;
let newTime = Math.max(0, newX / pixelsPerSecond);
- // Snap to beat when in beat mode
+ // Snap to beat when enabled
if (showBeats) {
- const beatDuration = 60.0 / bpm;
- const nearestBeat = Math.round(newTime / beatDuration);
- newTime = nearestBeat * beatDuration;
+ newTime = Math.round(newTime);
}
if (selectedItem.type === 'sequence') {
@@ -977,11 +1264,9 @@
const newX = e.clientX - timelineRect.left;
let newTime = Math.max(0, newX / pixelsPerSecond);
- // Snap to beat when in beat mode
+ // Snap to beat when enabled
if (showBeats) {
- const beatDuration = 60.0 / bpm;
- const nearestBeat = Math.round(newTime / beatDuration);
- newTime = nearestBeat * beatDuration;
+ newTime = Math.round(newTime);
}
const seq = sequences[selectedItem.seqIndex];
@@ -1153,6 +1438,7 @@
sequences = parsed.sequences;
bpm = parsed.bpm;
document.getElementById('currentBPM').textContent = bpm;
+ document.getElementById('bpmSlider').value = bpm;
renderTimeline();
saveBtn.disabled = false;
addSequenceBtn.disabled = false;
@@ -1172,7 +1458,7 @@
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
- a.download = currentFile || 'demo.seq';
+ a.download = currentFile || 'timeline.seq';
a.click();
URL.revokeObjectURL(url);
showMessage('File saved', 'success');
@@ -1189,12 +1475,52 @@
audioInput.value = ''; // Reset file input
});
+ playPauseBtn.addEventListener('click', () => {
+ if (isPlaying) {
+ stopPlayback();
+ } else {
+ // Reset to beginning if at end
+ if (playbackOffset >= audioDuration) {
+ playbackOffset = 0;
+ }
+ startPlayback();
+ }
+ });
+
+ // Waveform click to seek
+ waveformCanvas.addEventListener('click', (e) => {
+ if (!audioBuffer) return;
+
+ const rect = waveformCanvas.getBoundingClientRect();
+ const clickX = e.clientX - rect.left;
+ const audioDurationBeats = audioDuration * bpm / 60.0;
+ const clickBeats = (clickX / waveformCanvas.width) * audioDurationBeats;
+ const clickTime = clickBeats * 60.0 / bpm;
+
+ const wasPlaying = isPlaying;
+ if (wasPlaying) {
+ stopPlayback();
+ }
+
+ playbackOffset = Math.max(0, Math.min(clickTime, audioDuration));
+
+ if (wasPlaying) {
+ startPlayback();
+ } else {
+ // Update display even when paused
+ playbackTime.textContent = `${playbackOffset.toFixed(2)}s`;
+ const indicatorX = (playbackOffset * bpm / 60.0) * pixelsPerSecond;
+ playbackIndicator.style.left = `${indicatorX}px`;
+ }
+ });
+
addSequenceBtn.addEventListener('click', () => {
sequences.push({
type: 'sequence',
startTime: 0,
priority: 0,
- effects: []
+ effects: [],
+ _collapsed: true
});
renderTimeline();
showMessage('New sequence added', 'success');
@@ -1232,7 +1558,7 @@
const newIndex = sequences.indexOf(currentActiveSeq);
if (newIndex >= 0 && sequences[newIndex]._yPosition !== undefined) {
// Scroll to keep it in view
- timelineContainer.scrollTop = sequences[newIndex]._yPosition;
+ timelineContent.scrollTop = sequences[newIndex]._yPosition;
lastActiveSeqIndex = newIndex;
}
}
@@ -1245,13 +1571,24 @@
const zoom = parseInt(e.target.value);
pixelsPerSecond = zoom;
zoomLevel.textContent = `${zoom}%`;
- pixelsPerSecLabel.textContent = zoom;
if (audioBuffer) {
renderWaveform(); // Re-render waveform at new zoom
}
renderTimeline();
});
+ // BPM slider
+ const bpmSlider = document.getElementById('bpmSlider');
+ const currentBPMDisplay = document.getElementById('currentBPM');
+ bpmSlider.addEventListener('input', (e) => {
+ bpm = parseInt(e.target.value);
+ currentBPMDisplay.textContent = bpm;
+ if (audioBuffer) {
+ renderWaveform();
+ }
+ renderTimeline();
+ });
+
// Beats toggle
const showBeatsCheckbox = document.getElementById('showBeatsCheckbox');
showBeatsCheckbox.addEventListener('change', (e) => {
@@ -1266,13 +1603,13 @@
panelToggle.addEventListener('click', () => {
propertiesPanel.classList.add('collapsed');
panelCollapseBtn.classList.add('visible');
- panelToggle.textContent = '◀ Expand';
+ panelToggle.textContent = '▲ Expand';
});
panelCollapseBtn.addEventListener('click', () => {
propertiesPanel.classList.remove('collapsed');
panelCollapseBtn.classList.remove('visible');
- panelToggle.textContent = '▶ Collapse';
+ panelToggle.textContent = '▼ Collapse';
});
// Click outside to deselect
@@ -1283,18 +1620,27 @@
updateProperties();
});
+ // Keyboard shortcuts
+ document.addEventListener('keydown', (e) => {
+ // Spacebar: play/pause (if audio loaded)
+ if (e.code === 'Space' && audioBuffer) {
+ e.preventDefault();
+ playPauseBtn.click();
+ }
+ });
+
// Mouse wheel: zoom (with Ctrl/Cmd) or diagonal scroll
- timelineContainer.addEventListener('wheel', (e) => {
+ timelineContent.addEventListener('wheel', (e) => {
e.preventDefault();
// Zoom mode: Ctrl/Cmd + wheel
if (e.ctrlKey || e.metaKey) {
- // Get mouse position relative to timeline container
- const rect = timelineContainer.getBoundingClientRect();
+ // Get mouse position relative to timeline content
+ const rect = timelineContent.getBoundingClientRect();
const mouseX = e.clientX - rect.left; // Mouse X in viewport coordinates
// Calculate time position under cursor BEFORE zoom
- const scrollLeft = timelineContainer.scrollLeft;
+ const scrollLeft = timelineContent.scrollLeft;
const timeUnderCursor = (scrollLeft + mouseX) / pixelsPerSecond;
// Calculate new zoom level
@@ -1308,7 +1654,6 @@
// Update zoom slider and labels
zoomSlider.value = pixelsPerSecond;
zoomLevel.textContent = `${pixelsPerSecond}%`;
- pixelsPerSecLabel.textContent = pixelsPerSecond;
// Re-render waveform and timeline at new zoom
if (audioBuffer) {
@@ -1319,17 +1664,17 @@
// Adjust scroll position so time under cursor stays in same place
// After zoom: new_scrollLeft = time_under_cursor * newPixelsPerSecond - mouseX
const newScrollLeft = timeUnderCursor * newPixelsPerSecond - mouseX;
- timelineContainer.scrollLeft = newScrollLeft;
+ timelineContent.scrollLeft = newScrollLeft;
}
return;
}
// Normal mode: diagonal scroll
- timelineContainer.scrollLeft += e.deltaY;
+ timelineContent.scrollLeft += e.deltaY;
// Calculate current time position with 10% headroom for visual comfort
- const currentScrollLeft = timelineContainer.scrollLeft;
- const viewportWidth = timelineContainer.clientWidth;
+ const currentScrollLeft = timelineContent.scrollLeft;
+ const viewportWidth = timelineContent.clientWidth;
const slack = (viewportWidth / pixelsPerSecond) * 0.1; // 10% of viewport width in seconds
const currentTime = (currentScrollLeft / pixelsPerSecond) + slack;
@@ -1361,12 +1706,12 @@
// Smooth vertical scroll to bring target sequence to top of viewport
const targetScrollTop = sequences[targetSeqIndex]?._yPosition || 0;
- const currentScrollTop = timelineContainer.scrollTop;
+ const currentScrollTop = timelineContent.scrollTop;
const scrollDiff = targetScrollTop - currentScrollTop;
// Smooth transition (don't jump instantly)
if (Math.abs(scrollDiff) > 5) {
- timelineContainer.scrollTop += scrollDiff * 0.3;
+ timelineContent.scrollTop += scrollDiff * 0.3;
}
}, { passive: false });