summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--tools/timeline_editor/index.html92
1 files changed, 64 insertions, 28 deletions
diff --git a/tools/timeline_editor/index.html b/tools/timeline_editor/index.html
index eca7b97..7cd650c 100644
--- a/tools/timeline_editor/index.html
+++ b/tools/timeline_editor/index.html
@@ -42,14 +42,14 @@
.timeline-container { background: var(--bg-medium); border-radius: 8px; position: relative; height: calc(100vh - 280px); min-height: 500px; display: flex; flex-direction: column; }
.timeline-content { flex: 1; overflow: auto; position: relative; padding: 0 20px 20px 20px; scrollbar-width: none; -ms-overflow-style: none; }
.timeline-content::-webkit-scrollbar { display: none; }
- .timeline { position: relative; min-height: 100%; border-left: 2px solid var(--bg-light); }
+ .timeline { position: relative; min-height: 100%; }
.sticky-header { position: sticky; top: 0; background: var(--bg-medium); z-index: 100; padding: 20px 20px 10px 20px; border-bottom: 2px solid var(--bg-light); flex-shrink: 0; }
.waveform-container { position: relative; height: 80px; overflow: hidden; background: rgba(0, 0, 0, 0.3); border-radius: var(--radius); cursor: crosshair; }
#cpuLoadCanvas { position: absolute; left: 0; bottom: 0; height: 10px; display: block; z-index: 1; }
#waveformCanvas { position: absolute; left: 0; top: 0; height: 80px; display: block; z-index: 2; }
- .playback-indicator { position: absolute; top: 0; left: 0; width: 2px; background: var(--accent-red); box-shadow: 0 0 4px rgba(244, 135, 113, 0.8); pointer-events: none; z-index: 90; display: block; }
+ .playback-indicator { position: absolute; top: 0; bottom: 0; left: 20px; width: 2px; background: var(--accent-red); box-shadow: 0 0 4px rgba(244, 135, 113, 0.8); pointer-events: none; z-index: 110; display: block; }
.time-markers { position: relative; height: 30px; margin-top: var(--gap); border-bottom: 1px solid var(--bg-light); }
.time-marker { position: absolute; top: 0; font-size: 12px; color: var(--text-muted); }
@@ -125,7 +125,7 @@
<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">BPM: <input type="range" id="bpmSlider" min="60" max="200" value="120" step="1"></label>
- <span id="currentBPM">120</span>
+ <input type="number" id="currentBPM" value="120" min="60" max="200" step="1" style="width: 60px; padding: 4px; background: var(--bg-light); border: 1px solid var(--border-color); border-radius: var(--radius); color: var(--text-primary); text-align: center;">
<label class="checkbox-label" style="margin-left: 20px">
<input type="checkbox" id="showBeatsCheckbox" checked>Show Beats
</label>
@@ -149,16 +149,15 @@
<div id="messageArea"></div>
<div class="timeline-container">
+ <div class="playback-indicator" id="playbackIndicator"></div>
<div class="sticky-header">
<div class="waveform-container" id="waveformContainer">
<canvas id="cpuLoadCanvas"></canvas>
<canvas id="waveformCanvas"></canvas>
- <div class="playback-indicator" id="waveformPlaybackIndicator"></div>
</div>
<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>
@@ -217,7 +216,6 @@
playPauseBtn: document.getElementById('playPauseBtn'),
playbackTime: document.getElementById('playbackTime'),
playbackIndicator: document.getElementById('playbackIndicator'),
- waveformPlaybackIndicator: document.getElementById('waveformPlaybackIndicator'),
panelToggle: document.getElementById('panelToggle'),
panelCollapseBtn: document.getElementById('panelCollapseBtn'),
bpmSlider: document.getElementById('bpmSlider'),
@@ -342,7 +340,6 @@
const w = timeToBeats(state.audioDuration) * state.pixelsPerSecond, h = 80;
canvas.width = w; canvas.height = h;
canvas.style.width = `${w}px`; canvas.style.height = `${h}px`;
- dom.waveformPlaybackIndicator.style.height = `${h}px`;
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; ctx.fillRect(0, 0, w, h);
const channelData = state.audioBuffer.getChannelData(0);
@@ -365,6 +362,18 @@
ctx.stroke();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
ctx.beginPath(); ctx.moveTo(0, centerY); ctx.lineTo(w, centerY); ctx.stroke();
+
+ // Draw beat markers
+ const maxBeats = timeToBeats(state.audioDuration);
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
+ ctx.lineWidth = 1;
+ for (let beat = 0; beat <= maxBeats; beat++) {
+ const x = beat * state.pixelsPerSecond;
+ ctx.beginPath();
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, h);
+ ctx.stroke();
+ }
}
function computeCPULoad() {
@@ -450,12 +459,14 @@
}
function clearAudio() {
- stopPlayback(); state.audioBuffer = null; state.audioDuration = 0;
+ stopPlayback(); state.audioBuffer = null; state.audioDuration = 0; state.playbackOffset = 0;
dom.playbackControls.style.display = 'none';
dom.clearAudioBtn.disabled = true;
const ctx = dom.waveformCanvas.getContext('2d');
ctx.clearRect(0, 0, dom.waveformCanvas.width, dom.waveformCanvas.height);
- renderTimeline(); showMessage('Audio cleared', 'success');
+ renderTimeline();
+ updateIndicatorPosition(0, false);
+ showMessage('Audio cleared', 'success');
}
async function startPlayback() {
@@ -493,10 +504,7 @@
const currentTime = state.playbackOffset + elapsed;
const currentBeats = timeToBeats(currentTime);
dom.playbackTime.textContent = `${currentTime.toFixed(2)}s (${currentBeats.toFixed(2)}b)`;
- const indicatorX = currentBeats * state.pixelsPerSecond;
- dom.playbackIndicator.style.left = dom.waveformPlaybackIndicator.style.left = `${indicatorX}px`;
- const scrollDiff = indicatorX - dom.timelineContent.clientWidth * 0.4 - dom.timelineContent.scrollLeft;
- if (Math.abs(scrollDiff) > 5) dom.timelineContent.scrollLeft += scrollDiff * 0.1;
+ updateIndicatorPosition(currentBeats, true);
expandSequenceAtTime(currentBeats);
state.animationFrameId = requestAnimationFrame(updatePlaybackPosition);
}
@@ -520,6 +528,16 @@
}
}
+ function updateIndicatorPosition(beats, smoothScroll = false) {
+ const indicatorX = beats * state.pixelsPerSecond + 20;
+ dom.playbackIndicator.style.left = `${indicatorX}px`;
+ if (smoothScroll) {
+ const targetScroll = beats * state.pixelsPerSecond - dom.timelineContent.clientWidth * 0.4 + 20;
+ const scrollDiff = targetScroll - dom.timelineContent.scrollLeft;
+ if (Math.abs(scrollDiff) > 5) dom.timelineContent.scrollLeft += scrollDiff * 0.1;
+ }
+ }
+
// Render
function renderTimeline() {
renderCPULoad();
@@ -616,7 +634,6 @@
}
});
dom.timeline.style.minHeight = `${Math.max(totalTimelineHeight, dom.timelineContent.offsetHeight)}px`;
- if (dom.playbackIndicator) dom.playbackIndicator.style.height = `${Math.max(totalTimelineHeight, dom.timelineContent.offsetHeight)}px`;
updateStats();
}
@@ -800,9 +817,11 @@
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const content = await response.text(), parsed = parseSeqFile(content);
state.sequences = parsed.sequences; state.bpm = parsed.bpm;
- dom.currentBPM.textContent = state.bpm; dom.bpmSlider.value = state.bpm;
+ dom.currentBPM.value = state.bpm; dom.bpmSlider.value = state.bpm;
state.currentFile = seqURL.split('/').pop();
+ state.playbackOffset = 0;
renderTimeline(); dom.saveBtn.disabled = false; dom.addSequenceBtn.disabled = false; dom.reorderBtn.disabled = false;
+ updateIndicatorPosition(0, false);
showMessage(`Loaded ${state.currentFile} from URL`, 'success');
} catch (err) { showMessage(`Error loading seq file: ${err.message}`, 'error'); }
}
@@ -826,8 +845,10 @@
try {
const parsed = parseSeqFile(e.target.result);
state.sequences = parsed.sequences; state.bpm = parsed.bpm;
- dom.currentBPM.textContent = state.bpm; dom.bpmSlider.value = state.bpm;
+ 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);
showMessage(`Loaded ${state.currentFile} - ${state.sequences.length} sequences`, 'success');
} catch (err) { showMessage(`Error parsing file: ${err.message}`, 'error'); }
};
@@ -860,8 +881,7 @@
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)`;
- const indicatorX = pausedBeats * state.pixelsPerSecond;
- dom.playbackIndicator.style.left = dom.waveformPlaybackIndicator.style.left = `${indicatorX}px`;
+ updateIndicatorPosition(pausedBeats, false);
if (wasPlaying) await startPlayback();
});
@@ -901,11 +921,24 @@
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);
});
dom.bpmSlider.addEventListener('input', e => {
- state.bpm = parseInt(e.target.value); dom.currentBPM.textContent = state.bpm;
+ state.bpm = parseInt(e.target.value); dom.currentBPM.value = state.bpm;
if (state.audioBuffer) renderWaveform(); renderTimeline();
+ updateIndicatorPosition(timeToBeats(state.playbackOffset), false);
+ });
+
+ dom.currentBPM.addEventListener('change', e => {
+ const bpm = parseInt(e.target.value);
+ if (!isNaN(bpm) && bpm >= 60 && bpm <= 200) {
+ state.bpm = bpm; dom.bpmSlider.value = bpm;
+ if (state.audioBuffer) renderWaveform(); renderTimeline();
+ updateIndicatorPosition(timeToBeats(state.playbackOffset), false);
+ } else {
+ e.target.value = state.bpm;
+ }
});
dom.showBeatsCheckbox.addEventListener('change', e => { state.showBeats = e.target.checked; renderTimeline(); });
@@ -926,21 +959,23 @@
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)`;
- const indicatorX = pausedBeats * state.pixelsPerSecond;
- dom.playbackIndicator.style.left = dom.waveformPlaybackIndicator.style.left = `${indicatorX}px`;
+ updateIndicatorPosition(pausedBeats, false);
if (wasPlaying) await startPlayback();
showMessage(`Seek to ${clickTime.toFixed(2)}s (${clickBeats.toFixed(2)}b)`, 'success');
}
});
document.addEventListener('keydown', e => {
- if (e.code === 'Space' && state.audioBuffer) { e.preventDefault(); dom.playPauseBtn.click(); }
+ const isTyping = document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA';
+ if (e.code === 'Space' && state.audioBuffer && !isTyping) { 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();
+ if (!isTyping) {
+ 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();
+ }
}
});
@@ -948,7 +983,7 @@
const scrollLeft = dom.timelineContent.scrollLeft;
dom.cpuLoadCanvas.style.left = `-${scrollLeft}px`;
dom.waveformCanvas.style.left = `-${scrollLeft}px`;
- dom.waveformPlaybackIndicator.style.transform = `translateX(-${scrollLeft}px)`;
+ document.getElementById('timeMarkers').style.transform = `translateX(-${scrollLeft}px)`;
});
dom.timelineContent.addEventListener('wheel', e => {
@@ -961,6 +996,7 @@
if (newPixelsPerSecond !== oldPixelsPerSecond) {
state.pixelsPerSecond = newPixelsPerSecond; dom.zoomSlider.value = state.pixelsPerSecond; dom.zoomLevel.textContent = `${state.pixelsPerSecond}%`;
if (state.audioBuffer) renderWaveform(); renderTimeline();
+ updateIndicatorPosition(timeToBeats(state.playbackOffset), false);
dom.timelineContent.scrollLeft = timeUnderCursor * newPixelsPerSecond - mouseX;
}
return;