summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--tools/timeline_editor/README.md14
-rw-r--r--tools/timeline_editor/index.html62
-rw-r--r--workspaces/test/timeline.seq.backup8
3 files changed, 53 insertions, 31 deletions
diff --git a/tools/timeline_editor/README.md b/tools/timeline_editor/README.md
index 4fcb2f4..cc13a41 100644
--- a/tools/timeline_editor/README.md
+++ b/tools/timeline_editor/README.md
@@ -14,7 +14,7 @@ Interactive web-based editor for `timeline.seq` files.
- ⚙️ Stack-order based priority system
- 🔍 Zoom (10%-200%) with mouse wheel + Ctrl/Cmd
- 🎵 Audio waveform visualization (aligned to beats)
-- 🎼 Snap-to-beat mode (enabled by default)
+- 🎼 Quantize grid (Off, 1/32, 1/16, 1/8, 1/4, 1/2, 1 beat)
- 🎛️ BPM slider (60-200 BPM)
- 🔄 Re-order sequences by time
- 🗑️ Delete sequences/effects
@@ -37,10 +37,11 @@ Interactive web-based editor for `timeline.seq` files.
- Watch sequences auto-expand/collapse during playback
- Red playback indicators on both timeline and waveform show current position
5. **Edit:**
- - Drag sequences/effects to reposition
- - Double-click sequence header to collapse/expand
+ - Drag sequences/effects to reposition (works when collapsed or expanded)
+ - Double-click anywhere on sequence to collapse/expand
- Click item to edit properties in side panel
- Drag effect handles to resize
+ - **Quantize:** Use dropdown or hotkeys (0-6) to snap to grid
6. **Zoom:** Ctrl/Cmd + mouse wheel (zooms at cursor position)
7. **Save:** Click "💾 Save timeline.seq"
@@ -102,7 +103,9 @@ open "tools/timeline_editor/index.html?seq=../../workspaces/main/timeline.seq"
## Keyboard Shortcuts
- **Spacebar**: Play/pause audio playback
+- **0-6**: Quantize grid (0=Off, 1=1beat, 2=1/2, 3=1/4, 4=1/8, 5=1/16, 6=1/32)
- **Double-click timeline**: Seek to position (continues playing if active)
+- **Double-click sequence**: Collapse/expand
- **Ctrl/Cmd + Wheel**: Zoom in/out at cursor position
## Technical Notes
@@ -113,10 +116,11 @@ open "tools/timeline_editor/index.html?seq=../../workspaces/main/timeline.seq"
- BPM used for seconds conversion (tooltips, audio waveform alignment)
- Priority determines render order (higher = on top)
- Collapsed sequences show 35px title bar, expanded show full effect stack
-- Time markers show beats by default (4-beat/bar increments)
+- **Show Beats** toggle: Switch time markers between beats and seconds
+- Time markers show 4-beat/bar increments (beats) or 1s increments (seconds)
- **Waveform and time markers are sticky** at top during scroll/zoom
- Vertical grid lines aid alignment
-- Snap-to-beat enabled by default for musical alignment
+- **Quantize grid**: Independent snap control (works in both beat and second display modes)
- **Auto-expand/collapse**: Active sequence expands during playback, previous collapses
- **Auto-scroll**: Timeline follows playback indicator (keeps it in middle third of viewport)
- **Dual playback indicators**: Red bars on both timeline and waveform (synchronized)
diff --git a/tools/timeline_editor/index.html b/tools/timeline_editor/index.html
index 21bedd1..45c9f1f 100644
--- a/tools/timeline_editor/index.html
+++ b/tools/timeline_editor/index.html
@@ -66,7 +66,7 @@
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: pointer; user-select: none; }
+ .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; }
@@ -124,6 +124,17 @@
<label class="checkbox-label" style="margin-left: 20px">
<input type="checkbox" id="showBeatsCheckbox" checked>Show Beats
</label>
+ <label style="margin-left: 20px">Quantize:
+ <select id="quantizeSelect">
+ <option value="0">Off</option>
+ <option value="32">1/32</option>
+ <option value="16">1/16</option>
+ <option value="8">1/8</option>
+ <option value="4">1/4</option>
+ <option value="2">1/2</option>
+ <option value="1" selected>1 beat</option>
+ </select>
+ </label>
<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>
@@ -163,11 +174,11 @@
// State
const state = {
sequences: [], currentFile: null, selectedItem: null, pixelsPerSecond: 100,
- showBeats: true, bpm: 120, isDragging: false, dragOffset: { x: 0, y: 0 },
+ showBeats: true, quantizeUnit: 1, bpm: 120, isDragging: false, dragOffset: { x: 0, y: 0 },
lastActiveSeqIndex: -1, isDraggingHandle: false, handleType: null,
audioBuffer: null, audioDuration: 0, audioSource: null, audioContext: null,
isPlaying: false, playbackStartTime: 0, playbackOffset: 0, animationFrameId: null,
- lastExpandedSeqIndex: -1
+ lastExpandedSeqIndex: -1, dragMoved: false
};
// DOM
@@ -198,7 +209,8 @@
panelCollapseBtn: document.getElementById('panelCollapseBtn'),
bpmSlider: document.getElementById('bpmSlider'),
currentBPM: document.getElementById('currentBPM'),
- showBeatsCheckbox: document.getElementById('showBeatsCheckbox')
+ showBeatsCheckbox: document.getElementById('showBeatsCheckbox'),
+ quantizeSelect: document.getElementById('quantizeSelect')
};
// Parser
@@ -442,7 +454,6 @@
const headerName = document.createElement('span'); headerName.className = 'sequence-header-name';
headerName.textContent = seq.name || `Sequence ${seqIndex + 1}`;
seqHeaderDiv.appendChild(headerName);
- seqHeaderDiv.addEventListener('mousedown', e => e.stopPropagation());
seqHeaderDiv.addEventListener('dblclick', e => { e.stopPropagation(); e.preventDefault(); seq._collapsed = !seq._collapsed; renderTimeline(); });
seqDiv.appendChild(seqHeaderDiv);
const seqNameDiv = document.createElement('div'); seqNameDiv.className = 'sequence-name';
@@ -453,6 +464,7 @@
seqDiv.addEventListener('mouseleave', () => seqDiv.classList.remove('hovered'));
seqDiv.addEventListener('mousedown', e => startDrag(e, 'sequence', seqIndex));
seqDiv.addEventListener('click', e => { e.stopPropagation(); selectItem('sequence', seqIndex); });
+ seqDiv.addEventListener('dblclick', e => { e.stopPropagation(); e.preventDefault(); seq._collapsed = !seq._collapsed; renderTimeline(); });
dom.timeline.appendChild(seqDiv);
if (!seq._collapsed) {
seq.effects.forEach((effect, effectIndex) => {
@@ -485,26 +497,27 @@
// Drag
function startDrag(e, type, seqIndex, effectIndex = null) {
- e.preventDefault(); state.isDragging = true;
+ state.isDragging = true;
+ state.dragMoved = false;
const timelineRect = dom.timeline.getBoundingClientRect();
const currentLeft = parseFloat(e.currentTarget.style.left) || 0;
- state.dragOffset.x = e.clientX - timelineRect.left - currentLeft;
+ state.dragOffset.x = e.clientX - timelineRect.left + dom.timelineContent.scrollLeft - currentLeft;
state.dragOffset.y = e.clientY - e.currentTarget.getBoundingClientRect().top;
state.selectedItem = { type, index: seqIndex, seqIndex, effectIndex };
- renderTimeline(); updateProperties();
document.addEventListener('mousemove', onDrag); document.addEventListener('mouseup', stopDrag);
}
function onDrag(e) {
if (!state.isDragging || !state.selectedItem) return;
+ state.dragMoved = true;
const timelineRect = dom.timeline.getBoundingClientRect();
- let newTime = Math.max(0, (e.clientX - timelineRect.left - state.dragOffset.x) / state.pixelsPerSecond);
- if (state.showBeats) newTime = Math.round(newTime);
- if (state.selectedItem.type === 'sequence') state.sequences[state.selectedItem.index].startTime = Math.round(newTime * 100) / 100;
+ let newTime = Math.max(0, (e.clientX - timelineRect.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;
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 = Math.round(relativeTime * 100) / 100; effect.endTime = effect.startTime + duration;
+ effect.startTime = relativeTime; effect.endTime = effect.startTime + duration;
}
renderTimeline(); updateProperties();
}
@@ -512,30 +525,33 @@
function stopDrag() {
state.isDragging = false;
document.removeEventListener('mousemove', onDrag); document.removeEventListener('mouseup', stopDrag);
+ if (state.dragMoved) {
+ renderTimeline(); updateProperties();
+ }
}
function startHandleDrag(e, type, seqIndex, effectIndex) {
e.preventDefault(); state.isDraggingHandle = true; state.handleType = type;
state.selectedItem = { type: 'effect', seqIndex, effectIndex, index: seqIndex };
- renderTimeline(); updateProperties();
document.addEventListener('mousemove', onHandleDrag); document.addEventListener('mouseup', stopHandleDrag);
}
function onHandleDrag(e) {
if (!state.isDraggingHandle || !state.selectedItem) return;
const timelineRect = dom.timeline.getBoundingClientRect();
- let newTime = Math.max(0, (e.clientX - timelineRect.left) / state.pixelsPerSecond);
- if (state.showBeats) newTime = Math.round(newTime);
+ let newTime = Math.max(0, (e.clientX - timelineRect.left + dom.timelineContent.scrollLeft) / state.pixelsPerSecond);
+ if (state.quantizeUnit > 0) newTime = Math.round(newTime * 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(Math.round(relativeTime * 100) / 100, effect.endTime - 0.1);
- else if (state.handleType === 'right') effect.endTime = Math.max(effect.startTime + 0.1, Math.round(relativeTime * 100) / 100);
+ 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);
renderTimeline(); updateProperties();
}
function stopHandleDrag() {
state.isDraggingHandle = false; state.handleType = null;
document.removeEventListener('mousemove', onHandleDrag); document.removeEventListener('mouseup', stopHandleDrag);
+ renderTimeline(); updateProperties();
}
function selectItem(type, seqIndex, effectIndex = null) {
@@ -749,6 +765,7 @@
});
dom.showBeatsCheckbox.addEventListener('change', e => { state.showBeats = e.target.checked; renderTimeline(); });
+ dom.quantizeSelect.addEventListener('change', e => { state.quantizeUnit = parseFloat(e.target.value); });
dom.panelToggle.addEventListener('click', () => { dom.propertiesPanel.classList.add('collapsed'); dom.panelCollapseBtn.classList.add('visible'); dom.panelToggle.textContent = '▲ Expand'; });
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; renderTimeline(); updateProperties(); });
@@ -771,7 +788,16 @@
}
});
- document.addEventListener('keydown', e => { if (e.code === 'Space' && state.audioBuffer) { e.preventDefault(); dom.playPauseBtn.click(); } });
+ document.addEventListener('keydown', e => {
+ if (e.code === 'Space' && state.audioBuffer) { 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();
+ }
+ });
dom.timelineContent.addEventListener('scroll', () => {
if (dom.waveformCanvas) {
diff --git a/workspaces/test/timeline.seq.backup b/workspaces/test/timeline.seq.backup
deleted file mode 100644
index 100c7da..0000000
--- a/workspaces/test/timeline.seq.backup
+++ /dev/null
@@ -1,8 +0,0 @@
-# WORKSPACE: test
-# Minimal timeline for audio/visual sync testing
-# BPM 120 (set in test_demo.track)
-
-SEQUENCE 0.0 0 "Main Loop"
- EFFECT + FlashEffect 0.0 16.0
-
-END_DEMO 32b