diff options
| author | skal <pascal.massimino@gmail.com> | 2026-02-07 18:50:55 +0100 |
|---|---|---|
| committer | skal <pascal.massimino@gmail.com> | 2026-02-07 18:50:55 +0100 |
| commit | 74554454d7f58a3d3a74c319289f7a853ccb476d (patch) | |
| tree | b0b3febf325d6a0d647b7bde2cbe83e41d779143 /tools/track_visualizer/index.html | |
| parent | 3b1d28500629b8488863aeaba3203ad5c3118d5f (diff) | |
feat(tools): Add music track visualizer
Created HTML-based visualizer for .track files with:
Features:
- Load .track files via file input button
- Zoomable timeline (horizontal zoom with mouse wheel)
- Scrollable view (Shift+wheel for horizontal scroll)
- Vertical zoom controls for pattern boxes
- Click & drag panning
Visualization:
- Color-coded pattern boxes (deterministic HSL colors from name hash)
- Automatic stack-based layout (prevents overlapping patterns)
- Beat grid lines within each pattern (vertical lines at beat boundaries)
- Beat numbers displayed when zoomed in
- Sample ticks showing when events trigger (height varies with volume)
- Alternating beat background (full-height rectangles for easy counting)
- Time ruler with second markers at top
Technical:
- Single standalone HTML file (13KB, no dependencies)
- Pure HTML5 Canvas + JavaScript
- Parses .track format: SAMPLE, PATTERN, SCORE sections
- Responsive canvas sizing based on track duration
- 120 BPM timing (2 beats per second)
Files:
- tools/track_visualizer/index.html (visualizer)
- tools/track_visualizer/README.md (documentation)
Usage: Open index.html in browser, load assets/music.track
Diffstat (limited to 'tools/track_visualizer/index.html')
| -rw-r--r-- | tools/track_visualizer/index.html | 504 |
1 files changed, 504 insertions, 0 deletions
diff --git a/tools/track_visualizer/index.html b/tools/track_visualizer/index.html new file mode 100644 index 0000000..70a5fd8 --- /dev/null +++ b/tools/track_visualizer/index.html @@ -0,0 +1,504 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Music Track Visualizer</title> + <style> + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background: #1e1e1e; + color: #d4d4d4; + overflow: hidden; + } + #controls { + padding: 15px; + background: #2d2d2d; + border-bottom: 1px solid #3e3e3e; + display: flex; + gap: 15px; + align-items: center; + flex-wrap: wrap; + } + button, input[type="file"] { + padding: 8px 16px; + background: #0e639c; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + } + button:hover { + background: #1177bb; + } + input[type="file"] { + padding: 6px 12px; + } + .zoom-controls { + display: flex; + gap: 8px; + align-items: center; + } + .zoom-controls button { + padding: 6px 12px; + min-width: 40px; + } + .zoom-label { + font-size: 14px; + color: #9cdcfe; + } + #canvas-container { + position: relative; + width: 100%; + height: calc(100vh - 70px); + overflow: auto; + background: #1e1e1e; + } + #timeline-canvas { + display: block; + cursor: grab; + } + #timeline-canvas:active { + cursor: grabbing; + } + #info { + position: fixed; + bottom: 10px; + right: 10px; + background: rgba(45, 45, 45, 0.95); + padding: 10px 15px; + border-radius: 4px; + font-size: 12px; + border: 1px solid #3e3e3e; + max-width: 300px; + } + #info div { + margin: 3px 0; + } + .info-label { + color: #9cdcfe; + font-weight: bold; + } + #filename { + color: #4ec9b0; + margin-left: 10px; + font-weight: bold; + } + </style> +</head> +<body> + <div id="controls"> + <input type="file" id="file-input" accept=".track" /> + <span id="filename"></span> + <div class="zoom-controls"> + <span class="zoom-label">Zoom:</span> + <button id="zoom-out">−</button> + <button id="zoom-reset">Reset</button> + <button id="zoom-in">+</button> + </div> + <div class="zoom-controls"> + <span class="zoom-label">Vertical:</span> + <button id="v-zoom-out">−</button> + <button id="v-zoom-reset">Reset</button> + <button id="v-zoom-in">+</button> + </div> + </div> + <div id="canvas-container"> + <canvas id="timeline-canvas"></canvas> + </div> + <div id="info"> + <div><span class="info-label">Pan:</span> Click and drag</div> + <div><span class="info-label">Zoom:</span> Mouse wheel</div> + <div><span class="info-label">Scroll:</span> Shift + wheel</div> + <div><span class="info-label">Patterns:</span> <span id="pattern-count">0</span></div> + <div><span class="info-label">Duration:</span> <span id="duration">0s</span></div> + </div> + + <script> + const canvas = document.getElementById('timeline-canvas'); + const ctx = canvas.getContext('2d'); + const container = document.getElementById('canvas-container'); + const fileInput = document.getElementById('file-input'); + const filenameSpan = document.getElementById('filename'); + + // State + let trackData = null; + let zoomLevel = 1.0; + let verticalZoom = 1.0; + let offsetX = 0; + let offsetY = 0; + let isDragging = false; + let lastMouseX = 0; + let lastMouseY = 0; + + // Constants + const PIXELS_PER_SECOND = 80; // Base scale + const PATTERN_HEIGHT = 40; // Base height + const PATTERN_SPACING = 5; + const TIMELINE_TOP = 80; + const LEFT_MARGIN = 120; + const TICK_HEIGHT = 8; + + // Pattern colors (deterministic based on name hash) + function getPatternColor(patternName) { + let hash = 0; + for (let i = 0; i < patternName.length; i++) { + hash = patternName.charCodeAt(i) + ((hash << 5) - hash); + } + const hue = Math.abs(hash % 360); + return `hsl(${hue}, 70%, 55%)`; + } + + // Parse .track file + function parseTrackFile(content) { + const lines = content.split('\n'); + const patterns = {}; + const score = []; + let currentPattern = null; + let inScore = false; + + for (let line of lines) { + line = line.trim(); + + // Skip comments and empty lines + if (line.startsWith('#') || line.length === 0) continue; + + // Pattern definition + if (line.startsWith('PATTERN ')) { + currentPattern = line.substring(8).trim(); + patterns[currentPattern] = []; + inScore = false; + continue; + } + + // Score section + if (line === 'SCORE') { + inScore = true; + currentPattern = null; + continue; + } + + // Pattern events (beat, sample, volume, pan) + if (currentPattern && !inScore) { + const parts = line.split(',').map(s => s.trim()); + if (parts.length >= 2) { + patterns[currentPattern].push({ + beat: parseFloat(parts[0]), + sample: parts[1], + volume: parts.length > 2 ? parseFloat(parts[2]) : 1.0, + pan: parts.length > 3 ? parseFloat(parts[3]) : 0.0 + }); + } + } + + // Score entries (time, pattern_name) + if (inScore) { + const parts = line.split(',').map(s => s.trim()); + if (parts.length >= 2) { + score.push({ + time: parseFloat(parts[0]), + pattern: parts[1] + }); + } + } + } + + return { patterns, score }; + } + + // Calculate pattern duration (max beat time) + function getPatternDuration(pattern) { + if (pattern.length === 0) return 4.0; // Default 4 beats + return Math.max(...pattern.map(e => e.beat)) + 1.0; + } + + // Draw timeline + function drawTimeline() { + if (!trackData) return; + + const { patterns, score } = trackData; + + // Find max time for canvas sizing + const maxTime = score.length > 0 + ? Math.max(...score.map(s => s.time + getPatternDuration(patterns[s.pattern] || []))) + : 60; + + // Update canvas size + canvas.width = LEFT_MARGIN + (maxTime * PIXELS_PER_SECOND * zoomLevel) + 100; + canvas.height = TIMELINE_TOP + (score.length * (PATTERN_HEIGHT * verticalZoom + PATTERN_SPACING)) + 100; + + // Clear canvas + ctx.fillStyle = '#1e1e1e'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Draw time ruler + drawTimeRuler(maxTime); + + // Draw beat background + drawBeatBackground(maxTime); + + // Group score entries by time for stacking + const stackedPatterns = []; + for (const entry of score) { + const startTime = entry.time; + const duration = getPatternDuration(patterns[entry.pattern] || []); + const endTime = startTime + duration; + + // Find stack level (avoid overlaps) + let stackLevel = 0; + while (true) { + const conflicts = stackedPatterns.filter(p => + p.stackLevel === stackLevel && + !(endTime <= p.startTime || startTime >= p.endTime) + ); + if (conflicts.length === 0) break; + stackLevel++; + } + + stackedPatterns.push({ + patternName: entry.pattern, + startTime, + endTime, + duration, + stackLevel, + events: patterns[entry.pattern] || [] + }); + } + + // Draw pattern boxes + for (const item of stackedPatterns) { + drawPatternBox(item); + } + + // Update info + document.getElementById('pattern-count').textContent = Object.keys(patterns).length; + document.getElementById('duration').textContent = `${maxTime.toFixed(1)}s`; + } + + // Draw time ruler + function drawTimeRuler(maxTime) { + ctx.strokeStyle = '#3e3e3e'; + ctx.fillStyle = '#d4d4d4'; + ctx.font = '12px monospace'; + ctx.textAlign = 'center'; + + const rulerY = TIMELINE_TOP - 20; + + // Draw ruler line + ctx.beginPath(); + ctx.moveTo(LEFT_MARGIN, rulerY); + ctx.lineTo(LEFT_MARGIN + maxTime * PIXELS_PER_SECOND * zoomLevel, rulerY); + ctx.stroke(); + + // Draw time markers + const interval = zoomLevel < 0.5 ? 10 : zoomLevel < 1.0 ? 5 : 2; + for (let t = 0; t <= maxTime; t += interval) { + const x = LEFT_MARGIN + t * PIXELS_PER_SECOND * zoomLevel; + ctx.beginPath(); + ctx.moveTo(x, rulerY - 5); + ctx.lineTo(x, rulerY + 5); + ctx.stroke(); + + ctx.fillText(`${t}s`, x, rulerY - 10); + } + } + + // Draw beat background (alternating colored rectangles) + function drawBeatBackground(maxTime) { + const beatsPerSecond = 2; // 120 BPM = 2 beats per second + const beatWidth = (PIXELS_PER_SECOND * zoomLevel) / beatsPerSecond; + const totalBeats = Math.ceil(maxTime * beatsPerSecond); + + // Alternating colors for beats + const color1 = '#2a2a2a'; // Slightly lighter than background + const color2 = '#1e1e1e'; // Background color + + for (let beat = 0; beat < totalBeats; beat++) { + const x = LEFT_MARGIN + beat * beatWidth; + const isEvenBeat = beat % 2 === 0; + + ctx.fillStyle = isEvenBeat ? color1 : color2; + // Draw from TIMELINE_TOP to avoid covering the time ruler + ctx.fillRect(x, TIMELINE_TOP, beatWidth, canvas.height - TIMELINE_TOP); + } + } + + // Draw pattern box + function drawPatternBox(item) { + const x = LEFT_MARGIN + item.startTime * PIXELS_PER_SECOND * zoomLevel; + const y = TIMELINE_TOP + item.stackLevel * (PATTERN_HEIGHT * verticalZoom + PATTERN_SPACING); + const width = item.duration * PIXELS_PER_SECOND * zoomLevel; + const height = PATTERN_HEIGHT * verticalZoom; + + const color = getPatternColor(item.patternName); + + // Draw box + ctx.fillStyle = color + '33'; // Semi-transparent + ctx.fillRect(x, y, width, height); + + // Draw border + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.strokeRect(x, y, width, height); + + // Draw beat grid lines + ctx.strokeStyle = '#ffffff33'; // Semi-transparent white + ctx.lineWidth = 1; + const beatsPerSecond = 2; // 120 BPM = 2 beats per second + const beatWidth = (PIXELS_PER_SECOND * zoomLevel) / beatsPerSecond; + const numBeats = Math.ceil(item.duration * beatsPerSecond); + + for (let beat = 1; beat < numBeats; beat++) { + const beatX = x + beat * beatWidth; + ctx.beginPath(); + ctx.moveTo(beatX, y); + ctx.lineTo(beatX, y + height); + ctx.stroke(); + + // Beat number label (small) + if (verticalZoom > 0.8) { + ctx.fillStyle = '#ffffff55'; + ctx.font = `${Math.min(9, 8 * verticalZoom)}px monospace`; + ctx.textAlign = 'center'; + ctx.fillText(`${beat}`, beatX, y + 12); + } + } + + // Draw pattern name + ctx.fillStyle = '#ffffff'; + ctx.font = `bold ${Math.min(14, 12 * verticalZoom)}px sans-serif`; + ctx.textAlign = 'left'; + ctx.fillText(item.patternName, x + 5, y + height / 2 - 5); + + // Draw time label + ctx.font = `${Math.min(11, 10 * verticalZoom)}px monospace`; + ctx.fillStyle = '#9cdcfe'; + ctx.fillText(`${item.startTime.toFixed(1)}s`, x + 5, y + height / 2 + 10); + + // Draw sample ticks + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 2; + for (const event of item.events) { + const tickX = x + event.beat * (PIXELS_PER_SECOND * zoomLevel) / beatsPerSecond; + const tickY = y + height - TICK_HEIGHT * verticalZoom; + + // Vertical tick mark + ctx.beginPath(); + ctx.moveTo(tickX, tickY); + ctx.lineTo(tickX, y + height - 2); + ctx.stroke(); + + // Volume indicator (height of tick varies with volume) + const volumeHeight = TICK_HEIGHT * verticalZoom * event.volume; + ctx.fillStyle = color; + ctx.fillRect(tickX - 1, tickY, 3, volumeHeight); + } + } + + // Load file + fileInput.addEventListener('change', (e) => { + const file = e.target.files[0]; + if (!file) return; + + filenameSpan.textContent = file.name; + + const reader = new FileReader(); + reader.onload = (event) => { + try { + trackData = parseTrackFile(event.target.result); + zoomLevel = 1.0; + verticalZoom = 1.0; + offsetX = 0; + offsetY = 0; + drawTimeline(); + } catch (err) { + alert('Error parsing track file: ' + err.message); + console.error(err); + } + }; + reader.readAsText(file); + }); + + // Zoom controls + document.getElementById('zoom-in').addEventListener('click', () => { + zoomLevel = Math.min(zoomLevel * 1.2, 10); + drawTimeline(); + }); + + document.getElementById('zoom-out').addEventListener('click', () => { + zoomLevel = Math.max(zoomLevel / 1.2, 0.1); + drawTimeline(); + }); + + document.getElementById('zoom-reset').addEventListener('click', () => { + zoomLevel = 1.0; + drawTimeline(); + }); + + document.getElementById('v-zoom-in').addEventListener('click', () => { + verticalZoom = Math.min(verticalZoom * 1.2, 5); + drawTimeline(); + }); + + document.getElementById('v-zoom-out').addEventListener('click', () => { + verticalZoom = Math.max(verticalZoom / 1.2, 0.5); + drawTimeline(); + }); + + document.getElementById('v-zoom-reset').addEventListener('click', () => { + verticalZoom = 1.0; + drawTimeline(); + }); + + // Mouse wheel zoom/scroll + container.addEventListener('wheel', (e) => { + if (!trackData) return; + + if (e.shiftKey) { + // Horizontal scroll (use deltaX when Shift is pressed) + e.preventDefault(); + container.scrollLeft += e.deltaX || e.deltaY; + } else { + // Horizontal zoom + e.preventDefault(); + const delta = e.deltaY < 0 ? 1.1 : 0.9; + zoomLevel = Math.max(0.1, Math.min(10, zoomLevel * delta)); + drawTimeline(); + } + }, { passive: false }); + + // Panning + canvas.addEventListener('mousedown', (e) => { + isDragging = true; + lastMouseX = e.clientX; + lastMouseY = e.clientY; + }); + + window.addEventListener('mousemove', (e) => { + if (!isDragging) return; + + const dx = e.clientX - lastMouseX; + const dy = e.clientY - lastMouseY; + + container.scrollLeft -= dx; + container.scrollTop -= dy; + + lastMouseX = e.clientX; + lastMouseY = e.clientY; + }); + + window.addEventListener('mouseup', () => { + isDragging = false; + }); + + // Initial draw + drawTimeline(); + </script> +</body> +</html> |
