training-academy/docs/rhythm-timeline/README.md

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

  1. Horizontal Line: Runs across middle (y = height/2), purple semi-transparent
  2. Center HIT Marker: Vertical white line at center (x = width/2), ±40px tall, with "HIT" text above
  3. Beat Dots: Purple circles (6px radius) scrolling from right to left
  4. 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