From 93a65b43094641b4c188b4fc260b8ed44c883728 Mon Sep 17 00:00:00 2001 From: skal Date: Thu, 5 Feb 2026 02:01:57 +0100 Subject: move MD files --- ANALYSIS_VARIABLE_TEMPO.md | 372 ---------------------------------- ANALYSIS_VARIABLE_TEMPO_V2.md | 414 -------------------------------------- CLAUDE.md | 2 + GEMINI.md | 2 + doc/ANALYSIS_VARIABLE_TEMPO.md | 372 ++++++++++++++++++++++++++++++++++ doc/ANALYSIS_VARIABLE_TEMPO_V2.md | 414 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 790 insertions(+), 786 deletions(-) delete mode 100644 ANALYSIS_VARIABLE_TEMPO.md delete mode 100644 ANALYSIS_VARIABLE_TEMPO_V2.md create mode 100644 doc/ANALYSIS_VARIABLE_TEMPO.md create mode 100644 doc/ANALYSIS_VARIABLE_TEMPO_V2.md diff --git a/ANALYSIS_VARIABLE_TEMPO.md b/ANALYSIS_VARIABLE_TEMPO.md deleted file mode 100644 index f09996b..0000000 --- a/ANALYSIS_VARIABLE_TEMPO.md +++ /dev/null @@ -1,372 +0,0 @@ -# Analysis: Variable Tempo / Unified Music Time - -## Current Architecture Analysis - -### 1. BPM Dependencies (Problems Found) - -#### Location 1: `TrackerScore` (tracker.h:32) -```cpp -struct TrackerScore { - const TrackerPatternTrigger* triggers; - uint32_t num_triggers; - float bpm; // ❌ FIXED BPM - Cannot change dynamically -}; -``` - -#### Location 2: `tracker_update()` (tracker.cc:57) -```cpp -float beat_to_sec = 60.0f / g_tracker_score.bpm; // ❌ Used to convert beats→seconds -``` - -**Critical Issue (tracker.cc:84):** -```cpp -int frame_offset = (int)(event.beat * beat_to_sec * 32000.0f / DCT_SIZE); -``` - -**Problem:** The BPM is **baked into the spectrogram** at pattern trigger time! -- Events within a pattern are composited once with fixed spacing -- The resulting spectrogram frames encode the tempo -- Once triggered, the pattern plays at the encoded tempo -- **Cannot speed up/slow down dynamically** - -#### Location 3: `main.cc` (lines 99, 166) -```cpp -// Bass pattern timing -if (t - last_beat_time > (60.0f / g_tracker_score.bpm) / 2.0) { // ❌ BPM dependency - -// Visual beat calculation -float beat = fmodf((float)current_time * g_tracker_score.bpm / 60.0f, 1.0f); // ❌ BPM dependency -``` - ---- - -## Current Time Flow - -``` -Physical Time (hardware clock) - ↓ -platform_state.time (real seconds elapsed) - ↓ -current_time = platform_state.time + seek_offset - ↓ -tracker_update(current_time) ← checks if patterns should trigger - ↓ -Pattern compositing: event.beat * (60.0 / BPM) * 32kHz - ↓ -Spectrogram generated with FIXED frame spacing - ↓ -synth_trigger_voice() → registers spectrogram - ↓ -Audio callback @ 32kHz pulls samples via synth_render() - ↓ -Voice advances through spectral frames at FIXED rate (one frame per DCT_SIZE samples) -``` - -**The Fundamental Problem:** -- Music time = Physical time (1:1 mapping) -- Patterns are pre-rendered at fixed tempo -- Synth playback rate is locked to 32kHz sample clock - ---- - -## What Needs to Change - -### Architecture Requirements - -1. **Separate Music Time from Physical Time** - - Music time advances independently: `music_time += dt * tempo_scale` - - `tempo_scale = 1.0` → normal speed - - `tempo_scale = 2.0` → double speed - - `tempo_scale = 0.5` → half speed - -2. **Remove BPM from Pattern Compositing** - - Events should use abstract "beat" units, not seconds - - Pattern generation should NOT convert beats→frames using BPM - - Keep events in "music time" space - -3. **Variable Playback Rate in Synth** - - Synth must support dynamic playback speed - - As tempo changes, synth advances through spectral frames faster/slower - ---- - -## Proposed Solution - -### Phase 1: Introduce Unified Music Time - -#### Change 1: Update `TrackerScore` -```cpp -struct TrackerScore { - const TrackerPatternTrigger* triggers; - uint32_t num_triggers; - // REMOVE: float bpm; -}; -``` - -#### Change 2: Update `TrackerPatternTrigger` -```cpp -struct TrackerPatternTrigger { - float music_time; // Renamed from time_sec, now abstract units - uint16_t pattern_id; -}; -``` - -#### Change 3: Update `TrackerEvent` -```cpp -struct TrackerEvent { - float beat; // Keep as-is, already abstract - uint16_t sample_id; - float volume; - float pan; -}; -``` - -#### Change 4: Update `tracker_update()` signature -```cpp -// OLD: -void tracker_update(float time_sec); - -// NEW: -void tracker_update(float music_time); -``` - -**Note:** Pattern compositing still needs tempo for now (see Phase 2). - ---- - -### Phase 2: Variable Tempo Synth Playback - -The hard part: **Dynamic playback speed without pitch shifting.** - -#### Option A: Naive Approach (Pitch Shifts - NOT GOOD) -```cpp -// Simply advance through frames faster/slower -v.current_spectral_frame += playback_speed; // ❌ Changes pitch! -``` - -**Problem:** Advancing through spectral frames faster/slower changes pitch. -- 2x speed → 2x frequency (octave up) -- 0.5x speed → 0.5x frequency (octave down) - -#### Option B: Resample Spectrograms (Complex) -- Generate spectrograms at "reference tempo" (e.g., 1.0) -- At playback time, resample to match current tempo -- **Pros:** Preserves pitch -- **Cons:** CPU-intensive, requires interpolation - -#### Option C: Time-Stretching (Best Quality) -- Use phase vocoder or similar algorithm -- Stretches/compresses time without changing pitch -- **Pros:** High quality -- **Cons:** Very complex, may exceed 64k budget - -#### Option D: Pre-render at Reference Tempo (Recommended for 64k) -**Key Insight:** For a 64k demo, pre-render everything at a fixed internal tempo. - -```cpp -// At pattern trigger time: -// 1. Composite pattern at "reference tempo" (e.g., 120 BPM baseline) -int frame_offset = (int)(event.beat * REFERENCE_BEAT_DURATION * 32000.0f / DCT_SIZE); - -// 2. Store tempo multiplier with voice -struct Voice { - float playback_speed; // NEW: 1.0 = normal, 2.0 = double speed, etc. - // ... existing fields -}; - -// 3. In synth_render(), advance at variable rate: -void synth_render(float* output_buffer, int num_frames) { - for each voice { - // Advance through spectral frames at tempo-adjusted rate - float frame_advance = playback_speed / DCT_SIZE; - - // Use fractional frame index - float spectral_frame_pos; // NEW: float instead of int - - // Interpolate between frames - int frame0 = (int)spectral_frame_pos; - int frame1 = frame0 + 1; - float frac = spectral_frame_pos - frame0; - - // Blend spectral data from frame0 and frame1 - for (int j = 0; j < DCT_SIZE; ++j) { - windowed_frame[j] = lerp(spectral[frame0][j], spectral[frame1][j], frac); - } - } -} -``` - -**Trade-off:** This changes pitch! But for a demo, this might be acceptable. - ---- - -## Sync with Physical Audio Device - -### Current System -``` -Audio Callback (32kHz fixed) - ↓ -synth_render(buffer, num_frames) ← pulls samples - ↓ -Voices advance: ++buffer_pos, ++current_spectral_frame - ↓ -Fixed playback rate (one spectral frame = DCT_SIZE samples) -``` - -### With Variable Tempo -``` -Audio Callback (32kHz fixed - CANNOT CHANGE) - ↓ -synth_render(buffer, num_frames, current_playback_speed) - ↓ -Voices advance: buffer_pos += playback_speed - ↓ -Variable playback rate (playback_speed can be > 1.0 or < 1.0) -``` - -**Key Point:** Hardware audio rate (32kHz) is FIXED. We cannot change it. - -**Solution:** Adjust how fast we consume spectrogram data: -- `playback_speed = 2.0` → consume frames 2x faster → music plays 2x speed -- `playback_speed = 0.5` → consume frames 0.5x slower → music plays 0.5x speed - ---- - -## Implementation Roadmap - -### Step 1: Remove BPM from TrackerScore ✅ Ready -- Remove `float bpm` field -- Update tracker_compiler to not generate BPM -- Update tests - -### Step 2: Add Global Tempo State -```cpp -// In audio/tracker.h or main.cc -float g_current_tempo = 1.0f; // Multiplier: 1.0 = normal, 2.0 = double speed - -void tracker_set_tempo(float tempo_multiplier); -float tracker_get_tempo(); -``` - -### Step 3: Update tracker_update() -```cpp -void tracker_update(float music_time) { - // music_time is now abstract, not physical seconds - // Patterns trigger when music_time >= trigger.music_time -} -``` - -### Step 4: Update Pattern Compositing (Keep Reference Tempo) -```cpp -// Use fixed reference tempo for compositing (e.g., 120 BPM) -const float REFERENCE_BPM = 120.0f; -const float reference_beat_to_sec = 60.0f / REFERENCE_BPM; - -int frame_offset = (int)(event.beat * reference_beat_to_sec * 32000.0f / DCT_SIZE); -``` - -### Step 5: Variable Playback in Synth (Advanced) -```cpp -// Store playback speed with each voice -struct Voice { - float playback_speed; // Set when voice is triggered - float spectral_frame_pos; // Change to float for interpolation - // ... -}; - -// In synth_trigger_voice(): -v.playback_speed = g_current_tempo; - -// In synth_render(): -// Advance with fractional frame increment -v.spectral_frame_pos += v.playback_speed / DCT_SIZE; - -// Interpolate spectral data (required for smooth tempo changes) -``` - -### Step 6: Update Main Loop -```cpp -// In main.cc -static float g_music_time = 0.0f; -static float g_tempo_scale = 1.0f; // Can be animated! - -void update_game_logic(double physical_time) { - float dt = get_delta_time(); - - // Music time advances at variable rate - g_music_time += dt * g_tempo_scale; - - // Animate tempo (example) - g_tempo_scale = 1.0f + 0.5f * sinf(physical_time * 0.1f); // Oscillate 0.5x - 1.5x - - tracker_set_tempo(g_tempo_scale); - tracker_update(g_music_time); -} -``` - ---- - -## Open Questions - -1. **Pitch Shifting Acceptable?** - - If tempo changes, should pitch change too? - - For demoscene effect: probably YES (classic effect) - - For "realistic" music: NO (need time-stretching) - -2. **Tempo Curve Continuity** - - Should tempo changes be smoothed (acceleration curves)? - - Or instant jumps (may cause audio glitches)? - -3. **Spectral Frame Interpolation** - - Linear interpolation sufficient? - - Or need cubic/sinc for quality? - -4. **Tracker Compilation** - - Should tracker_compiler still know about tempo? - - Or output pure "beat" units? - ---- - -## Recommendation - -**For a 64k demo, I recommend:** - -### Minimal Change Approach (Easiest) -1. Keep BPM for compositing (as "reference tempo") -2. Add `g_current_tempo` multiplier -3. Make synth advance through frames at `tempo * playback_speed` -4. Accept pitch shifting as intentional effect - -**Changes needed:** -- Add `float g_current_tempo` global -- Update `synth_render()` to use `playback_speed` -- Main loop: `music_time += dt * g_current_tempo` -- Tracker: rename `time_sec` → `music_time` (conceptual only) - -**Size impact:** ~100 bytes -**Complexity:** Low -**Enables:** Full variable tempo with pitch shift effect - -### Advanced Approach (Better Quality) -- Implement spectral frame interpolation -- Add time-stretching (phase vocoder lite) -- Size impact: +2-3 KB - ---- - -## Conclusion - -**Is the code ready?** ❌ NO - -**What needs to change?** -1. BPM must become a "reference tempo" for compositing only -2. Add global `tempo_scale` variable -3. Synth must support variable playback speed -4. Main loop must track `music_time` separately from physical time - -**Sync with audio device:** -- Hardware rate (32kHz) is FIXED -- We control playback speed by advancing through spectral frames faster/slower -- This inherently changes pitch (unless we add time-stretching) - -**Recommended next step:** Implement minimal change approach first, then iterate. diff --git a/ANALYSIS_VARIABLE_TEMPO_V2.md b/ANALYSIS_VARIABLE_TEMPO_V2.md deleted file mode 100644 index add347c..0000000 --- a/ANALYSIS_VARIABLE_TEMPO_V2.md +++ /dev/null @@ -1,414 +0,0 @@ -# Variable Tempo: Simplified Approach (V2) - -## User's Proposal: Trigger Timing Only - -**Key Insight:** Don't change spectrograms or playback speed - just change **when** they trigger! - -### The Simple Solution - -```cpp -// In main loop -static float music_time = 0.0f; -static float tempo_scale = 1.0f; // Can be animated/changed - -void update_game_logic(float dt) { - // Music time advances at variable rate - music_time += dt * tempo_scale; - - // Patterns trigger based on music_time (not physical time) - tracker_update(music_time); -} -``` - -**That's it!** 🎉 - -### What This Achieves - -**Drums, melodies, samples:** All sound identical (no pitch shift) -**Tempo changes:** Patterns trigger faster/slower based on `tempo_scale` -**No synth changes:** Playback rate stays at 32kHz (unchanged) - -### Example Timeline - -**Pattern triggers at music_time = 4.0** - -| tempo_scale | Physical Time | Effect | -|-------------|---------------|--------| -| 1.0 | 4.0s | Normal tempo | -| 2.0 | 2.0s | Triggers 2x earlier (accelerated) | -| 0.5 | 8.0s | Triggers 2x later (slowed) | - -**Visual:** -``` -Physical Time: 0s----1s----2s----3s----4s----5s----6s----7s----8s - ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ - -tempo_scale=1.0: 🥁 (triggers at 4s) - -tempo_scale=2.0: 🥁 (triggers at 2s - accelerated!) - -tempo_scale=0.5: 🥁 (triggers at 8s - slowed) -``` - -### The "Reset" Trick - -**Problem:** Music has accelerated to 2.0x, feels too fast -**Solution:** Reset tempo and switch to denser pattern - -```cpp -// Music accelerating -tempo_scale = 1.0 + t * 0.1; // Gradually speeds up - -// At some point (e.g., tempo_scale = 2.0) -if (tempo_scale >= 2.0) { - tempo_scale = 1.0; // Reset to normal - trigger_pattern_dense(); // Switch to 2x denser pattern -} -``` - -**Pattern Authoring:** -``` -Pattern A (sparse): [kick]---[snare]---[kick]---[snare]--- - beat 0 beat 1 beat 2 beat 3 - -Pattern B (dense): [kick]-[snare]-[kick]-[snare]- - beat 0 beat 0.5 beat 1 beat 1.5 -``` - -**Result:** Music feels the same tempo, but you've "reset" the timeline! - ---- - -## What Needs to Change in Current Code - -### Change 1: Main Loop (main.cc) - -**Current:** -```cpp -void update_game_logic(double t) { - tracker_update((float)t); // ❌ Uses physical time directly -} -``` - -**New:** -```cpp -static float g_music_time = 0.0f; -static float g_tempo_scale = 1.0f; - -void update_game_logic(double t) { - float dt = get_delta_time(); // Physical time delta - - // Music time advances at variable rate - g_music_time += dt * g_tempo_scale; - - tracker_update(g_music_time); // ✅ Uses music time -} -``` - -### Change 2: Tempo Control API (NEW) - -```cpp -// In tracker.h or main.cc -void set_tempo_scale(float scale); -float get_tempo_scale(); -float get_music_time(); -``` - -### Change 3: Tracker (NO CHANGES NEEDED!) - -**tracker.h:** Keep as-is -**tracker.cc:** Keep as-is -**TrackerScore.bpm:** Keep it! (used for compositing patterns) - -The tracker already works with abstract "time" - we just feed it `music_time` instead of physical time. - -### Change 4: Synth (NO CHANGES NEEDED!) - -Spectrograms play back at fixed rate (32kHz). -No pitch shifting, no interpolation needed. - ---- - -## Detailed Example: Accelerating Drum Beat - -### Setup -```cpp -// Pattern: kick on beats 0 and 2, snare on beats 1 and 3 -TrackerEvent drum_events[] = { - {0.0f, KICK_ID, 1.0f, 0.0f}, // Beat 0 - {1.0f, SNARE_ID, 0.9f, 0.0f}, // Beat 1 - {2.0f, KICK_ID, 1.0f, 0.0f}, // Beat 2 - {3.0f, SNARE_ID, 0.9f, 0.0f}, // Beat 3 -}; - -TrackerPattern drum_pattern = {drum_events, 4, 4.0f}; -``` - -### Scenario: Gradual Acceleration - -```cpp -// Main loop -float tempo_scale = 1.0f; - -void update_game_logic(float dt) { - // Gradually accelerate (0.05x faster per second) - tempo_scale += dt * 0.05f; - - music_time += dt * tempo_scale; - tracker_update(music_time); -} -``` - -**Timeline:** -``` -Physical Time: 0s 1s 2s 3s 4s 5s 6s 7s 8s -tempo_scale: 1.0 1.05 1.10 1.15 1.20 1.25 1.30 1.35 1.40 - -Music Time: 0.0 1.05 2.15 3.30 4.50 5.75 7.05 8.40 9.80 - ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ -Pattern Trigs: 🥁 🥁 🥁 🥁 🥁 🥁 🥁 🥁 🥁 -(every 4 beats) 0 4 8 12 16 20 24 28 32 - -Physical times when patterns trigger: -- Pattern 0: t=0.0s -- Pattern 1: t≈3.8s (instead of 4.0s) -- Pattern 2: t≈7.2s (instead of 8.0s) -- Pattern 3: t≈10.2s (instead of 12.0s) -``` - -**Effect:** Drum beat gradually feels faster, even though drums sound the same! - ---- - -## The "Reset" Strategy - -### Problem -After accelerating to 2.0x, music feels too rushed. Want to maintain energy but reset tempo. - -### Solution: Tempo Reset + Pattern Swap - -**Before Reset:** -- tempo_scale = 2.0 -- Pattern A (sparse): kicks every 1 beat - -**After Reset:** -- tempo_scale = 1.0 -- Pattern B (dense): kicks every 0.5 beats - -**Result:** Same physical rate of kicks, but tempo has "reset"! - -### Code Example - -```cpp -void update_game_logic(float dt) { - music_time += dt * tempo_scale; - - // Gradually accelerate - tempo_scale += dt * 0.1f; - - // Check for reset point - if (tempo_scale >= 2.0f) { - // Reset tempo - tempo_scale = 1.0f; - - // Switch to denser pattern - current_pattern_density *= 2; // 2x more events per beat - - // Optionally: retrigger current section with new density - retrigger_section_with_density(current_pattern_density); - } - - tracker_update(music_time); -} -``` - -### Pattern Authoring Strategy - -**Create patterns at multiple densities:** - -```cpp -// Sparse pattern (quarter notes) -TrackerEvent pattern_1x[] = { - {0.0f, KICK, 1.0f, 0.0f}, - {1.0f, SNARE, 0.9f, 0.0f}, - {2.0f, KICK, 1.0f, 0.0f}, - {3.0f, SNARE, 0.9f, 0.0f}, -}; - -// Dense pattern (eighth notes) -TrackerEvent pattern_2x[] = { - {0.0f, KICK, 1.0f, 0.0f}, - {0.5f, HIHAT, 0.6f, 0.0f}, - {1.0f, SNARE, 0.9f, 0.0f}, - {1.5f, HIHAT, 0.6f, 0.0f}, - {2.0f, KICK, 1.0f, 0.0f}, - {2.5f, HIHAT, 0.6f, 0.0f}, - {3.0f, SNARE, 0.9f, 0.0f}, - {3.5f, HIHAT, 0.6f, 0.0f}, -}; - -// Very dense pattern (sixteenth notes) -TrackerEvent pattern_4x[] = { - // ... even more events -}; -``` - -**Use pattern density to match tempo:** -- tempo_scale = 1.0x → use pattern_1x -- tempo_scale = 2.0x → reset to 1.0x, use pattern_2x -- tempo_scale = 4.0x → reset to 1.0x, use pattern_4x - ---- - -## Comparison: Old Proposal vs. New Proposal - -| Aspect | Old Proposal (Complex) | New Proposal (Simple) | -|--------|------------------------|----------------------| -| **Synth Changes** | Variable playback speed, interpolation | ❌ None | -| **Pitch Shifting** | Yes (side effect) | ❌ No | -| **Spectrograms** | Modified at playback | ✅ Unchanged | -| **Complexity** | High (12 hours) | Low (1 hour) | -| **Code Changes** | ~500 lines | ~20 lines | -| **Size Impact** | +2-3 KB | +50 bytes | -| **Quality** | Good with interpolation | ✅ Perfect (no artifacts) | - ---- - -## Implementation Checklist - -### Step 1: Add Music Time State (5 minutes) -```cpp -// In main.cc (global scope) -static float g_music_time = 0.0f; -static float g_tempo_scale = 1.0f; -static float g_last_physical_time = 0.0f; -``` - -### Step 2: Update Main Loop (10 minutes) -```cpp -void update_game_logic(double physical_time) { - // Calculate delta - float dt = (float)(physical_time - g_last_physical_time); - g_last_physical_time = physical_time; - - // Advance music time at scaled rate - g_music_time += dt * g_tempo_scale; - - // Update tracker with music time (not physical time) - tracker_update(g_music_time); -} - -// In main loop -while (!should_close) { - double current_time = platform_state.time + seek_time; - update_game_logic(current_time); // Pass physical time - // ... -} -``` - -### Step 3: Add Tempo Control API (10 minutes) -```cpp -// In main.cc or new file -void set_tempo_scale(float scale) { - g_tempo_scale = scale; -} - -float get_tempo_scale() { - return g_tempo_scale; -} - -float get_music_time() { - return g_music_time; -} -``` - -### Step 4: Test with Simple Acceleration (10 minutes) -```cpp -// Temporary test: gradual acceleration -void update_game_logic(double physical_time) { - float dt = get_delta_time(); - - // TEST: Accelerate from 1.0x to 2.0x over 10 seconds - g_tempo_scale = 1.0f + (physical_time / 10.0f); - g_tempo_scale = fminf(g_tempo_scale, 2.0f); - - g_music_time += dt * g_tempo_scale; - tracker_update(g_music_time); -} -``` - -### Step 5: Implement Reset Logic (15 minutes) -```cpp -// When tempo hits threshold, reset and switch patterns -if (g_tempo_scale >= 2.0f) { - g_tempo_scale = 1.0f; - // Trigger denser pattern here -} -``` - -### Step 6: Create Pattern Variants (tracker compiler work) -- Author patterns at 1x, 2x, 4x densities -- Map tempo ranges to pattern variants - ---- - -## Advantages of This Approach - -✅ **Simple:** ~20 lines of code -✅ **No pitch shift:** Samples sound identical -✅ **No synth changes:** Risk-free -✅ **Flexible:** Easy to animate tempo curves -✅ **Tiny size impact:** ~50 bytes -✅ **Perfect quality:** No interpolation artifacts - ---- - -## Example: Tempo Curves - -### Linear Acceleration -```cpp -tempo_scale = 1.0f + t * 0.1f; // 0.1x faster per second -``` - -### Exponential Acceleration -```cpp -tempo_scale = powf(2.0f, t / 10.0f); // Doubles every 10 seconds -``` - -### Oscillating Tempo -```cpp -tempo_scale = 1.0f + 0.3f * sinf(t * 0.5f); // Wave between 0.7x and 1.3x -``` - -### Manual Control (BPM curve from score) -```cpp -// Define tempo curve in tracker score -float tempo_curve[] = {1.0f, 1.1f, 1.3f, 1.6f, 2.0f, 1.0f, ...}; -tempo_scale = interpolate_tempo_curve(music_time); -``` - ---- - -## Conclusion - -**Your proposal is brilliant!** 🎯 - -### What You Get -- Variable tempo without modifying spectrograms -- No pitch shifting (drums sound like drums) -- Simple implementation (~1 hour) -- Tiny code size (~50 bytes) -- Perfect audio quality - -### What You Need to Do -1. Add `g_music_time` and `g_tempo_scale` to main loop -2. Compute `music_time += dt * tempo_scale` -3. Pass `music_time` to `tracker_update()` -4. Animate `tempo_scale` however you want! - -### For the "Reset" Trick -- Author patterns at 1x, 2x, 4x note densities -- When tempo hits 2.0x, reset to 1.0x and switch to denser pattern -- Result: Same perceived tempo, but timeline has reset - -**Ready to implement?** This is a 1-hour task! diff --git a/CLAUDE.md b/CLAUDE.md index 77bfc3a..cdc9e0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,8 @@ @doc/SPEC_EDITOR.md @doc/TRACKER.md @doc/PROCEDURAL.md +@doc/ANALYSIS_VARIABLE_TEMPO.md +@doc/ANALYSIS_VARIABLE_TEMPO_V2.md # coding guidelines @doc/CONTRIBUTING.md diff --git a/GEMINI.md b/GEMINI.md index 8aa8e74..ce58ae1 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -14,6 +14,8 @@ @doc/SPEC_EDITOR.md @doc/TRACKER.md @doc/PROCEDURAL.md +@doc/ANALYSIS_VARIABLE_TEMPO.md +@doc/ANALYSIS_VARIABLE_TEMPO_V2.md # coding guidelines @doc/CONTRIBUTING.md diff --git a/doc/ANALYSIS_VARIABLE_TEMPO.md b/doc/ANALYSIS_VARIABLE_TEMPO.md new file mode 100644 index 0000000..f09996b --- /dev/null +++ b/doc/ANALYSIS_VARIABLE_TEMPO.md @@ -0,0 +1,372 @@ +# Analysis: Variable Tempo / Unified Music Time + +## Current Architecture Analysis + +### 1. BPM Dependencies (Problems Found) + +#### Location 1: `TrackerScore` (tracker.h:32) +```cpp +struct TrackerScore { + const TrackerPatternTrigger* triggers; + uint32_t num_triggers; + float bpm; // ❌ FIXED BPM - Cannot change dynamically +}; +``` + +#### Location 2: `tracker_update()` (tracker.cc:57) +```cpp +float beat_to_sec = 60.0f / g_tracker_score.bpm; // ❌ Used to convert beats→seconds +``` + +**Critical Issue (tracker.cc:84):** +```cpp +int frame_offset = (int)(event.beat * beat_to_sec * 32000.0f / DCT_SIZE); +``` + +**Problem:** The BPM is **baked into the spectrogram** at pattern trigger time! +- Events within a pattern are composited once with fixed spacing +- The resulting spectrogram frames encode the tempo +- Once triggered, the pattern plays at the encoded tempo +- **Cannot speed up/slow down dynamically** + +#### Location 3: `main.cc` (lines 99, 166) +```cpp +// Bass pattern timing +if (t - last_beat_time > (60.0f / g_tracker_score.bpm) / 2.0) { // ❌ BPM dependency + +// Visual beat calculation +float beat = fmodf((float)current_time * g_tracker_score.bpm / 60.0f, 1.0f); // ❌ BPM dependency +``` + +--- + +## Current Time Flow + +``` +Physical Time (hardware clock) + ↓ +platform_state.time (real seconds elapsed) + ↓ +current_time = platform_state.time + seek_offset + ↓ +tracker_update(current_time) ← checks if patterns should trigger + ↓ +Pattern compositing: event.beat * (60.0 / BPM) * 32kHz + ↓ +Spectrogram generated with FIXED frame spacing + ↓ +synth_trigger_voice() → registers spectrogram + ↓ +Audio callback @ 32kHz pulls samples via synth_render() + ↓ +Voice advances through spectral frames at FIXED rate (one frame per DCT_SIZE samples) +``` + +**The Fundamental Problem:** +- Music time = Physical time (1:1 mapping) +- Patterns are pre-rendered at fixed tempo +- Synth playback rate is locked to 32kHz sample clock + +--- + +## What Needs to Change + +### Architecture Requirements + +1. **Separate Music Time from Physical Time** + - Music time advances independently: `music_time += dt * tempo_scale` + - `tempo_scale = 1.0` → normal speed + - `tempo_scale = 2.0` → double speed + - `tempo_scale = 0.5` → half speed + +2. **Remove BPM from Pattern Compositing** + - Events should use abstract "beat" units, not seconds + - Pattern generation should NOT convert beats→frames using BPM + - Keep events in "music time" space + +3. **Variable Playback Rate in Synth** + - Synth must support dynamic playback speed + - As tempo changes, synth advances through spectral frames faster/slower + +--- + +## Proposed Solution + +### Phase 1: Introduce Unified Music Time + +#### Change 1: Update `TrackerScore` +```cpp +struct TrackerScore { + const TrackerPatternTrigger* triggers; + uint32_t num_triggers; + // REMOVE: float bpm; +}; +``` + +#### Change 2: Update `TrackerPatternTrigger` +```cpp +struct TrackerPatternTrigger { + float music_time; // Renamed from time_sec, now abstract units + uint16_t pattern_id; +}; +``` + +#### Change 3: Update `TrackerEvent` +```cpp +struct TrackerEvent { + float beat; // Keep as-is, already abstract + uint16_t sample_id; + float volume; + float pan; +}; +``` + +#### Change 4: Update `tracker_update()` signature +```cpp +// OLD: +void tracker_update(float time_sec); + +// NEW: +void tracker_update(float music_time); +``` + +**Note:** Pattern compositing still needs tempo for now (see Phase 2). + +--- + +### Phase 2: Variable Tempo Synth Playback + +The hard part: **Dynamic playback speed without pitch shifting.** + +#### Option A: Naive Approach (Pitch Shifts - NOT GOOD) +```cpp +// Simply advance through frames faster/slower +v.current_spectral_frame += playback_speed; // ❌ Changes pitch! +``` + +**Problem:** Advancing through spectral frames faster/slower changes pitch. +- 2x speed → 2x frequency (octave up) +- 0.5x speed → 0.5x frequency (octave down) + +#### Option B: Resample Spectrograms (Complex) +- Generate spectrograms at "reference tempo" (e.g., 1.0) +- At playback time, resample to match current tempo +- **Pros:** Preserves pitch +- **Cons:** CPU-intensive, requires interpolation + +#### Option C: Time-Stretching (Best Quality) +- Use phase vocoder or similar algorithm +- Stretches/compresses time without changing pitch +- **Pros:** High quality +- **Cons:** Very complex, may exceed 64k budget + +#### Option D: Pre-render at Reference Tempo (Recommended for 64k) +**Key Insight:** For a 64k demo, pre-render everything at a fixed internal tempo. + +```cpp +// At pattern trigger time: +// 1. Composite pattern at "reference tempo" (e.g., 120 BPM baseline) +int frame_offset = (int)(event.beat * REFERENCE_BEAT_DURATION * 32000.0f / DCT_SIZE); + +// 2. Store tempo multiplier with voice +struct Voice { + float playback_speed; // NEW: 1.0 = normal, 2.0 = double speed, etc. + // ... existing fields +}; + +// 3. In synth_render(), advance at variable rate: +void synth_render(float* output_buffer, int num_frames) { + for each voice { + // Advance through spectral frames at tempo-adjusted rate + float frame_advance = playback_speed / DCT_SIZE; + + // Use fractional frame index + float spectral_frame_pos; // NEW: float instead of int + + // Interpolate between frames + int frame0 = (int)spectral_frame_pos; + int frame1 = frame0 + 1; + float frac = spectral_frame_pos - frame0; + + // Blend spectral data from frame0 and frame1 + for (int j = 0; j < DCT_SIZE; ++j) { + windowed_frame[j] = lerp(spectral[frame0][j], spectral[frame1][j], frac); + } + } +} +``` + +**Trade-off:** This changes pitch! But for a demo, this might be acceptable. + +--- + +## Sync with Physical Audio Device + +### Current System +``` +Audio Callback (32kHz fixed) + ↓ +synth_render(buffer, num_frames) ← pulls samples + ↓ +Voices advance: ++buffer_pos, ++current_spectral_frame + ↓ +Fixed playback rate (one spectral frame = DCT_SIZE samples) +``` + +### With Variable Tempo +``` +Audio Callback (32kHz fixed - CANNOT CHANGE) + ↓ +synth_render(buffer, num_frames, current_playback_speed) + ↓ +Voices advance: buffer_pos += playback_speed + ↓ +Variable playback rate (playback_speed can be > 1.0 or < 1.0) +``` + +**Key Point:** Hardware audio rate (32kHz) is FIXED. We cannot change it. + +**Solution:** Adjust how fast we consume spectrogram data: +- `playback_speed = 2.0` → consume frames 2x faster → music plays 2x speed +- `playback_speed = 0.5` → consume frames 0.5x slower → music plays 0.5x speed + +--- + +## Implementation Roadmap + +### Step 1: Remove BPM from TrackerScore ✅ Ready +- Remove `float bpm` field +- Update tracker_compiler to not generate BPM +- Update tests + +### Step 2: Add Global Tempo State +```cpp +// In audio/tracker.h or main.cc +float g_current_tempo = 1.0f; // Multiplier: 1.0 = normal, 2.0 = double speed + +void tracker_set_tempo(float tempo_multiplier); +float tracker_get_tempo(); +``` + +### Step 3: Update tracker_update() +```cpp +void tracker_update(float music_time) { + // music_time is now abstract, not physical seconds + // Patterns trigger when music_time >= trigger.music_time +} +``` + +### Step 4: Update Pattern Compositing (Keep Reference Tempo) +```cpp +// Use fixed reference tempo for compositing (e.g., 120 BPM) +const float REFERENCE_BPM = 120.0f; +const float reference_beat_to_sec = 60.0f / REFERENCE_BPM; + +int frame_offset = (int)(event.beat * reference_beat_to_sec * 32000.0f / DCT_SIZE); +``` + +### Step 5: Variable Playback in Synth (Advanced) +```cpp +// Store playback speed with each voice +struct Voice { + float playback_speed; // Set when voice is triggered + float spectral_frame_pos; // Change to float for interpolation + // ... +}; + +// In synth_trigger_voice(): +v.playback_speed = g_current_tempo; + +// In synth_render(): +// Advance with fractional frame increment +v.spectral_frame_pos += v.playback_speed / DCT_SIZE; + +// Interpolate spectral data (required for smooth tempo changes) +``` + +### Step 6: Update Main Loop +```cpp +// In main.cc +static float g_music_time = 0.0f; +static float g_tempo_scale = 1.0f; // Can be animated! + +void update_game_logic(double physical_time) { + float dt = get_delta_time(); + + // Music time advances at variable rate + g_music_time += dt * g_tempo_scale; + + // Animate tempo (example) + g_tempo_scale = 1.0f + 0.5f * sinf(physical_time * 0.1f); // Oscillate 0.5x - 1.5x + + tracker_set_tempo(g_tempo_scale); + tracker_update(g_music_time); +} +``` + +--- + +## Open Questions + +1. **Pitch Shifting Acceptable?** + - If tempo changes, should pitch change too? + - For demoscene effect: probably YES (classic effect) + - For "realistic" music: NO (need time-stretching) + +2. **Tempo Curve Continuity** + - Should tempo changes be smoothed (acceleration curves)? + - Or instant jumps (may cause audio glitches)? + +3. **Spectral Frame Interpolation** + - Linear interpolation sufficient? + - Or need cubic/sinc for quality? + +4. **Tracker Compilation** + - Should tracker_compiler still know about tempo? + - Or output pure "beat" units? + +--- + +## Recommendation + +**For a 64k demo, I recommend:** + +### Minimal Change Approach (Easiest) +1. Keep BPM for compositing (as "reference tempo") +2. Add `g_current_tempo` multiplier +3. Make synth advance through frames at `tempo * playback_speed` +4. Accept pitch shifting as intentional effect + +**Changes needed:** +- Add `float g_current_tempo` global +- Update `synth_render()` to use `playback_speed` +- Main loop: `music_time += dt * g_current_tempo` +- Tracker: rename `time_sec` → `music_time` (conceptual only) + +**Size impact:** ~100 bytes +**Complexity:** Low +**Enables:** Full variable tempo with pitch shift effect + +### Advanced Approach (Better Quality) +- Implement spectral frame interpolation +- Add time-stretching (phase vocoder lite) +- Size impact: +2-3 KB + +--- + +## Conclusion + +**Is the code ready?** ❌ NO + +**What needs to change?** +1. BPM must become a "reference tempo" for compositing only +2. Add global `tempo_scale` variable +3. Synth must support variable playback speed +4. Main loop must track `music_time` separately from physical time + +**Sync with audio device:** +- Hardware rate (32kHz) is FIXED +- We control playback speed by advancing through spectral frames faster/slower +- This inherently changes pitch (unless we add time-stretching) + +**Recommended next step:** Implement minimal change approach first, then iterate. diff --git a/doc/ANALYSIS_VARIABLE_TEMPO_V2.md b/doc/ANALYSIS_VARIABLE_TEMPO_V2.md new file mode 100644 index 0000000..add347c --- /dev/null +++ b/doc/ANALYSIS_VARIABLE_TEMPO_V2.md @@ -0,0 +1,414 @@ +# Variable Tempo: Simplified Approach (V2) + +## User's Proposal: Trigger Timing Only + +**Key Insight:** Don't change spectrograms or playback speed - just change **when** they trigger! + +### The Simple Solution + +```cpp +// In main loop +static float music_time = 0.0f; +static float tempo_scale = 1.0f; // Can be animated/changed + +void update_game_logic(float dt) { + // Music time advances at variable rate + music_time += dt * tempo_scale; + + // Patterns trigger based on music_time (not physical time) + tracker_update(music_time); +} +``` + +**That's it!** 🎉 + +### What This Achieves + +**Drums, melodies, samples:** All sound identical (no pitch shift) +**Tempo changes:** Patterns trigger faster/slower based on `tempo_scale` +**No synth changes:** Playback rate stays at 32kHz (unchanged) + +### Example Timeline + +**Pattern triggers at music_time = 4.0** + +| tempo_scale | Physical Time | Effect | +|-------------|---------------|--------| +| 1.0 | 4.0s | Normal tempo | +| 2.0 | 2.0s | Triggers 2x earlier (accelerated) | +| 0.5 | 8.0s | Triggers 2x later (slowed) | + +**Visual:** +``` +Physical Time: 0s----1s----2s----3s----4s----5s----6s----7s----8s + ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ + +tempo_scale=1.0: 🥁 (triggers at 4s) + +tempo_scale=2.0: 🥁 (triggers at 2s - accelerated!) + +tempo_scale=0.5: 🥁 (triggers at 8s - slowed) +``` + +### The "Reset" Trick + +**Problem:** Music has accelerated to 2.0x, feels too fast +**Solution:** Reset tempo and switch to denser pattern + +```cpp +// Music accelerating +tempo_scale = 1.0 + t * 0.1; // Gradually speeds up + +// At some point (e.g., tempo_scale = 2.0) +if (tempo_scale >= 2.0) { + tempo_scale = 1.0; // Reset to normal + trigger_pattern_dense(); // Switch to 2x denser pattern +} +``` + +**Pattern Authoring:** +``` +Pattern A (sparse): [kick]---[snare]---[kick]---[snare]--- + beat 0 beat 1 beat 2 beat 3 + +Pattern B (dense): [kick]-[snare]-[kick]-[snare]- + beat 0 beat 0.5 beat 1 beat 1.5 +``` + +**Result:** Music feels the same tempo, but you've "reset" the timeline! + +--- + +## What Needs to Change in Current Code + +### Change 1: Main Loop (main.cc) + +**Current:** +```cpp +void update_game_logic(double t) { + tracker_update((float)t); // ❌ Uses physical time directly +} +``` + +**New:** +```cpp +static float g_music_time = 0.0f; +static float g_tempo_scale = 1.0f; + +void update_game_logic(double t) { + float dt = get_delta_time(); // Physical time delta + + // Music time advances at variable rate + g_music_time += dt * g_tempo_scale; + + tracker_update(g_music_time); // ✅ Uses music time +} +``` + +### Change 2: Tempo Control API (NEW) + +```cpp +// In tracker.h or main.cc +void set_tempo_scale(float scale); +float get_tempo_scale(); +float get_music_time(); +``` + +### Change 3: Tracker (NO CHANGES NEEDED!) + +**tracker.h:** Keep as-is +**tracker.cc:** Keep as-is +**TrackerScore.bpm:** Keep it! (used for compositing patterns) + +The tracker already works with abstract "time" - we just feed it `music_time` instead of physical time. + +### Change 4: Synth (NO CHANGES NEEDED!) + +Spectrograms play back at fixed rate (32kHz). +No pitch shifting, no interpolation needed. + +--- + +## Detailed Example: Accelerating Drum Beat + +### Setup +```cpp +// Pattern: kick on beats 0 and 2, snare on beats 1 and 3 +TrackerEvent drum_events[] = { + {0.0f, KICK_ID, 1.0f, 0.0f}, // Beat 0 + {1.0f, SNARE_ID, 0.9f, 0.0f}, // Beat 1 + {2.0f, KICK_ID, 1.0f, 0.0f}, // Beat 2 + {3.0f, SNARE_ID, 0.9f, 0.0f}, // Beat 3 +}; + +TrackerPattern drum_pattern = {drum_events, 4, 4.0f}; +``` + +### Scenario: Gradual Acceleration + +```cpp +// Main loop +float tempo_scale = 1.0f; + +void update_game_logic(float dt) { + // Gradually accelerate (0.05x faster per second) + tempo_scale += dt * 0.05f; + + music_time += dt * tempo_scale; + tracker_update(music_time); +} +``` + +**Timeline:** +``` +Physical Time: 0s 1s 2s 3s 4s 5s 6s 7s 8s +tempo_scale: 1.0 1.05 1.10 1.15 1.20 1.25 1.30 1.35 1.40 + +Music Time: 0.0 1.05 2.15 3.30 4.50 5.75 7.05 8.40 9.80 + ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ ↓ +Pattern Trigs: 🥁 🥁 🥁 🥁 🥁 🥁 🥁 🥁 🥁 +(every 4 beats) 0 4 8 12 16 20 24 28 32 + +Physical times when patterns trigger: +- Pattern 0: t=0.0s +- Pattern 1: t≈3.8s (instead of 4.0s) +- Pattern 2: t≈7.2s (instead of 8.0s) +- Pattern 3: t≈10.2s (instead of 12.0s) +``` + +**Effect:** Drum beat gradually feels faster, even though drums sound the same! + +--- + +## The "Reset" Strategy + +### Problem +After accelerating to 2.0x, music feels too rushed. Want to maintain energy but reset tempo. + +### Solution: Tempo Reset + Pattern Swap + +**Before Reset:** +- tempo_scale = 2.0 +- Pattern A (sparse): kicks every 1 beat + +**After Reset:** +- tempo_scale = 1.0 +- Pattern B (dense): kicks every 0.5 beats + +**Result:** Same physical rate of kicks, but tempo has "reset"! + +### Code Example + +```cpp +void update_game_logic(float dt) { + music_time += dt * tempo_scale; + + // Gradually accelerate + tempo_scale += dt * 0.1f; + + // Check for reset point + if (tempo_scale >= 2.0f) { + // Reset tempo + tempo_scale = 1.0f; + + // Switch to denser pattern + current_pattern_density *= 2; // 2x more events per beat + + // Optionally: retrigger current section with new density + retrigger_section_with_density(current_pattern_density); + } + + tracker_update(music_time); +} +``` + +### Pattern Authoring Strategy + +**Create patterns at multiple densities:** + +```cpp +// Sparse pattern (quarter notes) +TrackerEvent pattern_1x[] = { + {0.0f, KICK, 1.0f, 0.0f}, + {1.0f, SNARE, 0.9f, 0.0f}, + {2.0f, KICK, 1.0f, 0.0f}, + {3.0f, SNARE, 0.9f, 0.0f}, +}; + +// Dense pattern (eighth notes) +TrackerEvent pattern_2x[] = { + {0.0f, KICK, 1.0f, 0.0f}, + {0.5f, HIHAT, 0.6f, 0.0f}, + {1.0f, SNARE, 0.9f, 0.0f}, + {1.5f, HIHAT, 0.6f, 0.0f}, + {2.0f, KICK, 1.0f, 0.0f}, + {2.5f, HIHAT, 0.6f, 0.0f}, + {3.0f, SNARE, 0.9f, 0.0f}, + {3.5f, HIHAT, 0.6f, 0.0f}, +}; + +// Very dense pattern (sixteenth notes) +TrackerEvent pattern_4x[] = { + // ... even more events +}; +``` + +**Use pattern density to match tempo:** +- tempo_scale = 1.0x → use pattern_1x +- tempo_scale = 2.0x → reset to 1.0x, use pattern_2x +- tempo_scale = 4.0x → reset to 1.0x, use pattern_4x + +--- + +## Comparison: Old Proposal vs. New Proposal + +| Aspect | Old Proposal (Complex) | New Proposal (Simple) | +|--------|------------------------|----------------------| +| **Synth Changes** | Variable playback speed, interpolation | ❌ None | +| **Pitch Shifting** | Yes (side effect) | ❌ No | +| **Spectrograms** | Modified at playback | ✅ Unchanged | +| **Complexity** | High (12 hours) | Low (1 hour) | +| **Code Changes** | ~500 lines | ~20 lines | +| **Size Impact** | +2-3 KB | +50 bytes | +| **Quality** | Good with interpolation | ✅ Perfect (no artifacts) | + +--- + +## Implementation Checklist + +### Step 1: Add Music Time State (5 minutes) +```cpp +// In main.cc (global scope) +static float g_music_time = 0.0f; +static float g_tempo_scale = 1.0f; +static float g_last_physical_time = 0.0f; +``` + +### Step 2: Update Main Loop (10 minutes) +```cpp +void update_game_logic(double physical_time) { + // Calculate delta + float dt = (float)(physical_time - g_last_physical_time); + g_last_physical_time = physical_time; + + // Advance music time at scaled rate + g_music_time += dt * g_tempo_scale; + + // Update tracker with music time (not physical time) + tracker_update(g_music_time); +} + +// In main loop +while (!should_close) { + double current_time = platform_state.time + seek_time; + update_game_logic(current_time); // Pass physical time + // ... +} +``` + +### Step 3: Add Tempo Control API (10 minutes) +```cpp +// In main.cc or new file +void set_tempo_scale(float scale) { + g_tempo_scale = scale; +} + +float get_tempo_scale() { + return g_tempo_scale; +} + +float get_music_time() { + return g_music_time; +} +``` + +### Step 4: Test with Simple Acceleration (10 minutes) +```cpp +// Temporary test: gradual acceleration +void update_game_logic(double physical_time) { + float dt = get_delta_time(); + + // TEST: Accelerate from 1.0x to 2.0x over 10 seconds + g_tempo_scale = 1.0f + (physical_time / 10.0f); + g_tempo_scale = fminf(g_tempo_scale, 2.0f); + + g_music_time += dt * g_tempo_scale; + tracker_update(g_music_time); +} +``` + +### Step 5: Implement Reset Logic (15 minutes) +```cpp +// When tempo hits threshold, reset and switch patterns +if (g_tempo_scale >= 2.0f) { + g_tempo_scale = 1.0f; + // Trigger denser pattern here +} +``` + +### Step 6: Create Pattern Variants (tracker compiler work) +- Author patterns at 1x, 2x, 4x densities +- Map tempo ranges to pattern variants + +--- + +## Advantages of This Approach + +✅ **Simple:** ~20 lines of code +✅ **No pitch shift:** Samples sound identical +✅ **No synth changes:** Risk-free +✅ **Flexible:** Easy to animate tempo curves +✅ **Tiny size impact:** ~50 bytes +✅ **Perfect quality:** No interpolation artifacts + +--- + +## Example: Tempo Curves + +### Linear Acceleration +```cpp +tempo_scale = 1.0f + t * 0.1f; // 0.1x faster per second +``` + +### Exponential Acceleration +```cpp +tempo_scale = powf(2.0f, t / 10.0f); // Doubles every 10 seconds +``` + +### Oscillating Tempo +```cpp +tempo_scale = 1.0f + 0.3f * sinf(t * 0.5f); // Wave between 0.7x and 1.3x +``` + +### Manual Control (BPM curve from score) +```cpp +// Define tempo curve in tracker score +float tempo_curve[] = {1.0f, 1.1f, 1.3f, 1.6f, 2.0f, 1.0f, ...}; +tempo_scale = interpolate_tempo_curve(music_time); +``` + +--- + +## Conclusion + +**Your proposal is brilliant!** 🎯 + +### What You Get +- Variable tempo without modifying spectrograms +- No pitch shifting (drums sound like drums) +- Simple implementation (~1 hour) +- Tiny code size (~50 bytes) +- Perfect audio quality + +### What You Need to Do +1. Add `g_music_time` and `g_tempo_scale` to main loop +2. Compute `music_time += dt * tempo_scale` +3. Pass `music_time` to `tracker_update()` +4. Animate `tempo_scale` however you want! + +### For the "Reset" Trick +- Author patterns at 1x, 2x, 4x note densities +- When tempo hits 2.0x, reset to 1.0x and switch to denser pattern +- Result: Same perceived tempo, but timeline has reset + +**Ready to implement?** This is a 1-hour task! -- cgit v1.2.3