8.5 KiB
Rhythm Timeline Implementation Reference
Overview
A Canvas-based scrolling beat timeline for the rhythm-training interactive task in Level 16. Displays dots scrolling horizontally across a line that pulse when crossing a center "HIT" marker.
Visual Design
Canvas Setup
- Element:
<canvas id="beat-timeline" width="600" height="120"> - Styling: 2px solid border, 10px border-radius, purple gradient background
- Context: 2D rendering context
Visual Elements
- Horizontal Line: Runs across middle (y = height/2), purple semi-transparent
- Center HIT Marker: Vertical white line at center (x = width/2), ±40px tall, with "HIT" text above
- Beat Dots: Purple circles (6px radius) scrolling from right to left
- Pulse Effect: When dot crosses center (within 5px), it grows 1.8x and glows bright purple
Tempo Sequence
5-Minute Cycle (300 seconds)
The rhythm follows a hardcoded 7-segment tempo progression:
const tempoSegments = [
{ tempo: 60, duration: 42.86 }, // seconds per segment
{ tempo: 120, duration: 42.86 },
{ tempo: 150, duration: 42.86 },
{ tempo: 60, duration: 42.86 },
{ tempo: 15, duration: 42.86 },
{ tempo: 200, duration: 42.86 },
{ tempo: 240, duration: 42.86 }
];
Each segment is ~42.86 seconds (300s ÷ 7 segments).
Pre-Generation System
Why Pre-Generate?
To avoid dynamic calculation glitches and maintain smooth constant-speed scrolling regardless of tempo changes.
Beat Position Calculation
const beatPositions = [];
const scrollSpeed = 50; // pixels per second (constant)
let position = 0;
for (const segment of tempoSegments) {
const beatsInSegment = Math.floor((segment.duration / 60) * segment.tempo);
const pixelsPerBeat = scrollSpeed * (60 / segment.tempo);
for (let i = 0; i < beatsInSegment; i++) {
beatPositions.push({
position: position,
tempo: segment.tempo,
isDownbeat: (beatPositions.length % 4) === 0
});
position += pixelsPerBeat;
}
}
const totalDistance = position; // Total pixels for entire 5-min cycle
Key Principle
- Scroll speed is constant (50 px/s)
- Beat spacing varies based on tempo
- Higher tempo = dots closer together (more beats in same space)
- Lower tempo = dots farther apart (fewer beats in same space)
Example:
- 60 BPM:
pixelsPerBeat = 50 * (60/60) = 50px - 120 BPM:
pixelsPerBeat = 50 * (60/120) = 25px(twice as many dots) - 240 BPM:
pixelsPerBeat = 50 * (60/240) = 12.5px(four times as many)
Animation System
Core Variables
let scrollOffset = 0; // Accumulated scroll distance
let animationStartTime = null; // When animation started
let nextBeatTime = null; // Timestamp for next audio click
let lastFrameTime = null; // Previous frame timestamp for delta
Animation Loop (60fps via requestAnimationFrame)
const animateTimeline = () => {
const now = Date.now();
// Delta-time based scrolling (smooth regardless of framerate)
if (lastFrameTime) {
const deltaTime = (now - lastFrameTime) / 1000; // seconds
scrollOffset += scrollSpeed * deltaTime;
}
lastFrameTime = now;
// Trigger beat audio when time reached
if (now >= nextBeatTime) {
playBeat();
nextBeatTime = now + beatDuration;
}
drawTimeline();
animationFrameId = requestAnimationFrame(animateTimeline);
};
Drawing Loop
const drawTimeline = () => {
// Use modulo for seamless looping
const currentOffset = scrollOffset % totalDistance;
for (let i = 0; i < beatPositions.length; i++) {
const beat = beatPositions[i];
let x = centerX + beat.position - currentOffset;
// Handle wrapping when dot scrolls off left edge
if (x < -100) x += totalDistance;
if (x > width + 100) continue;
// Calculate pulse effect
const distanceFromCenter = Math.abs(x - centerX);
const isPulsing = distanceFromCenter < 5;
const dotRadius = 6;
const pulseRadius = isPulsing ? dotRadius * 1.8 : dotRadius;
// Draw dot with shadow when pulsing
ctx.beginPath();
ctx.arc(x, height / 2, pulseRadius, 0, Math.PI * 2);
if (isPulsing) {
ctx.fillStyle = 'rgba(138, 43, 226, 1)';
ctx.shadowBlur = 15;
ctx.shadowColor = '#8a2be2';
} else {
ctx.fillStyle = 'rgba(138, 43, 226, 0.6)';
ctx.shadowBlur = 0;
}
ctx.fill();
ctx.shadowBlur = 0;
}
};
Audio System
Single Shared AudioContext
Critical: Only create ONE AudioContext instance to avoid browser errors.
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const playBeat = () => {
beatCount++;
// Reuse shared context
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 800; // Hz
oscillator.type = 'sine';
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.1);
};
Tempo Management
Dynamic Tempo Updates
Tempo changes happen automatically based on elapsed time:
const cycleElapsed = now - segmentStartTime;
const targetSegmentIndex = Math.floor(cycleElapsed / segmentDuration);
if (targetSegmentIndex !== currentSegmentIndex && targetSegmentIndex < tempoSequence.length) {
currentSegmentIndex = targetSegmentIndex;
updateTempo(tempoSequence[currentSegmentIndex]);
} else if (cycleElapsed >= (5 * 60 * 1000)) {
// Cycle complete, restart
segmentStartTime = now;
currentSegmentIndex = 0;
updateTempo(tempoSequence[0]);
}
Update Tempo Function
const updateTempo = (newTempo) => {
currentTempo = Math.max(15, Math.min(240, newTempo));
beatDuration = (60 / currentTempo) * 1000; // ms per beat
if (tempoDisplay) tempoDisplay.textContent = currentTempo;
};
Note: Changing tempo does NOT affect scroll speed or existing beat positions. The pre-generated pattern already contains all tempo changes baked in.
Start/Stop Control
Start Metronome
const startMetronome = () => {
if (animationFrameId) cancelAnimationFrame(animationFrameId);
beatDuration = (60 / currentTempo) * 1000;
animationStartTime = Date.now();
nextBeatTime = Date.now() + beatDuration;
beatCount = 0;
scrollOffset = 0;
lastFrameTime = null;
playBeat();
animateTimeline();
};
Pause/Resume
stopBtn.addEventListener('click', () => {
if (animationFrameId) {
// Pause
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
animationStartTime = null;
lastFrameTime = null;
} else {
// Resume
startMetronome();
}
});
Common Issues & Solutions
Issue: Timeline Glitching/Jumping Backwards
Cause: Resetting scrollOffset during animation or using tempo-based scroll speed Solution: Use constant scroll speed with delta time, never reset scrollOffset mid-animation
Issue: Multiple AudioContext Errors
Cause: Creating new AudioContext on every beat Solution: Create ONE shared AudioContext at initialization, reuse for all beats
Issue: Dots Speed Up When Tempo Changes
Cause: Calculating spacing dynamically instead of using pre-generated positions Solution: Pre-calculate all beat positions at init time with correct spacing per tempo segment
Issue: Visual Stutter on Tempo Transitions
Cause: Not pre-generating, recalculating positions when tempo changes Solution: Entire 5-minute pattern is pre-calculated, tempo changes are already in the data
File Location
Implementation: src/features/tasks/interactiveTaskManager.js
- Lines ~7265-7295: Variable declarations and beat pre-generation
- Lines ~7300-7375: drawTimeline() function
- Lines ~7377-7410: animateTimeline() function
- Lines ~7412-7440: playBeat() and startMetronome() functions
Integration with Level 16
Used in rhythm-training interactive task type:
- Duration: 5 minutes (300s)
- Repeats cycle continuously throughout task
- Task completes after specified duration regardless of tempo cycle position