summaryrefslogtreecommitdiff
path: root/tools/timeline_editor
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-15 13:04:49 +0100
committerskal <pascal.massimino@gmail.com>2026-02-15 13:04:49 +0100
commitf7227fb28aabd1899832cd769fe72692ea4890e6 (patch)
treef78e58e60c2a3ee2dd3747f38e48a0cd4109eba2 /tools/timeline_editor
parentd040d7e04622d89e9110f699e87f3bc1d7bc385d (diff)
refactor(timeline-editor): extract viewport and playback to ES6 modules
Extract zoom/scroll/playback code from monolithic index.html into separate modules for better code organization: - timeline-viewport.js: Zoom, scroll sync, indicator positioning (133 lines) - timeline-playback.js: Audio loading, playback, waveform rendering (303 lines) - index.html: Reduced from 1093 to 853 lines (-22%) Requires HTTP server for ES6 module imports. Updated README with usage. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Diffstat (limited to 'tools/timeline_editor')
-rw-r--r--tools/timeline_editor/README.md13
-rw-r--r--tools/timeline_editor/index.html317
-rw-r--r--tools/timeline_editor/timeline-playback.js303
-rw-r--r--tools/timeline_editor/timeline-viewport.js133
4 files changed, 486 insertions, 280 deletions
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 775330f..4131782 100644
--- a/tools/timeline_editor/index.html
+++ b/tools/timeline_editor/index.html
@@ -176,16 +176,15 @@
<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 TIMELINE_LEFT_PADDING = 20;
- const SCROLL_VIEWPORT_FRACTION = 0.4;
- const SMOOTH_SCROLL_SPEED = 0.1;
- const VERTICAL_SCROLL_SPEED = 0.3;
const SEQUENCE_GAP = 10;
const SEQUENCE_DEFAULT_WIDTH = 10;
const SEQUENCE_DEFAULT_DURATION = 16;
@@ -195,7 +194,6 @@
const SEQUENCE_BOTTOM_PADDING = 5;
const EFFECT_SPACING = 30;
const EFFECT_HEIGHT = 26;
- const WAVEFORM_AMPLITUDE_SCALE = 0.4;
// State
const state = {
@@ -301,6 +299,13 @@
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 = {};
@@ -334,76 +339,8 @@
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.playbackIndicator.style.display = 'block';
- dom.clearAudioBtn.disabled = false;
- dom.replayBtn.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');
-
- // Calculate maxTime same as timeline to ensure alignment
- let maxTime = 60;
- for (const seq of state.sequences) {
- maxTime = Math.max(maxTime, seq.startTime + SEQUENCE_DEFAULT_DURATION);
- for (const effect of seq.effects) maxTime = Math.max(maxTime, seq.startTime + effect.endTime);
- }
- if (state.audioDuration > 0) maxTime = Math.max(maxTime, state.audioDuration * state.bpm / 60.0);
-
- const w = maxTime * state.pixelsPerSecond, 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 = state.audioBuffer.getChannelData(0);
- const audioBeats = timeToBeats(state.audioDuration);
- const audioPixelWidth = audioBeats * state.pixelsPerSecond;
- const samplesPerPixel = Math.ceil(channelData.length / audioPixelWidth);
- const centerY = h / 2, amplitudeScale = h * 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, 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(audioPixelWidth, centerY); ctx.stroke();
-
- // Draw beat markers across full maxTime width
- ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
- ctx.lineWidth = 1;
- for (let beat = 0; beat <= maxTime; beat++) {
- const x = beat * state.pixelsPerSecond;
- ctx.beginPath();
- ctx.moveTo(x, 0);
- ctx.lineTo(x, h);
- ctx.stroke();
- }
- }
+ // Controllers - initialized after DOM setup
+ let viewportController, playbackController;
function computeCPULoad() {
if (state.sequences.length === 0) return { maxTime: 60, loads: [], conflicts: [] };
@@ -487,89 +424,6 @@
});
}
- function clearAudio() {
- stopPlayback(); state.audioBuffer = null; state.audioDuration = 0; state.playbackOffset = 0;
- state.playStartPosition = 0;
- dom.playbackControls.style.display = 'none';
- dom.playbackIndicator.style.display = 'none';
- dom.clearAudioBtn.disabled = true;
- dom.replayBtn.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)`;
- updateIndicatorPosition(currentBeats, true);
- 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;
- }
- }
-
- function updateIndicatorPosition(beats, smoothScroll = false) {
- const timelineX = beats * state.pixelsPerSecond;
- const scrollLeft = dom.timelineContent.scrollLeft;
- dom.playbackIndicator.style.left = `${timelineX - scrollLeft + TIMELINE_LEFT_PADDING}px`;
- if (smoothScroll) {
- const targetScroll = timelineX - dom.timelineContent.clientWidth * SCROLL_VIEWPORT_FRACTION;
- const scrollDiff = targetScroll - scrollLeft;
- if (Math.abs(scrollDiff) > 5) dom.timelineContent.scrollLeft += scrollDiff * SMOOTH_SCROLL_SPEED;
- }
- }
-
// Render
function renderTimeline() {
renderCPULoad();
@@ -826,13 +680,6 @@
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 =>
@@ -853,16 +700,16 @@
state.currentFile = seqURL.split('/').pop();
state.playbackOffset = 0;
renderTimeline(); dom.saveBtn.disabled = false; dom.addSequenceBtn.disabled = false; dom.reorderBtn.disabled = false;
- updateIndicatorPosition(0, 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'); }
}
}
@@ -880,7 +727,7 @@
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;
- updateIndicatorPosition(0, 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'); }
};
@@ -894,41 +741,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;
- state.playStartPosition = state.playbackOffset;
- await startPlayback();
- }
- });
-
- dom.replayBtn.addEventListener('click', async () => {
- stopPlayback(false);
- state.playbackOffset = state.playStartPosition;
- const replayBeats = timeToBeats(state.playbackOffset);
- dom.playbackTime.textContent = `${state.playbackOffset.toFixed(2)}s (${replayBeats.toFixed(2)}b)`;
- updateIndicatorPosition(replayBeats, false);
- 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)`;
- updateIndicatorPosition(pausedBeats, false);
- 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 });
@@ -963,20 +776,14 @@
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();
- updateIndicatorPosition(timeToBeats(state.playbackOffset), false);
- });
+ // Zoom handler - managed by ViewportController
dom.bpmSlider.addEventListener('input', e => {
state.bpm = parseInt(e.target.value);
dom.currentBPM.value = state.bpm;
- if (state.audioBuffer) renderWaveform();
+ if (state.audioBuffer && playbackController) playbackController.renderWaveform();
renderTimeline();
- updateIndicatorPosition(timeToBeats(state.playbackOffset), false);
+ if (viewportController) viewportController.updateIndicatorPosition(timeToBeats(state.playbackOffset), false);
});
dom.currentBPM.addEventListener('change', e => {
@@ -984,9 +791,9 @@
if (!isNaN(bpm) && bpm >= 60 && bpm <= 200) {
state.bpm = bpm;
dom.bpmSlider.value = bpm;
- if (state.audioBuffer) renderWaveform();
+ if (state.audioBuffer && playbackController) playbackController.renderWaveform();
renderTimeline();
- updateIndicatorPosition(timeToBeats(state.playbackOffset), false);
+ if (viewportController) viewportController.updateIndicatorPosition(timeToBeats(state.playbackOffset), false);
} else {
e.target.value = state.bpm;
}
@@ -998,22 +805,15 @@
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 - TIMELINE_LEFT_PADDING;
+ const clickX = e.clientX - containerRect.left + dom.timelineContent.scrollLeft - viewportController.TIMELINE_LEFT_PADDING;
const clickBeats = clickX / state.pixelsPerSecond;
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)`;
- updateIndicatorPosition(pausedBeats, false);
- 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 => {
@@ -1030,60 +830,21 @@
}
});
- dom.timelineContent.addEventListener('scroll', () => {
- const scrollLeft = dom.timelineContent.scrollLeft;
- dom.cpuLoadCanvas.style.left = `-${scrollLeft}px`;
- dom.waveformCanvas.style.left = `-${scrollLeft}px`;
- document.getElementById('timeMarkers').style.transform = `translateX(-${scrollLeft}px)`;
- updateIndicatorPosition(timeToBeats(state.playbackOffset), false);
- });
+ // Scroll/wheel handlers - managed by ViewportController
- const handleWheel = 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;
- const newPixelsPerSecond = Math.max(10, Math.min(500, state.pixelsPerSecond + zoomDelta));
- if (newPixelsPerSecond !== state.pixelsPerSecond) {
- state.pixelsPerSecond = newPixelsPerSecond;
- dom.zoomSlider.value = state.pixelsPerSecond;
- dom.zoomLevel.textContent = `${state.pixelsPerSecond}%`;
- if (state.audioBuffer) renderWaveform();
- renderTimeline();
- dom.timelineContent.scrollLeft = timeUnderCursor * newPixelsPerSecond - mouseX;
- updateIndicatorPosition(timeToBeats(state.playbackOffset), false);
- }
- 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 * VERTICAL_SCROLL_SPEED;
};
- dom.timelineContent.addEventListener('wheel', handleWheel, { passive: false });
- dom.waveformContainer.addEventListener('wheel', handleWheel, { passive: false });
-
- // Prevent wheel events from bubbling up from UI containers
- document.querySelector('header').addEventListener('wheel', e => e.stopPropagation());
- dom.propertiesPanel.addEventListener('wheel', e => e.stopPropagation());
- document.querySelector('.zoom-controls').addEventListener('wheel', e => e.stopPropagation());
- document.querySelector('.stats').addEventListener('wheel', e => e.stopPropagation());
+ 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..1bcdcd0
--- /dev/null
+++ b/tools/timeline_editor/timeline-playback.js
@@ -0,0 +1,303 @@
+// 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.audioDuration) {
+ 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.pixelsPerSecond;
+ 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.audioDuration));
+ 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();
+ if (!this.state.audioContext) {
+ this.state.audioContext = new (window.AudioContext || window.webkitAudioContext)();
+ }
+ this.state.audioBuffer = await this.state.audioContext.decodeAudioData(arrayBuffer);
+ this.state.audioDuration = this.state.audioBuffer.duration;
+ 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.audioDuration.toFixed(2)}s`, '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 maxTime same as timeline
+ let maxTime = 60;
+ for (const seq of this.state.sequences) {
+ maxTime = Math.max(maxTime, seq.startTime + this.SEQUENCE_DEFAULT_DURATION);
+ for (const effect of seq.effects) {
+ maxTime = Math.max(maxTime, seq.startTime + effect.endTime);
+ }
+ }
+ if (this.state.audioDuration > 0) {
+ maxTime = Math.max(maxTime, this.state.audioDuration * this.state.bpm / 60.0);
+ }
+
+ const w = maxTime * this.state.pixelsPerSecond;
+ 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.audioDuration);
+ const audioPixelWidth = audioBeats * this.state.pixelsPerSecond;
+ 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.15)';
+ ctx.lineWidth = 1;
+ for (let beat = 0; beat <= maxTime; beat++) {
+ const x = beat * this.state.pixelsPerSecond;
+ ctx.beginPath();
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, h);
+ ctx.stroke();
+ }
+ }
+
+ clearAudio() {
+ this.stopPlayback();
+ this.state.audioBuffer = null;
+ this.state.audioDuration = 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.audioDuration);
+ }
+
+ 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.audioDuration));
+ 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 * 60.0 / this.state.bpm;
+ }
+
+ timeToBeats(seconds) {
+ return seconds * this.state.bpm / 60.0;
+ }
+}
diff --git a/tools/timeline_editor/timeline-viewport.js b/tools/timeline_editor/timeline-viewport.js
new file mode 100644
index 0000000..becf8e9
--- /dev/null
+++ b/tools/timeline_editor/timeline-viewport.js
@@ -0,0 +1,133 @@
+// 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
+ const wheelHandler = e => this.handleWheel(e);
+ this.dom.timelineContent.addEventListener('wheel', wheelHandler, { passive: false });
+ this.dom.waveformContainer.addEventListener('wheel', wheelHandler, { passive: false });
+
+ // Prevent wheel bubbling from UI containers
+ 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());
+ }
+
+ handleZoomSlider(e) {
+ this.state.pixelsPerSecond = parseInt(e.target.value);
+ this.dom.zoomLevel.textContent = `${this.state.pixelsPerSecond}%`;
+ 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.pixelsPerSecond;
+
+ const zoomDelta = e.deltaY > 0 ? -10 : 10;
+ const newPixelsPerSecond = Math.max(10, Math.min(500, this.state.pixelsPerSecond + zoomDelta));
+
+ if (newPixelsPerSecond !== this.state.pixelsPerSecond) {
+ this.state.pixelsPerSecond = newPixelsPerSecond;
+ this.dom.zoomSlider.value = this.state.pixelsPerSecond;
+ this.dom.zoomLevel.textContent = `${this.state.pixelsPerSecond}%`;
+ this.renderCallback('zoomWheel');
+ this.dom.timelineContent.scrollLeft = timeUnderCursor * newPixelsPerSecond - 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.pixelsPerSecond) * 0.1;
+ const currentTime = (currentScrollLeft / this.state.pixelsPerSecond) + 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.pixelsPerSecond;
+ 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;
+ }
+ }
+ }
+
+ // Helper
+ timeToBeats(seconds) {
+ return seconds * this.state.bpm / 60.0;
+ }
+}