summaryrefslogtreecommitdiff
path: root/tools/timeline_editor/timeline-playback.js
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/timeline-playback.js
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/timeline-playback.js')
-rw-r--r--tools/timeline_editor/timeline-playback.js303
1 files changed, 303 insertions, 0 deletions
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;
+ }
+}