From a09e36b60e1755b07796e8b0cb9082c522795adf Mon Sep 17 00:00:00 2001 From: skal Date: Wed, 4 Feb 2026 13:25:38 +0100 Subject: docs: Add variable tempo architecture analysis Comprehensive analysis of BPM dependencies and variable tempo requirements. Key findings: - BPM is baked into spectrogram generation (cannot change dynamically) - Music time currently tied 1:1 to physical time - Synth playback rate is fixed (no variable speed support) Proposes solution: - Global tempo_scale multiplier - Unified music_time abstraction - Variable synth playback with spectral interpolation - Trade-off: pitch shifts with tempo (acceptable for demos) Implementation roadmap: ~12 hours estimated effort Co-Authored-By: Claude Sonnet 4.5 --- ANALYSIS_VARIABLE_TEMPO.md | 372 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 ANALYSIS_VARIABLE_TEMPO.md (limited to 'ANALYSIS_VARIABLE_TEMPO.md') diff --git a/ANALYSIS_VARIABLE_TEMPO.md b/ANALYSIS_VARIABLE_TEMPO.md new file mode 100644 index 0000000..f09996b --- /dev/null +++ b/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. -- cgit v1.2.3