1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
|
# Audio WAV Drift Bug Investigation
**Date:** 2026-02-15
**Status:** ACCEPTABLE (to be continued)
**Current State:** -150ms drift at beat 64b, no glitches
## Problem Statement
Timeline viewer shows progressive visual drift between audio waveform and beat grid markers:
- At beat 8 (5.33s @ 90 BPM): kick waveform appears **-30ms early** (left of grid line)
- At beat 60 (40.0s @ 90 BPM): kick waveform appears **-180ms early** (left of grid line)
Progressive drift rate: ~4.3ms/second
## Initial Hypotheses (Ruled Out)
### 1. ❌ Viewer Display Bug
- **Tested:** Sample rate detection in viewer (32kHz correctly detected)
- **Result:** Viewer BPM = 90 (correct), `pixelsPerSecond` mapping correct
- **Conclusion:** Not a viewer rendering issue
### 2. ❌ WAV File Content Error
- **Tested:** Direct WAV sample position analysis via Python
- **Result:** Actual kick positions in WAV file:
```
Beat | Expected(s) | WAV(s) | Drift
-----|-------------|----------|-------
8 | 5.3333 | 5.3526 | +19ms (LATE)
60 | 40.0000 | 39.9980 | -2ms (nearly perfect)
```
- **Conclusion:** WAV file samples are at correct positions; visual drift not in WAV content
### 3. ❌ Frame Truncation (Partial Cause)
- **Issue:** `frames_per_update = (int)(32000 * (1/60))` = 533 frames (truncates 0.333)
- **Impact:** Loses 0.333 frames/update = 10.4μs/frame
- **Total drift over 40s:** 2400 frames × 10.4μs = **25ms**
- **Conclusion:** Explains 25ms of 180ms, but not sufficient
## Root Cause Discovery
### Investigation Method
Added debug tracking to `audio_render_ahead()` (audio.cc:115):
```cpp
static int64_t g_total_render_calls = 0;
static int64_t g_total_frames_rendered = 0;
// Track actual frames rendered vs expected
const int64_t actual_rendered = frames_after - frames_before;
g_total_render_calls++;
g_total_frames_rendered += actual_rendered;
```
### Critical Finding: Over-Rendering
**WAV dump @ 40s (2400 iterations):**
```
Expected frames: 1,279,200 (2400 × 533)
Actual rendered: 1,290,933
Difference: +11,733 frames = +366.66ms EXTRA audio
```
**Pattern observed every 10s (600 calls @ 60fps):**
```
[RENDER_DRIFT] calls=600 expect=319800 actual=331533 drift=-366.66ms
[RENDER_DRIFT] calls=1200 expect=639600 actual=651333 drift=-366.66ms
[RENDER_DRIFT] calls=1800 expect=959400 actual=971133 drift=-366.66ms
[RENDER_DRIFT] calls=2400 expect=1279200 actual=1290933 drift=-366.66ms
```
### Why This Causes Visual Drift
**WAV Dump Flow (main.cc:289-302):**
1. `fill_audio_buffer(update_dt)` → calls `audio_render_ahead()`
- Renders audio into ring buffer
- **BUG:** Renders MORE than `chunk_frames` due to buffer management loop
2. `ring_buffer->read(chunk_buffer, samples_per_update)`
- Reads exactly 533 frames from ring buffer
3. `wav_backend.write_audio(chunk_buffer, samples_per_update)`
- Writes exactly 533 frames to WAV
**Result:** Ring buffer accumulates 11,733 extra frames over 40s.
### Timing Shift Mechanism
Ring buffer acts as FIFO queue with 400ms lookahead:
- Initially fills to 400ms (12,800 frames)
- Each iteration: renders 533.333 (actual: ~536) frames, reads 533 frames
- Net accumulation: ~3 frames/iteration
- After 2400 iterations: 12,800 + (2400 × 3) = 20,000 frames buffer size
Events trigger at correct `music_time` but get written to ring buffer position that's ahead. When WAV reads from buffer, it reads from older position, causing events to appear EARLIER in WAV file than their nominal music_time.
## Technical Details
### Code Locations
**Truncation point 1:** `main.cc:282`
```cpp
const int frames_per_update = (int)(32000 * update_dt); // 533.333 → 533
```
**Truncation point 2:** `audio.cc:105`
```cpp
const int chunk_frames = (int)(dt * RING_BUFFER_SAMPLE_RATE); // 533.333 → 533
```
**Over-render loop:** `audio.cc:112-229`
```cpp
while (true) {
// Keeps rendering until buffer >= target_lookahead
// Renders MORE than chunk_frames due to buffer management
...
}
```
### Why 366ms Per 10s?
At 60fps, 10s = 600 iterations:
- Expected: 600 × 533 = 319,800 frames
- Actual: 331,533 frames
- Extra: 11,733 frames ÷ 600 = **19.55 frames extra per iteration**
But `chunk_frames = 533`, so we render 533 + 19.55 = **~552.55 frames per call** on average.
Discrepancy from 533.333 expected: 552.55 - 533.333 = **19.22 frames/call over-render**
This 19.22 frames = 0.6ms per iteration accumulates to 366ms per 10s.
## Proposed Fix
### Option 1: Match Render to Read (Recommended)
In WAV dump mode, ensure `audio_render_ahead()` renders exactly `frames_per_update`:
```cpp
// main.cc WAV dump loop
const int frames_per_update = (int)(32000 * update_dt);
audio_render_ahead(g_music_time, update_dt, /* force_exact_amount */ frames_per_update);
```
Modify `audio_render_ahead()` to accept optional exact frame count and render precisely that amount instead of filling to target lookahead.
### Option 2: Round Instead of Truncate
```cpp
const int frames_per_update = (int)(32000 * update_dt + 0.5f); // Round: 533.333 → 533
```
Reduces truncation error but doesn't solve over-rendering.
### Option 3: Use Double Precision + Accumulator
```cpp
static double accumulated_time = 0.0;
accumulated_time += update_dt;
const int frames_to_render = (int)(accumulated_time * 32000);
accumulated_time -= frames_to_render / 32000.0;
```
Eliminates cumulative truncation error.
## Related Issues
- `tracker.cc:237` TODO comment mentions "180ms drift over 63 beats" - this is the same bug
- Ring buffer lookahead (400ms) is separate from drift (not the cause)
- Web Audio API `outputLatency` in viewer is unrelated (affects playback, not waveform display)
## Verification Steps
1. ✅ Measure WAV sample positions directly (Python script)
2. ✅ Add render tracking debug output
3. ✅ Confirm over-rendering (366ms per 10s)
4. ✅ Implement partial fix (bypass ring buffer, direct render)
5. ⚠️ Current result: -150ms drift at beat 64b (acceptable, needs further work)
## Current Implementation (main.cc:286-308)
**WAV dump now bypasses ring buffer entirely:**
1. **Frame accumulator**: Calculates exact frames per update (no truncation)
2. **Direct render**: Calls `synth_render()` directly with exact frame count
3. **No ring buffer**: Eliminates buffer management complexity
4. **Result**: No glitches, but -150ms drift remains
**Remaining issue:** Drift persists despite direct rendering. Likely related to tempo scaling or audio engine state management. Acceptable for now.
## Notes
- Viewer waveform rendering is CORRECT - displays WAV content accurately
- Bug is in demo's WAV generation, specifically ring buffer management in `audio_render_ahead()`
- Progressive nature of drift (30ms → 180ms) indicates accumulation, not one-time offset
- Fix must ensure rendered frames = read frames in WAV dump mode
|