summaryrefslogtreecommitdiff
path: root/tools/track_visualizer
diff options
context:
space:
mode:
Diffstat (limited to 'tools/track_visualizer')
-rw-r--r--tools/track_visualizer/README.md82
-rw-r--r--tools/track_visualizer/index.html504
2 files changed, 586 insertions, 0 deletions
diff --git a/tools/track_visualizer/README.md b/tools/track_visualizer/README.md
new file mode 100644
index 0000000..a036dbd
--- /dev/null
+++ b/tools/track_visualizer/README.md
@@ -0,0 +1,82 @@
+# Music Track Visualizer
+
+A simple HTML-based visualizer for `.track` music files.
+
+## Features
+
+- **Load .track files** via file input button
+- **Zoomable timeline** with horizontal and vertical zoom controls
+- **Pan/scroll** by clicking and dragging on the canvas
+- **Color-coded patterns** with deterministic colors based on pattern names
+- **Sample ticks** displayed within each pattern box (vertical marks showing when samples trigger)
+- **Stack-based layout** prevents overlapping patterns automatically
+- **Time ruler** at the top with second markers
+- **Pattern duration** calculated from max beat time in each pattern
+
+## Usage
+
+1. Open `index.html` in a web browser
+2. Click "Choose File" and select a `.track` file (e.g., `assets/music.track`)
+3. Use the zoom controls to adjust horizontal and vertical scale
+4. Click and drag on the canvas to pan around
+5. Use mouse wheel to zoom (Shift+wheel for vertical zoom)
+
+## Controls
+
+| Action | Method |
+|--------|--------|
+| **Load file** | Click "Choose File" button |
+| **Zoom in/out** | Use + / − buttons or mouse wheel |
+| **Vertical zoom** | Use vertical zoom buttons or Shift+wheel |
+| **Pan** | Click and drag on canvas |
+| **Reset zoom** | Click "Reset" buttons |
+
+## Track File Format
+
+The visualizer parses `.track` files with the following structure:
+
+```
+# Comments start with #
+
+SAMPLE ASSET_KICK_1
+SAMPLE ASSET_SNARE_1
+
+PATTERN pattern_name
+ beat, sample_id, volume, pan
+ 0.0, ASSET_KICK_1, 1.0, 0.0
+ 2.0, ASSET_SNARE_1, 0.9, 0.1
+
+SCORE
+ time, pattern_name
+ 0.0, pattern_name
+ 4.0, pattern_name
+```
+
+### Elements Visualized
+
+- **Pattern boxes**: Color-coded rectangles representing each pattern trigger
+- **Sample ticks**: Vertical marks inside boxes showing when samples play
+- **Volume indicators**: Tick height varies with sample volume
+- **Time labels**: Start time displayed in each pattern box
+- **Stack levels**: Patterns automatically stack to avoid visual overlap
+
+## Implementation Details
+
+- **Stack-based layout**: Patterns are automatically arranged in vertical stacks to prevent overlaps
+- **Deterministic colors**: Pattern colors are generated from pattern name hashes for consistency
+- **Responsive canvas**: Canvas size adapts to track duration and pattern count
+- **Efficient rendering**: Full redraw on zoom/pan changes
+
+## Keyboard Shortcuts
+
+None currently - all controls via mouse/buttons.
+
+## Future Enhancements
+
+Potential additions:
+- Beat grid overlay
+- Pattern highlighting on hover
+- Sample name tooltips
+- Export to image
+- Playback cursor
+- Edit mode (move/resize patterns)
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>