summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CMakeLists.txt60
-rw-r--r--assets/test_demo.seq7
-rw-r--r--assets/test_demo.track32
-rw-r--r--src/generated/test_demo_music.cc77
-rw-r--r--src/generated/test_demo_timeline.cc15
-rw-r--r--src/test_demo.cc216
-rw-r--r--test_demo_README.md223
7 files changed, 630 insertions, 0 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 2bf4d4a..4fc00b0 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -307,6 +307,66 @@ if (DEMO_SIZE_OPT)
endif()
endif()
+# ==============================================================================
+# Test Demo (Audio/Visual Sync Tool)
+# ==============================================================================
+
+# Timeline generation
+set(TEST_DEMO_SEQ_PATH ${CMAKE_CURRENT_SOURCE_DIR}/assets/test_demo.seq)
+set(GENERATED_TEST_DEMO_TIMELINE_CC ${CMAKE_CURRENT_SOURCE_DIR}/src/generated/test_demo_timeline.cc)
+add_custom_command(
+ OUTPUT ${GENERATED_TEST_DEMO_TIMELINE_CC}
+ COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_SOURCE_DIR}/src/generated
+ COMMAND ${SEQ_COMPILER_CMD} ${TEST_DEMO_SEQ_PATH} ${GENERATED_TEST_DEMO_TIMELINE_CC}
+ DEPENDS ${SEQ_COMPILER_DEPENDS} ${TEST_DEMO_SEQ_PATH} src/gpu/demo_effects.h
+ COMMENT "Compiling test_demo sequence..."
+)
+add_custom_target(generate_test_demo_timeline ALL DEPENDS ${GENERATED_TEST_DEMO_TIMELINE_CC})
+
+# Music generation
+set(TEST_DEMO_TRACK_PATH ${CMAKE_CURRENT_SOURCE_DIR}/assets/test_demo.track)
+set(GENERATED_TEST_DEMO_MUSIC_CC ${CMAKE_CURRENT_SOURCE_DIR}/src/generated/test_demo_music.cc)
+add_custom_command(
+ OUTPUT ${GENERATED_TEST_DEMO_MUSIC_CC}
+ COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_SOURCE_DIR}/src/generated
+ COMMAND ${TRACKER_COMPILER_FINAL_CMD} ${TEST_DEMO_TRACK_PATH} ${GENERATED_TEST_DEMO_MUSIC_CC}
+ DEPENDS ${TRACKER_COMPILER_FINAL_DEPENDS} ${TEST_DEMO_TRACK_PATH} tracker_compiler_host
+ COMMENT "Compiling test_demo music..."
+)
+add_custom_target(generate_test_demo_music ALL DEPENDS ${GENERATED_TEST_DEMO_MUSIC_CC})
+
+# Build executable (uses main demo assets)
+add_demo_executable(
+ test_demo
+ src/test_demo.cc
+ ${PLATFORM_SOURCES}
+ ${GEN_DEMO_CC}
+ ${GENERATED_TEST_DEMO_TIMELINE_CC}
+ ${GENERATED_TEST_DEMO_MUSIC_CC}
+)
+
+add_dependencies(test_demo generate_demo_assets generate_test_demo_timeline generate_test_demo_music)
+
+if (APPLE)
+ target_link_libraries(test_demo PRIVATE 3d gpu audio procedural util ${DEMO_LIBS})
+else()
+ target_link_libraries(test_demo PRIVATE -Wl,--start-group 3d gpu audio procedural util -Wl,--end-group ${DEMO_LIBS})
+endif()
+
+# Size optimizations
+if (DEMO_SIZE_OPT)
+ if (MSVC)
+ target_compile_options(test_demo PRIVATE /Os /GS-)
+ target_link_options(test_demo PRIVATE /OPT:REF /OPT:ICF /INCREMENTAL:NO)
+ elseif (APPLE)
+ target_compile_options(test_demo PRIVATE -Os)
+ target_link_options(test_demo PRIVATE -Wl,-dead_strip)
+ else ()
+ target_compile_options(test_demo PRIVATE -Os -ffunction-sections -fdata-sections)
+ target_link_options(test_demo PRIVATE -Wl,--gc-sections -s)
+ endif()
+endif()
+
#-- - Tests -- -
enable_testing()
if(DEMO_BUILD_TESTS)
diff --git a/assets/test_demo.seq b/assets/test_demo.seq
new file mode 100644
index 0000000..6dc26ca
--- /dev/null
+++ b/assets/test_demo.seq
@@ -0,0 +1,7 @@
+# Minimal timeline for audio/visual sync testing
+# BPM 120 (set in test_demo.track)
+
+SEQUENCE 0.0 0 "Main Loop"
+ EFFECT + FlashEffect 0.0 16.0
+
+END_DEMO 32b
diff --git a/assets/test_demo.track b/assets/test_demo.track
new file mode 100644
index 0000000..8c06100
--- /dev/null
+++ b/assets/test_demo.track
@@ -0,0 +1,32 @@
+# Minimal drum beat for audio/visual sync testing
+# Pattern: kick-snare-kick-snare, crash every 4th bar
+# Includes NOTE_A4 (440 Hz) at start of each bar for testing
+
+SAMPLE ASSET_KICK_1
+SAMPLE ASSET_SNARE_1
+SAMPLE ASSET_CRASH_1
+
+PATTERN drums_basic
+ 0.0, ASSET_KICK_1, 1.0, 0.0
+ 0.0, NOTE_A4, 0.5, 0.0
+ 1.0, ASSET_SNARE_1, 0.9, 0.0
+ 2.0, ASSET_KICK_1, 1.0, 0.0
+ 3.0, ASSET_SNARE_1, 0.9, 0.0
+
+PATTERN drums_with_crash
+ 0.0, ASSET_KICK_1, 1.0, 0.0
+ 0.0, ASSET_CRASH_1, 0.85, 0.0
+ 0.0, NOTE_A4, 0.5, 0.0
+ 1.0, ASSET_SNARE_1, 0.9, 0.0
+ 2.0, ASSET_KICK_1, 1.0, 0.0
+ 3.0, ASSET_SNARE_1, 0.9, 0.0
+
+SCORE
+ 0.0, drums_basic
+ 2.0, drums_basic
+ 4.0, drums_with_crash
+ 6.0, drums_basic
+ 8.0, drums_basic
+ 10.0, drums_basic
+ 12.0, drums_with_crash
+ 14.0, drums_basic
diff --git a/src/generated/test_demo_music.cc b/src/generated/test_demo_music.cc
new file mode 100644
index 0000000..3fdd2a1
--- /dev/null
+++ b/src/generated/test_demo_music.cc
@@ -0,0 +1,77 @@
+// Generated by tracker_compiler. Do not edit.
+
+#include "audio/tracker.h"
+
+#include "generated/assets.h"
+
+const NoteParams g_tracker_samples[] = {
+ { 0 }, // ASSET_KICK_1 (ASSET)
+ { 0 }, // ASSET_SNARE_1 (ASSET)
+ { 0 }, // ASSET_CRASH_1 (ASSET)
+ { 440.0f, 0.50f, 1.0f, 0.01f, 0.0f, 0.0f, 0.0f, 3, 0.6f, 0.0f, 0.0f }, // NOTE_A4
+};
+const uint32_t g_tracker_samples_count = 4;
+
+const AssetId g_tracker_sample_assets[] = {
+ AssetId::ASSET_KICK_1,
+ AssetId::ASSET_SNARE_1,
+ AssetId::ASSET_CRASH_1,
+ AssetId::ASSET_LAST_ID,
+};
+
+static const TrackerEvent PATTERN_EVENTS_drums_basic[] = {
+ { 0.0f, 0, 1.0f, 0.0f },
+ { 0.0f, 3, 0.5f, 0.0f },
+ { 1.0f, 1, 0.9f, 0.0f },
+ { 2.0f, 0, 1.0f, 0.0f },
+ { 3.0f, 1, 0.9f, 0.0f },
+};
+static const TrackerEvent PATTERN_EVENTS_drums_with_crash[] = {
+ { 0.0f, 0, 1.0f, 0.0f },
+ { 0.0f, 2, 0.9f, 0.0f },
+ { 0.0f, 3, 0.5f, 0.0f },
+ { 1.0f, 1, 0.9f, 0.0f },
+ { 2.0f, 0, 1.0f, 0.0f },
+ { 3.0f, 1, 0.9f, 0.0f },
+};
+
+const TrackerPattern g_tracker_patterns[] = {
+ { PATTERN_EVENTS_drums_basic, 5, 4.0f }, // drums_basic
+ { PATTERN_EVENTS_drums_with_crash, 6, 4.0f }, // drums_with_crash
+};
+const uint32_t g_tracker_patterns_count = 2;
+
+static const TrackerPatternTrigger SCORE_TRIGGERS[] = {
+ { 0.0f, 0 },
+ { 2.0f, 0 },
+ { 4.0f, 1 },
+ { 6.0f, 0 },
+ { 8.0f, 0 },
+ { 10.0f, 0 },
+ { 12.0f, 1 },
+ { 14.0f, 0 },
+};
+
+const TrackerScore g_tracker_score = {
+ SCORE_TRIGGERS, 8, 120.0f
+};
+
+// ============================================================
+// RESOURCE USAGE ANALYSIS (for synth.h configuration)
+// ============================================================
+// Total samples: 4 (3 assets + 1 generated notes)
+// Max simultaneous pattern triggers: 1
+// Estimated max polyphony: 5 voices
+//
+// REQUIRED (minimum to avoid pool exhaustion):
+// MAX_VOICES: 5
+// MAX_SPECTROGRAMS: 8 (no caching)
+//
+// RECOMMENDED (with 50% safety margin):
+// MAX_VOICES: 10
+// MAX_SPECTROGRAMS: 12 (no caching)
+//
+// NOTE: With spectrogram caching by note parameters,
+// MAX_SPECTROGRAMS could be reduced to ~4
+// ============================================================
+
diff --git a/src/generated/test_demo_timeline.cc b/src/generated/test_demo_timeline.cc
new file mode 100644
index 0000000..75c5fe6
--- /dev/null
+++ b/src/generated/test_demo_timeline.cc
@@ -0,0 +1,15 @@
+// Auto-generated by seq_compiler. Do not edit.
+#include "gpu/demo_effects.h"
+#include "gpu/effect.h"
+
+float GetDemoDuration() {
+ return 16.000000f;
+}
+
+void LoadTimeline(MainSequence& main_seq, WGPUDevice device, WGPUQueue queue, WGPUTextureFormat format) {
+ {
+ auto seq = std::make_shared<Sequence>();
+ seq->add_effect(std::make_shared<FlashEffect>(device, queue, format), 0.0f, 16.0f, 0);
+ main_seq.add_sequence(seq, 0.0f, 0);
+ }
+}
diff --git a/src/test_demo.cc b/src/test_demo.cc
new file mode 100644
index 0000000..1664f7c
--- /dev/null
+++ b/src/test_demo.cc
@@ -0,0 +1,216 @@
+// Minimal audio/visual synchronization test tool
+// Plays simple drum beat with synchronized screen flashes
+
+#include "audio/audio.h"
+#include "audio/audio_engine.h"
+#include "audio/synth.h"
+#include "generated/assets.h" // Note: uses main demo asset bundle
+#include "gpu/demo_effects.h"
+#include "gpu/gpu.h"
+#include "platform.h"
+#include <cmath>
+#include <cstdio>
+#include <cstdlib>
+#include <cstring>
+
+// External declarations from generated files
+extern float GetDemoDuration();
+extern void LoadTimeline(MainSequence& main_seq, WGPUDevice device, WGPUQueue queue, WGPUTextureFormat format);
+
+#if !defined(STRIP_ALL)
+static void print_usage(const char* prog_name) {
+ printf("Usage: %s [OPTIONS]\n", prog_name);
+ printf("\nMinimal audio/visual synchronization test tool.\n");
+ printf("Plays a simple drum beat with synchronized screen flashes.\n");
+ printf("\nOptions:\n");
+ printf(" --help Show this help message and exit\n");
+ printf(" --fullscreen Run in fullscreen mode\n");
+ printf(" --resolution WxH Set window resolution (e.g., 1024x768)\n");
+ printf(" --tempo Enable tempo variation test mode\n");
+ printf(" (alternates between acceleration and deceleration)\n");
+ printf(" --log-peaks FILE Log audio peaks to FILE for gnuplot visualization\n");
+ printf("\nExamples:\n");
+ printf(" %s --fullscreen\n", prog_name);
+ printf(" %s --resolution 1024x768 --tempo\n", prog_name);
+ printf(" %s --log-peaks peaks.txt\n", prog_name);
+ printf("\nControls:\n");
+ printf(" ESC Exit the demo\n");
+ printf(" F Toggle fullscreen\n");
+}
+#endif
+
+int main(int argc, char** argv) {
+ // Parse command-line
+ PlatformState platform_state;
+ bool fullscreen_enabled = false;
+ bool tempo_test_enabled = false;
+ int width = 1280;
+ int height = 720;
+ const char* log_peaks_file = nullptr;
+
+#if !defined(STRIP_ALL)
+ for (int i = 1; i < argc; ++i) {
+ if (strcmp(argv[i], "--help") == 0) {
+ print_usage(argv[0]);
+ return 0;
+ } else if (strcmp(argv[i], "--fullscreen") == 0) {
+ fullscreen_enabled = true;
+ } else if (strcmp(argv[i], "--tempo") == 0) {
+ tempo_test_enabled = true;
+ } else if (strcmp(argv[i], "--resolution") == 0 && i + 1 < argc) {
+ const char* res_str = argv[++i];
+ int w, h;
+ if (sscanf(res_str, "%dx%d", &w, &h) == 2) {
+ width = w;
+ height = h;
+ }
+ } else if (strcmp(argv[i], "--log-peaks") == 0 && i + 1 < argc) {
+ log_peaks_file = argv[++i];
+ }
+ }
+#else
+ (void)argc;
+ (void)argv;
+ fullscreen_enabled = true;
+#endif
+
+ // Initialize platform, GPU, audio
+ platform_state = platform_init(fullscreen_enabled, width, height);
+ gpu_init(&platform_state);
+ audio_init();
+
+ static AudioEngine g_audio_engine;
+ g_audio_engine.init();
+
+ // Music time tracking with optional tempo variation
+ static float g_music_time = 0.0f;
+ static double g_last_physical_time = 0.0;
+ static float g_tempo_scale = 1.0f;
+
+ auto fill_audio_buffer = [&](double t) {
+ const float dt = (float)(t - g_last_physical_time);
+ g_last_physical_time = t;
+
+ // Calculate tempo scale if --tempo flag enabled
+ if (tempo_test_enabled) {
+ // Each bar = 2 seconds at 120 BPM (4 beats)
+ const float bar_duration = 2.0f;
+ const int bar_number = (int)(t / bar_duration);
+ const float bar_progress = fmodf((float)t, bar_duration) / bar_duration; // 0.0-1.0 within bar
+
+ if (bar_number % 2 == 0) {
+ // Even bars: Ramp from 1.0x → 1.5x
+ g_tempo_scale = 1.0f + (0.5f * bar_progress);
+ } else {
+ // Odd bars: Ramp from 1.0x → 0.66x
+ g_tempo_scale = 1.0f - (0.34f * bar_progress);
+ }
+ } else {
+ g_tempo_scale = 1.0f; // No tempo variation
+ }
+
+ g_music_time += dt * g_tempo_scale;
+
+ g_audio_engine.update(g_music_time);
+ audio_render_ahead(g_music_time, dt * g_tempo_scale);
+ };
+
+ // Pre-fill audio buffer
+ g_audio_engine.update(g_music_time);
+ audio_render_ahead(g_music_time, 1.0f / 60.0f);
+ audio_start();
+
+ int last_width = platform_state.width;
+ int last_height = platform_state.height;
+ const float demo_duration = GetDemoDuration();
+
+#if !defined(STRIP_ALL)
+ // Open peak log file if requested
+ FILE* peak_log = nullptr;
+ if (log_peaks_file) {
+ peak_log = fopen(log_peaks_file, "w");
+ if (peak_log) {
+ fprintf(peak_log, "# Audio peak log from test_demo\n");
+ fprintf(peak_log, "# To plot with gnuplot:\n");
+ fprintf(peak_log, "# gnuplot -p -e \"set xlabel 'Time (s)'; set ylabel 'Peak'; plot '%s' using 1:3 with lines title 'Raw Peak'\"\n", log_peaks_file);
+ fprintf(peak_log, "# Columns: beat_number clock_time raw_peak\n");
+ fprintf(peak_log, "#\n");
+ } else {
+ fprintf(stderr, "Warning: Could not open log file '%s'\n", log_peaks_file);
+ }
+ }
+ int last_beat_logged = -1;
+#endif
+
+ // Main loop
+ while (!platform_should_close(&platform_state)) {
+ platform_poll(&platform_state);
+
+ // Handle resize
+ if (platform_state.width != last_width ||
+ platform_state.height != last_height) {
+ last_width = platform_state.width;
+ last_height = platform_state.height;
+ gpu_resize(last_width, last_height);
+ }
+
+ const double current_time = platform_state.time;
+
+ // Auto-exit at end
+ if (demo_duration > 0.0f && current_time >= demo_duration) {
+#if !defined(STRIP_ALL)
+ printf("test_demo finished at %.2f seconds.\n", current_time);
+#endif
+ break;
+ }
+
+ fill_audio_buffer(current_time);
+
+ // Audio/visual sync parameters
+ const float aspect_ratio = platform_state.aspect_ratio;
+ const float raw_peak = synth_get_output_peak();
+ const float visual_peak = fminf(raw_peak * 8.0f, 1.0f);
+
+ // Beat calculation (hardcoded BPM=120)
+ const float beat_time = (float)current_time * 120.0f / 60.0f;
+ const int beat_number = (int)beat_time;
+ const float beat = fmodf(beat_time, 1.0f);
+
+#if !defined(STRIP_ALL)
+ // Log peak at each beat boundary
+ if (peak_log && beat_number != last_beat_logged) {
+ fprintf(peak_log, "%d %.6f %.6f\n", beat_number, current_time, raw_peak);
+ last_beat_logged = beat_number;
+ }
+
+ // Debug output every 0.5 seconds
+ static float last_print_time = -1.0f;
+ if (current_time - last_print_time >= 0.5f) {
+ if (tempo_test_enabled) {
+ printf("[T=%.2f, MusicT=%.2f, Beat=%d, Frac=%.2f, Peak=%.2f, Tempo=%.2fx]\n",
+ (float)current_time, g_music_time, beat_number, beat, visual_peak, g_tempo_scale);
+ } else {
+ printf("[T=%.2f, Beat=%d, Frac=%.2f, Peak=%.2f]\n",
+ (float)current_time, beat_number, beat, visual_peak);
+ }
+ last_print_time = (float)current_time;
+ }
+#endif
+
+ gpu_draw(visual_peak, aspect_ratio, (float)current_time, beat);
+ audio_update();
+ }
+
+ // Shutdown
+#if !defined(STRIP_ALL)
+ if (peak_log) {
+ fclose(peak_log);
+ printf("Peak log written to '%s'\n", log_peaks_file);
+ }
+#endif
+
+ audio_shutdown();
+ gpu_shutdown();
+ platform_shutdown(&platform_state);
+ return 0;
+}
diff --git a/test_demo_README.md b/test_demo_README.md
new file mode 100644
index 0000000..f84f972
--- /dev/null
+++ b/test_demo_README.md
@@ -0,0 +1,223 @@
+# test_demo - Audio/Visual Synchronization Debug Tool
+
+## Overview
+
+A minimal standalone tool for debugging audio/visual synchronization in the demo without the complexity of the full demo64k timeline.
+
+## Features
+
+- **Simple Drum Beat**: Kick-snare pattern with crash every 4th bar
+- **NOTE_A4 Reference Tone**: 440 Hz tone plays at start of each bar for testing
+- **Screen Flash Effect**: Visual feedback synchronized to audio peaks
+- **16 Second Duration**: 8 bars at 120 BPM
+- **Variable Tempo Mode**: Tests tempo scaling (1.0x ↔ 1.5x and 1.0x ↔ 0.66x)
+- **Peak Logging**: Export audio peaks to file for gnuplot visualization
+
+## Building
+
+```bash
+cmake --build build --target test_demo
+```
+
+## Usage
+
+### Basic Usage
+
+```bash
+build/test_demo
+```
+
+### Command-Line Options
+
+```
+ --help Show help message and exit
+ --fullscreen Run in fullscreen mode
+ --resolution WxH Set window resolution (e.g., 1024x768)
+ --tempo Enable tempo variation test mode
+ --log-peaks FILE Log audio peaks to FILE for gnuplot visualization
+```
+
+### Examples
+
+#### Run in fullscreen
+```bash
+build/test_demo --fullscreen
+```
+
+#### Custom resolution with tempo testing
+```bash
+build/test_demo --resolution 1024x768 --tempo
+```
+
+#### Log audio peaks for analysis
+```bash
+build/test_demo --log-peaks peaks.txt
+```
+
+After running, visualize with gnuplot:
+```bash
+gnuplot -p -e "set xlabel 'Time (s)'; set ylabel 'Peak'; plot 'peaks.txt' using 1:3 with lines title 'Raw Peak'"
+```
+
+## Keyboard Controls
+
+- **ESC**: Exit the demo
+- **F**: Toggle fullscreen
+
+## Audio Pattern
+
+The demo plays a repeating drum pattern:
+
+```
+Bar 1-2: Kick-Snare-Kick-Snare (with A4 note)
+Bar 3: Kick+Crash-Snare-Kick-Snare (with A4 note) ← Crash landmark
+Bar 4-6: Kick-Snare-Kick-Snare (with A4 note)
+Bar 7: Kick+Crash-Snare-Kick-Snare (with A4 note) ← Crash landmark
+Bar 8: Kick-Snare-Kick-Snare (with A4 note)
+```
+
+**Timing:**
+- 120 BPM (2 beats per second)
+- 1 bar = 4 beats = 2 seconds
+- Total duration: 8 bars = 16 seconds
+
+**Crash Landmarks:**
+- First crash at T=4.0s (bar 3, beat 8)
+- Second crash at T=12.0s (bar 7, beat 24)
+
+## Tempo Test Mode (`--tempo`)
+
+When enabled with `--tempo` flag, the demo alternates tempo scaling every bar:
+
+- **Even bars (0, 2, 4, 6)**: Accelerate from 1.0x → 1.5x
+- **Odd bars (1, 3, 5, 7)**: Decelerate from 1.0x → 0.66x
+
+This tests the variable tempo system where music time advances independently of physical time:
+- `music_time += dt * tempo_scale`
+- Pattern triggering respects tempo scaling
+- Audio samples play at normal pitch (no pitch shifting)
+
+**Console Output with --tempo:**
+```
+[T=0.50, MusicT=0.56, Beat=1, Frac=0.12, Peak=0.72, Tempo=1.25x]
+```
+
+**Expected Behavior:**
+- Physical time always advances at 1:1 (16 seconds = 16 seconds)
+- Music time advances faster during acceleration, slower during deceleration
+- By end of demo: Music time > Physical time (due to net acceleration)
+
+## Peak Logging Format
+
+The `--log-peaks` option writes a text file with three columns:
+
+```
+# Audio peak log from test_demo
+# To plot with gnuplot:
+# gnuplot -p -e "set xlabel 'Time (s)'; set ylabel 'Peak'; plot 'peaks.txt' using 1:3 with lines title 'Raw Peak'"
+# Columns: beat_number clock_time raw_peak
+#
+0 0.000000 0.850000
+1 0.500000 0.720000
+2 1.000000 0.800000
+...
+```
+
+**Columns:**
+1. **beat_number**: Beat index (0, 1, 2, ...)
+2. **clock_time**: Physical time in seconds
+3. **raw_peak**: Audio peak value (0.0-1.0+)
+
+**Use Cases:**
+- Verify audio/visual synchronization timing
+- Detect clipping (peak > 1.0)
+- Analyze tempo scaling effects
+- Compare expected vs actual beat times
+
+## Files
+
+- **`src/test_demo.cc`**: Main executable (~220 lines)
+- **`assets/test_demo.track`**: Drum pattern and NOTE_A4 definition
+- **`assets/test_demo.seq`**: Visual timeline (FlashEffect)
+- **`src/generated/test_demo_timeline.cc`**: Generated timeline (auto)
+- **`src/generated/test_demo_music.cc`**: Generated music data (auto)
+
+## Verification Checklist
+
+### Normal Mode
+- [ ] Visual flashes occur every ~0.5 seconds
+- [ ] Audio plays (kick-snare drum pattern audible)
+- [ ] A4 tone (440 Hz) audible at start of each bar
+- [ ] Synchronization: Flash happens simultaneously with kick hit
+- [ ] Crash landmark: Larger flash + cymbal crash at T=4.0s and T=12.0s
+- [ ] Auto-exit: Demo stops cleanly at 16 seconds
+- [ ] Console timing: Peak values spike when kicks hit (Peak > 0.7)
+- [ ] No audio glitches: Smooth playback, no stuttering
+
+### Tempo Test Mode
+- [ ] Bar 0 accelerates (1.0x → 1.5x)
+- [ ] Bar 1 decelerates (1.0x → 0.66x)
+- [ ] Music time drifts from physical time
+- [ ] Audio still syncs with flashes
+- [ ] Smooth transitions at bar boundaries
+- [ ] Physical time = 16s at end
+- [ ] Music time > 16s at end
+- [ ] Console shows tempo value
+
+### Peak Logging
+- [ ] File created when `--log-peaks` specified
+- [ ] Contains beat_number, clock_time, raw_peak columns
+- [ ] Gnuplot command in header comment
+- [ ] One row per beat (32 rows for 16 seconds)
+- [ ] Peak values match console output
+- [ ] Gnuplot visualization works
+
+## Troubleshooting
+
+**Flash appears before audio:**
+- Cause: Audio latency too high
+- Fix: Reduce ring buffer size in `ring_buffer.h`
+
+**Flash appears after audio:**
+- Cause: Ring buffer under-filled
+- Fix: Increase pre-fill duration in `audio_render_ahead()`
+
+**No flash at all:**
+- Cause: Peak threshold not reached
+- Check: Console shows Peak > 0.7
+- Fix: Increase `visual_peak` multiplier in code (currently 8.0×)
+
+**A4 note not audible:**
+- Cause: NOTE_A4 volume too low or procedural generation issue
+- Check: Console shows correct sample count (4 samples)
+- Fix: Increase volume in test_demo.track (currently 0.5)
+
+**Peak log file empty:**
+- Cause: Demo exited before first beat
+- Check: File has header comments
+- Fix: Ensure demo runs for at least 0.5 seconds
+
+## Design Rationale
+
+**Why separate from demo64k?**
+- Isolated testing environment
+- No timeline complexity
+- Faster iteration cycles
+- Independent verification
+
+**Why use main demo assets?**
+- Avoid asset system conflicts (AssetId enum collision)
+- Reuse existing samples
+- No size overhead
+- Simpler CMake integration
+
+**Why 16 seconds?**
+- Long enough for verification (8 bars)
+- Short enough for quick tests
+- Crash landmarks at 25% and 75% for easy reference
+
+**Why NOTE_A4?**
+- Standard reference tone (440 Hz)
+- Easily identifiable pitch
+- Tests procedural note generation
+- Minimal code impact