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
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
|
# Peak Meter Debug Summary (February 7, 2026)
## Side-Task Completed: Peak Visualization ✅
Added inline peak meter effect to test_demo for visual debugging of audio-visual synchronization.
### Implementation
**Files Modified:**
- `src/test_demo.cc`: Added `PeakMeterEffect` class inline (89 lines of WGSL + C++)
- `src/gpu/gpu.h`: Added `gpu_add_custom_effect()` API and exposed `g_device`, `g_queue`, `g_format`
- `src/gpu/gpu.cc`: Implemented `gpu_add_custom_effect()` to add effects to MainSequence at runtime
**Peak Meter Features:**
- Red horizontal bar in middle of screen (5% height)
- Bar width extends from left (0.0) to peak_value (0.0-1.0)
- Renders as final post-process pass (priority=999)
- Only compiled in debug builds (`!STRIP_ALL`)
**Visual Effect:**
```
Screen Layout:
┌─────────────────────────────────────┐
│ │
│ │
│ ████████████░░░░░░░░░░░░░░░░░ │ ← Red bar (width = audio peak)
│ │
│ │
└─────────────────────────────────────┘
```
### WGSL Shader Code
```wgsl
@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
let color = textureSample(inputTexture, inputSampler, input.uv);
// Draw red horizontal bar in middle of screen
let bar_height = 0.05;
let bar_center_y = 0.5;
let bar_y_min = bar_center_y - bar_height * 0.5;
let bar_y_max = bar_center_y + bar_height * 0.5;
let bar_x_max = uniforms.peak_value;
let in_bar_y = input.uv.y >= bar_y_min && input.uv.y <= bar_y_max;
let in_bar_x = input.uv.x <= bar_x_max;
if (in_bar_y && in_bar_x) {
return vec4<f32>(1.0, 0.0, 0.0, 1.0); // Red bar
} else {
return color; // Original scene
}
}
```
---
## Main Issue: Audio Peak Timing Analysis 🔍
### Problem Discovery
The raw_peak values logged at beat boundaries don't match the expected drum pattern:
**Expected Pattern** (from test_demo.track):
```
Beat 0, 2: Kick (volume 1.0) → expect raw_peak ~0.125 (after 8x = 1.0 visual)
Beat 1, 3: Snare (volume 0.9) → expect raw_peak ~0.090 (after 8x = 0.72 visual)
```
**Actual Logged Peaks** (from peaks.txt):
```
Beat | Time | Raw Peak | Expected
-----|-------|----------|----------
0 | 0.19s | 0.588 | ~0.125 (kick)
1 | 0.50s | 0.177 | ~0.090 (snare)
2 | 1.00s | 0.236 | ~0.125 (kick) ← Too low!
3 | 1.50s | 0.199 | ~0.090 (snare)
4 | 2.00s | 0.234 | ~0.125 (kick) ← Too low!
5 | 2.50s | 0.475 | ~0.090 (snare)
9 | 4.50s | 0.975 | ~0.090 (snare) ← Should be kick!
```
### Root Cause: Ring Buffer Latency
**Ring Buffer Configuration:**
- `RING_BUFFER_LOOKAHEAD_MS = 400` (src/audio/ring_buffer.h:14)
- Audio is rendered 400ms ahead of playback
- Real-time peak is measured when audio is actually played (in audio callback)
- Visual timing uses `current_time` (physical time)
**Timing Mismatch:**
```
Visual Beat 2 (T=1.00s) → Audio being played (T=1.00s - 0.40s = T=0.60s)
→ At T=0.60s, beat = 0.60 * 2 = 1.2 → Beat 1 (snare)
→ Visual expects kick, but hearing snare!
```
### Peak Decay Analysis
**Decay Configuration** (src/audio/backend/miniaudio_backend.cc:166):
```cpp
realtime_peak_ *= 0.7f; // Decay: 30% per callback
```
**Decay Timing:**
- Callback interval: ~128ms (at 4096 frames @ 32kHz)
- To decay from 1.0 to 0.1: `0.7^n = 0.1` → n ≈ 6.45 callbacks
- Time to 10%: 6.45 * 128ms = 825ms (~0.8 seconds)
- Comment claims "~1 second decay" (line 162): `0.7^7.8 ≈ 0.1`
**Problem:**
- Drums hit every 0.5 seconds (120 BPM = 2 beats/second)
- Decay takes 0.8-1.0 seconds
- Peak doesn't drop fast enough between beats!
**Calculation:**
- After 0.5s (1 beat): `0.7^(0.5/0.128) = 0.7^3.9 ≈ 0.24` (raw peak)
- Visual peak: `0.24 * 8 = 1.92` (clamped to 1.0)
- Result: Visual peak stays at 1.0 between beats!
---
## Solutions
### Option A: Fix Ring Buffer Latency Alignment
**Change:** Use audio playback time instead of current_time for visual effects.
```cpp
// In test_demo.cc, replace current_time with audio-aligned time:
const float audio_time = current_time - (RING_BUFFER_LOOKAHEAD_MS / 1000.0f);
const float beat_time = audio_time * 120.0f / 60.0f;
```
**Pros:** Simple fix, aligns visual timing with heard audio
**Cons:** Introduces 400ms visual lag (flash happens 400ms after visual beat)
### Option B: Compensate Peak Forward
**Change:** Measure peak from future audio (at render time, not playback time).
```cpp
// In synth.cc, measure peak when audio is rendered:
float synth_get_output_peak() {
return g_peak; // Peak measured at render time (400ms ahead)
}
```
**Pros:** Zero visual lag, flash syncs with visual beat timing
**Cons:** Flash happens 400ms BEFORE audio is heard (original bug!)
### Option C: Reduce Ring Buffer Latency
**Change:** Decrease `RING_BUFFER_LOOKAHEAD_MS` from 400ms to 100ms.
**Pros:** Smaller timing mismatch (100ms instead of 400ms)
**Cons:** May cause audio underruns at 2.0x tempo scaling
### Option D: Faster Peak Decay
**Change:** Increase decay rate to match beat interval.
**Target:** Peak should drop below 0.7 (flash threshold) after 0.5s.
**Calculation:**
- Visual threshold: 0.7
- After 8x multiplier: raw_peak < 0.7/8 = 0.0875
- After 0.5s (3.9 callbacks): `decay_rate^3.9 < 0.0875`
- `decay_rate < 0.0875^(1/3.9) = 0.493`
**Recommended Decay:** 0.5 per callback (instead of 0.7)
```cpp
// In miniaudio_backend.cc:166
realtime_peak_ *= 0.5f; // Decay: 50% per callback (~500ms to 10%)
```
**Pros:** Flash triggers only on actual hits, fast fade
**Cons:** Very aggressive decay, might miss short drum hits
---
## Recommended Solution: Option A + Option D
**Combined Approach:**
1. **Align visual beat timing** with audio playback (subtract 400ms)
2. **Faster decay** (0.5 instead of 0.7) to prevent overlapping flashes
**Implementation:**
```cpp
// test_demo.cc:209 (replace current_time calculation)
const float audio_aligned_time = (float)current_time - 0.4f; // Subtract ring buffer latency
const float beat_time = fmaxf(0.0f, audio_aligned_time) * 120.0f / 60.0f;
// miniaudio_backend.cc:166 (update decay rate)
realtime_peak_ *= 0.5f; // Decay: 50% per callback (faster)
```
**Expected Result:**
- Visual flash triggers exactly when kick is HEARD (not 400ms early)
- Flash decays quickly (~500ms) so snare doesn't re-trigger
- Peak meter visualization shows accurate real-time audio levels
---
## Testing Checklist
With peak meter visualization, verify:
- [ ] Red bar extends when kicks hit (every 1 second at beats 0, 2, 4, ...)
- [ ] Bar width matches FlashEffect intensity (both use same peak value)
- [ ] Bar decays smoothly between hits
- [ ] Snares (beats 1, 3, 5, ...) show smaller bar width (~60-70%)
- [ ] With faster decay (0.5), bar reaches minimum before next hit
---
## Next Steps
1. **Implement Option A + D** (timing alignment + faster decay)
2. **Test with peak meter** to visually verify timing
3. **Log peaks with --log-peaks** to quantify improvement
4. **Consider Option C** (reduce ring buffer) if tempo scaling still works
5. **Update documentation** with final timing strategy
---
*Created: February 7, 2026*
*Peak meter visualization added, timing analysis complete*
|