summaryrefslogtreecommitdiff
path: root/src/tests/audio
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-09 20:27:04 +0100
committerskal <pascal.massimino@gmail.com>2026-02-09 20:27:04 +0100
commiteff8d43479e7704df65fae2a80eefa787213f502 (patch)
tree76f2fb8fe8d3db2c15179449df2cf12f7f54e0bf /src/tests/audio
parent12378b1b7e9091ba59895b4360b2fa959180a56a (diff)
refactor: Reorganize tests into subsystem subdirectories
Restructured test suite for better organization and targeted testing: **Structure:** - src/tests/audio/ - 15 audio system tests - src/tests/gpu/ - 12 GPU/shader tests - src/tests/3d/ - 6 3D rendering tests - src/tests/assets/ - 2 asset system tests - src/tests/util/ - 3 utility tests - src/tests/common/ - 3 shared test helpers - src/tests/scripts/ - 2 bash test scripts (moved conceptually, not physically) **CMake changes:** - Updated add_demo_test macro to accept LABEL parameter - Applied CTest labels to all 36 tests for subsystem filtering - Updated all test file paths in CMakeLists.txt - Fixed common helper paths (webgpu_test_fixture, etc.) - Added custom targets for subsystem testing: - run_audio_tests, run_gpu_tests, run_3d_tests - run_assets_tests, run_util_tests, run_all_tests **Include path updates:** - Fixed relative includes in GPU tests to reference ../common/ **Documentation:** - Updated doc/HOWTO.md with subsystem test commands - Updated doc/CONTRIBUTING.md with new test organization - Updated scripts/check_all.sh to reflect new structure **Verification:** - All 36 tests passing (100%) - ctest -L <subsystem> filters work correctly - make run_<subsystem>_tests targets functional - scripts/check_all.sh passes Backward compatible: make test and ctest continue to work unchanged. handoff(Gemini): Test reorganization complete. 36/36 tests passing.
Diffstat (limited to 'src/tests/audio')
-rw-r--r--src/tests/audio/test_audio_backend.cc130
-rw-r--r--src/tests/audio/test_audio_engine.cc182
-rw-r--r--src/tests/audio/test_audio_gen.cc97
-rw-r--r--src/tests/audio/test_dct.cc44
-rw-r--r--src/tests/audio/test_fft.cc229
-rw-r--r--src/tests/audio/test_jittered_audio.cc161
-rw-r--r--src/tests/audio/test_mock_backend.cc215
-rw-r--r--src/tests/audio/test_silent_backend.cc211
-rw-r--r--src/tests/audio/test_spectral_brush.cc243
-rw-r--r--src/tests/audio/test_synth.cc113
-rw-r--r--src/tests/audio/test_tracker.cc73
-rw-r--r--src/tests/audio/test_tracker_timing.cc309
-rw-r--r--src/tests/audio/test_variable_tempo.cc291
-rw-r--r--src/tests/audio/test_wav_dump.cc309
-rw-r--r--src/tests/audio/test_window.cc28
15 files changed, 2635 insertions, 0 deletions
diff --git a/src/tests/audio/test_audio_backend.cc b/src/tests/audio/test_audio_backend.cc
new file mode 100644
index 0000000..6a748aa
--- /dev/null
+++ b/src/tests/audio/test_audio_backend.cc
@@ -0,0 +1,130 @@
+// This file is part of the 64k demo project.
+// It tests the audio backend abstraction layer.
+// Verifies backend injection and event hooks work correctly.
+
+#include "audio/audio.h"
+#include "audio/audio_backend.h"
+#include "audio/synth.h"
+#include <assert.h>
+#include <stdio.h>
+#include <vector>
+
+#if !defined(STRIP_ALL)
+
+// Simple test backend that records events
+class TestBackend : public AudioBackend {
+ public:
+ struct Event {
+ float timestamp;
+ int spectrogram_id;
+ float volume;
+ float pan;
+ };
+
+ std::vector<Event> events;
+ bool init_called = false;
+ bool start_called = false;
+ bool shutdown_called = false;
+
+ void init() override {
+ init_called = true;
+ }
+
+ void start() override {
+ start_called = true;
+ }
+
+ void shutdown() override {
+ shutdown_called = true;
+ }
+
+ float get_realtime_peak() override {
+ // Test backend: return synthetic peak
+ return 0.5f;
+ }
+
+ void on_voice_triggered(float timestamp, int spectrogram_id, float volume,
+ float pan) override {
+ events.push_back({timestamp, spectrogram_id, volume, pan});
+ }
+};
+
+void test_backend_injection() {
+ TestBackend backend;
+
+ // Inject test backend before audio_init
+ audio_set_backend(&backend);
+
+ audio_init();
+ assert(backend.init_called);
+
+ audio_start();
+ assert(backend.start_called);
+
+ audio_shutdown();
+ assert(backend.shutdown_called);
+
+ printf("Backend injection test PASSED\n");
+}
+
+void test_event_recording() {
+ TestBackend backend;
+ audio_set_backend(&backend);
+
+ synth_init();
+
+ // Create a dummy spectrogram
+ float data[DCT_SIZE * 2] = {0};
+ Spectrogram spec = {data, data, 2};
+ int id = synth_register_spectrogram(&spec);
+
+ // Trigger a voice
+ synth_trigger_voice(id, 0.8f, -0.5f);
+
+ // Render some frames to advance time
+ float output[1024] = {0};
+ synth_render(output, 256); // ~0.008 sec at 32kHz
+
+ // Verify event was recorded
+ assert(backend.events.size() == 1);
+ assert(backend.events[0].spectrogram_id == id);
+ assert(backend.events[0].volume == 0.8f);
+ assert(backend.events[0].pan == -0.5f);
+ assert(backend.events[0].timestamp == 0.0f); // Triggered before any render
+
+ // Trigger another voice after rendering
+ synth_trigger_voice(id, 1.0f, 0.0f);
+
+ assert(backend.events.size() == 2);
+ assert(backend.events[1].timestamp > 0.0f); // Should be > 0 now
+
+ printf("Event recording test PASSED\n");
+}
+
+void test_default_backend() {
+ // Reset backend to nullptr to test default
+ audio_set_backend(nullptr);
+
+ // This should use MiniaudioBackend by default
+ audio_init();
+ audio_start();
+ audio_shutdown();
+
+ printf("Default backend test PASSED\n");
+}
+
+#endif /* !defined(STRIP_ALL) */
+
+int main() {
+#if !defined(STRIP_ALL)
+ printf("Running Audio Backend tests...\n");
+ test_backend_injection();
+ test_event_recording();
+ test_default_backend();
+ printf("All Audio Backend tests PASSED\n");
+ return 0;
+#else
+ printf("Audio Backend tests skipped (STRIP_ALL enabled)\n");
+ return 0;
+#endif /* !defined(STRIP_ALL) */
+}
diff --git a/src/tests/audio/test_audio_engine.cc b/src/tests/audio/test_audio_engine.cc
new file mode 100644
index 0000000..3b29dcd
--- /dev/null
+++ b/src/tests/audio/test_audio_engine.cc
@@ -0,0 +1,182 @@
+// This file is part of the 64k demo project.
+// Unit tests for AudioEngine lifecycle and resource management.
+
+#include "audio/audio_engine.h"
+#include "audio/tracker.h"
+#include "generated/assets.h"
+#include <assert.h>
+#include <stdio.h>
+
+#if !defined(STRIP_ALL)
+
+// Test 1: Basic lifecycle (init/shutdown)
+void test_audio_engine_lifecycle() {
+ printf("Test: AudioEngine lifecycle...\n");
+
+ AudioEngine engine;
+ printf(" Created AudioEngine object...\n");
+
+ engine.init();
+ printf(" Initialized AudioEngine...\n");
+
+ // Verify initialization
+ assert(engine.get_active_voice_count() == 0);
+ printf(" Verified voice count is 0...\n");
+
+ engine.shutdown();
+ printf(" Shutdown AudioEngine...\n");
+
+ printf(" ✓ AudioEngine lifecycle test passed\n");
+}
+
+// Test 2: Load music data and verify resource registration
+void test_audio_engine_music_loading() {
+ printf("Test: AudioEngine music data loading...\n");
+
+ AudioEngine engine;
+ engine.init();
+
+ // Load global music data
+ engine.load_music_data(&g_tracker_score, g_tracker_samples,
+ g_tracker_sample_assets, g_tracker_samples_count);
+
+ // Verify resource manager was initialized (samples registered but not loaded
+ // yet)
+ SpectrogramResourceManager* res_mgr = engine.get_resource_manager();
+ assert(res_mgr != nullptr);
+
+ // Initially, no samples should be loaded (lazy loading)
+ assert(res_mgr->get_loaded_count() == 0);
+
+ printf(" ✓ Music data loaded: %u samples registered\n",
+ g_tracker_samples_count);
+
+ engine.shutdown();
+
+ printf(" ✓ AudioEngine music loading test passed\n");
+}
+
+// Test 3: Manual resource loading via resource manager
+void test_audio_engine_manual_resource_loading() {
+ printf("Test: AudioEngine manual resource loading...\n");
+
+ AudioEngine engine;
+ engine.init();
+
+ // Load music data
+ engine.load_music_data(&g_tracker_score, g_tracker_samples,
+ g_tracker_sample_assets, g_tracker_samples_count);
+
+ SpectrogramResourceManager* res_mgr = engine.get_resource_manager();
+ const int initial_loaded = res_mgr->get_loaded_count();
+ assert(initial_loaded == 0); // No samples loaded yet
+
+ // Manually preload first few samples
+ res_mgr->preload(0);
+ res_mgr->preload(1);
+ res_mgr->preload(2);
+
+ const int after_preload = res_mgr->get_loaded_count();
+ printf(" Samples loaded after manual preload: %d\n", after_preload);
+ assert(after_preload == 3); // Should have 3 samples loaded
+
+ // Verify samples are accessible
+ const Spectrogram* spec0 = res_mgr->get_spectrogram(0);
+ const Spectrogram* spec1 = res_mgr->get_spectrogram(1);
+ const Spectrogram* spec2 = res_mgr->get_spectrogram(2);
+
+ assert(spec0 != nullptr);
+ assert(spec1 != nullptr);
+ assert(spec2 != nullptr);
+
+ engine.shutdown();
+
+ printf(" ✓ AudioEngine manual resource loading test passed\n");
+}
+
+// Test 4: Reset and verify state cleanup
+void test_audio_engine_reset() {
+ printf("Test: AudioEngine reset...\n");
+
+ AudioEngine engine;
+ engine.init();
+
+ engine.load_music_data(&g_tracker_score, g_tracker_samples,
+ g_tracker_sample_assets, g_tracker_samples_count);
+
+ SpectrogramResourceManager* res_mgr = engine.get_resource_manager();
+
+ // Manually load some samples
+ res_mgr->preload(0);
+ res_mgr->preload(1);
+ res_mgr->preload(2);
+
+ const int loaded_before_reset = res_mgr->get_loaded_count();
+ assert(loaded_before_reset == 3);
+
+ // Reset engine
+ engine.reset();
+
+ // After reset, state should be cleared
+ assert(engine.get_active_voice_count() == 0);
+
+ // Resources should be marked as unloaded (but memory not freed)
+ const int loaded_after_reset = res_mgr->get_loaded_count();
+ printf(" Loaded count before reset: %d, after reset: %d\n",
+ loaded_before_reset, loaded_after_reset);
+ assert(loaded_after_reset == 0);
+
+ engine.shutdown();
+
+ printf(" ✓ AudioEngine reset test passed\n");
+}
+
+#if !defined(STRIP_ALL)
+// Test 5: Seeking
+void test_audio_engine_seeking() {
+ printf("Test: AudioEngine seeking...\n");
+
+ AudioEngine engine;
+ engine.init();
+
+ engine.load_music_data(&g_tracker_score, g_tracker_samples,
+ g_tracker_sample_assets, g_tracker_samples_count);
+
+ // Seek to t=5.0s
+ engine.seek(5.0f);
+ assert(engine.get_time() == 5.0f);
+
+ // Seek backward to t=2.0s
+ engine.seek(2.0f);
+ assert(engine.get_time() == 2.0f);
+
+ // Seek to beginning
+ engine.seek(0.0f);
+ assert(engine.get_time() == 0.0f);
+
+ engine.shutdown();
+
+ printf(" ✓ AudioEngine seeking test passed\n");
+}
+#endif /* !defined(STRIP_ALL) */
+
+#endif /* !defined(STRIP_ALL) */
+
+int main() {
+#if !defined(STRIP_ALL)
+ printf("Running AudioEngine tests...\n\n");
+
+ test_audio_engine_lifecycle();
+ test_audio_engine_music_loading();
+ test_audio_engine_manual_resource_loading();
+ test_audio_engine_reset();
+ // TODO: Re-enable after debugging
+ // test_audio_engine_seeking();
+
+ printf("\n✅ All AudioEngine tests PASSED\n");
+ return 0;
+#else
+ printf("AudioEngine tests skipped (STRIP_ALL enabled)\n");
+ return 0;
+#endif /* !defined(STRIP_ALL) */
+}
diff --git a/src/tests/audio/test_audio_gen.cc b/src/tests/audio/test_audio_gen.cc
new file mode 100644
index 0000000..ebdcb25
--- /dev/null
+++ b/src/tests/audio/test_audio_gen.cc
@@ -0,0 +1,97 @@
+// This file is part of the 64k demo project.
+// It tests the procedural audio generation functions.
+
+#include "audio/dct.h"
+#include "audio/gen.h"
+#include <cassert>
+#include <cmath>
+#include <iostream>
+#include <vector>
+
+void test_generate_note() {
+ NoteParams params;
+ params.base_freq = 440.0f;
+ params.duration_sec = 0.1f; // ~3 frames
+ params.amplitude = 0.5f;
+ params.attack_sec = 0.01f;
+ params.decay_sec = 0.0f;
+ params.vibrato_rate = 0.0f;
+ params.vibrato_depth = 0.0f;
+ params.num_harmonics = 1;
+ params.harmonic_decay = 1.0f;
+ params.pitch_randomness = 0.0f;
+ params.amp_randomness = 0.0f;
+
+ int num_frames = 0;
+ std::vector<float> data = generate_note_spectrogram(params, &num_frames);
+
+ assert(num_frames > 0);
+ assert(data.size() == (size_t)num_frames * DCT_SIZE);
+
+ // Check if data is not all zero
+ bool non_zero = false;
+ for (float v : data) {
+ if (std::abs(v) > 1e-6f) {
+ non_zero = true;
+ break;
+ }
+ }
+ assert(non_zero);
+}
+
+void test_paste() {
+ std::vector<float> dest;
+ int dest_frames = 0;
+ std::vector<float> src(DCT_SIZE * 2, 1.0f); // 2 frames of 1.0s
+
+ paste_spectrogram(dest, &dest_frames, src, 2, 0);
+ assert(dest_frames == 2);
+ assert(dest.size() == 2 * DCT_SIZE);
+ assert(dest[0] == 1.0f);
+
+ // Paste with offset
+ paste_spectrogram(dest, &dest_frames, src, 2, 1);
+ // Dest was 2 frames. We paste 2 frames at offset 1.
+ // Result should be 1 + 2 = 3 frames.
+ assert(dest_frames == 3);
+ assert(dest.size() == 3 * DCT_SIZE);
+ // Overlap at frame 1: 1.0 + 1.0 = 2.0
+ assert(dest[DCT_SIZE] == 2.0f);
+ // Frame 2: 0.0 (original) + 1.0 (new) = 1.0
+ assert(dest[2 * DCT_SIZE] == 1.0f);
+}
+
+void test_filters() {
+ int num_frames = 1;
+ std::vector<float> data(DCT_SIZE, 1.0f);
+
+ // Lowpass
+ apply_spectral_lowpass(data, num_frames, 0.5f);
+ // Bins >= 256 should be 0
+ assert(data[0] == 1.0f);
+ assert(data[DCT_SIZE - 1] == 0.0f);
+ assert(data[256] == 0.0f);
+ assert(data[255] == 1.0f); // Boundary check
+
+ // Comb
+ data.assign(DCT_SIZE, 1.0f);
+ apply_spectral_comb(data, num_frames, 10.0f, 1.0f);
+ // Just check modification
+ assert(data[0] != 1.0f || data[1] != 1.0f); // It should change values
+
+ // Noise
+ data.assign(DCT_SIZE, 1.0f);
+ srand(42);
+ apply_spectral_noise(data, num_frames, 0.5f);
+ // Should be noisy
+ assert(data[0] != 1.0f);
+}
+
+int main() {
+ std::cout << "Running Audio Gen tests..." << std::endl;
+ test_generate_note();
+ test_paste();
+ test_filters();
+ std::cout << "Audio Gen tests PASSED" << std::endl;
+ return 0;
+}
diff --git a/src/tests/audio/test_dct.cc b/src/tests/audio/test_dct.cc
new file mode 100644
index 0000000..89b7964
--- /dev/null
+++ b/src/tests/audio/test_dct.cc
@@ -0,0 +1,44 @@
+// This file is part of the 64k demo project.
+// It tests the DCT implementation for correctness and coverage.
+
+#include "audio/dct.h"
+#include <cassert>
+#include <cmath>
+#include <cstdlib>
+#include <iostream>
+#include <vector>
+
+void test_fdct_idct() {
+ float input[DCT_SIZE];
+ float freq[DCT_SIZE];
+ float output[DCT_SIZE];
+
+ // Initialize with random data
+ srand(12345); // Fixed seed for reproducibility
+ for (int i = 0; i < DCT_SIZE; ++i) {
+ input[i] = (float)rand() / RAND_MAX * 2.0f - 1.0f;
+ }
+
+ fdct_512(input, freq);
+ idct_512(freq, output);
+
+ // Verify reconstruction
+ float max_error = 0.0f;
+ for (int i = 0; i < DCT_SIZE; ++i) {
+ float err = std::abs(input[i] - output[i]);
+ if (err > max_error)
+ max_error = err;
+ }
+ std::cout << "Max reconstruction error: " << max_error << std::endl;
+
+ // Allow some error due to float precision and iterative sum
+ // 512 sums can accumulate error.
+ assert(max_error < 1e-4f);
+}
+
+int main() {
+ std::cout << "Running DCT tests..." << std::endl;
+ test_fdct_idct();
+ std::cout << "DCT tests PASSED" << std::endl;
+ return 0;
+}
diff --git a/src/tests/audio/test_fft.cc b/src/tests/audio/test_fft.cc
new file mode 100644
index 0000000..2151608
--- /dev/null
+++ b/src/tests/audio/test_fft.cc
@@ -0,0 +1,229 @@
+// Tests for FFT-based DCT/IDCT implementation
+// Verifies correctness against reference O(N²) implementation
+
+#include "audio/fft.h"
+
+#include <cassert>
+#include <cmath>
+#include <cstdio>
+#include <cstring>
+
+// Reference O(N²) DCT-II implementation (from original code)
+static void dct_reference(const float* input, float* output, size_t N) {
+ const float PI = 3.14159265358979323846f;
+
+ for (size_t k = 0; k < N; k++) {
+ float sum = 0.0f;
+ for (size_t n = 0; n < N; n++) {
+ sum += input[n] * cosf((PI / N) * k * (n + 0.5f));
+ }
+
+ // Apply DCT-II normalization
+ if (k == 0) {
+ output[k] = sum * sqrtf(1.0f / N);
+ } else {
+ output[k] = sum * sqrtf(2.0f / N);
+ }
+ }
+}
+
+// Reference O(N²) IDCT implementation (DCT-III, inverse of DCT-II)
+static void idct_reference(const float* input, float* output, size_t N) {
+ const float PI = 3.14159265358979323846f;
+
+ for (size_t n = 0; n < N; ++n) {
+ // DC term with correct normalization
+ float sum = input[0] * sqrtf(1.0f / N);
+ // AC terms
+ for (size_t k = 1; k < N; ++k) {
+ sum += input[k] * sqrtf(2.0f / N) * cosf((PI / N) * k * (n + 0.5f));
+ }
+ output[n] = sum;
+ }
+}
+
+// Compare two arrays with tolerance
+// Note: FFT-based DCT accumulates slightly more rounding error than O(N²)
+// direct method A tolerance of 5e-3 is acceptable for audio applications (< -46
+// dB error) Some input patterns (e.g., impulse at N/2, high-frequency
+// sinusoids) have higher numerical error due to reordering and accumulated
+// floating-point error
+static bool arrays_match(const float* a, const float* b, size_t N,
+ float tolerance = 5e-3f) {
+ for (size_t i = 0; i < N; i++) {
+ const float diff = fabsf(a[i] - b[i]);
+ if (diff > tolerance) {
+ fprintf(stderr, "Mismatch at index %zu: %.6f vs %.6f (diff=%.6e)\n", i,
+ a[i], b[i], diff);
+ return false;
+ }
+ }
+ return true;
+}
+
+// Test 1: DCT correctness (FFT-based vs reference)
+static void test_dct_correctness() {
+ printf("Test 1: DCT correctness (FFT vs reference O(N²))...\n");
+
+ const size_t N = 512;
+ float input[N];
+ float output_ref[N];
+ float output_fft[N];
+
+ // Test case 1: Impulse at index 0
+ memset(input, 0, N * sizeof(float));
+ input[0] = 1.0f;
+
+ dct_reference(input, output_ref, N);
+ dct_fft(input, output_fft, N);
+
+ assert(arrays_match(output_ref, output_fft, N));
+ printf(" ✓ Impulse test passed\n");
+
+ // Test case 2: Impulse at middle (SKIPPED - reordering method has issues with
+ // this pattern) The reordering FFT method has systematic sign errors for
+ // impulses at certain positions This doesn't affect typical audio signals
+ // (smooth spectra), only pathological cases
+ // TODO: Investigate and fix, or switch to a different FFT-DCT algorithm
+ // memset(input, 0, N * sizeof(float));
+ // input[N / 2] = 1.0f;
+ // dct_reference(input, output_ref, N);
+ // dct_fft(input, output_fft, N);
+ // assert(arrays_match(output_ref, output_fft, N));
+ printf(" ⊘ Middle impulse test skipped (known limitation)\n");
+
+ // Test case 3: Sinusoidal input (SKIPPED - FFT accumulates error for
+ // high-frequency components) The reordering method has accumulated
+ // floating-point error that grows with frequency index This doesn't affect
+ // audio synthesis quality (round-trip is what matters)
+ printf(
+ " ⊘ Sinusoidal input test skipped (accumulated floating-point error)\n");
+
+ // Test case 4: Random-ish input (SKIPPED - same issue as sinusoidal)
+ printf(" ⊘ Complex input test skipped (accumulated floating-point error)\n");
+
+ printf("Test 1: PASSED ✓\n\n");
+}
+
+// Test 2: IDCT correctness (FFT-based vs reference)
+static void test_idct_correctness() {
+ printf("Test 2: IDCT correctness (FFT vs reference O(N²))...\n");
+
+ const size_t N = 512;
+ float input[N];
+ float output_ref[N];
+ float output_fft[N];
+
+ // Test case 1: DC component only
+ memset(input, 0, N * sizeof(float));
+ input[0] = 1.0f;
+
+ idct_reference(input, output_ref, N);
+ idct_fft(input, output_fft, N);
+
+ assert(arrays_match(output_ref, output_fft, N));
+ printf(" ✓ DC component test passed\n");
+
+ // Test case 2: Single frequency bin
+ memset(input, 0, N * sizeof(float));
+ input[10] = 1.0f;
+
+ idct_reference(input, output_ref, N);
+ idct_fft(input, output_fft, N);
+
+ assert(arrays_match(output_ref, output_fft, N));
+ printf(" ✓ Single bin test passed\n");
+
+ // Test case 3: Mixed frequencies (SKIPPED - accumulated error for complex
+ // spectra)
+ printf(
+ " ⊘ Mixed frequencies test skipped (accumulated floating-point "
+ "error)\n");
+
+ printf("Test 2: PASSED ✓\n\n");
+}
+
+// Test 3: Round-trip (DCT → IDCT should recover original)
+static void test_roundtrip() {
+ printf("Test 3: Round-trip (DCT → IDCT = identity)...\n");
+
+ const size_t N = 512;
+ float input[N];
+ float dct_output[N];
+ float reconstructed[N];
+
+ // Test case 1: Sinusoidal input
+ for (size_t i = 0; i < N; i++) {
+ input[i] = sinf(2.0f * 3.14159265358979323846f * 3.0f * i / N);
+ }
+
+ dct_fft(input, dct_output, N);
+ idct_fft(dct_output, reconstructed, N);
+
+ assert(arrays_match(input, reconstructed, N));
+ printf(" ✓ Sinusoidal round-trip passed\n");
+
+ // Test case 2: Complex signal
+ for (size_t i = 0; i < N; i++) {
+ input[i] = sinf(i * 0.1f) * cosf(i * 0.05f) + cosf(i * 0.03f);
+ }
+
+ dct_fft(input, dct_output, N);
+ idct_fft(dct_output, reconstructed, N);
+
+ assert(arrays_match(input, reconstructed, N));
+ printf(" ✓ Complex signal round-trip passed\n");
+
+ printf("Test 3: PASSED ✓\n\n");
+}
+
+// Test 4: Output known values for JavaScript comparison
+static void test_known_values() {
+ printf("Test 4: Known values (for JavaScript verification)...\n");
+
+ const size_t N = 512;
+ float input[N];
+ float output[N];
+
+ // Simple test case: impulse at index 0
+ memset(input, 0, N * sizeof(float));
+ input[0] = 1.0f;
+
+ dct_fft(input, output, N);
+
+ printf(" DCT of impulse at 0:\n");
+ printf(" output[0] = %.8f (expected ~0.04419417)\n", output[0]);
+ printf(" output[1] = %.8f (expected ~0.04419417)\n", output[1]);
+ printf(" output[10] = %.8f (expected ~0.04419417)\n", output[10]);
+
+ // IDCT test
+ memset(input, 0, N * sizeof(float));
+ input[0] = 1.0f;
+
+ idct_fft(input, output, N);
+
+ printf(" IDCT of DC component:\n");
+ printf(" output[0] = %.8f (expected ~0.04419417)\n", output[0]);
+ printf(" output[100] = %.8f (expected ~0.04419417)\n", output[100]);
+ printf(" output[511] = %.8f (expected ~0.04419417)\n", output[511]);
+
+ printf("Test 4: PASSED ✓\n");
+ printf("(Copy these values to JavaScript test for verification)\n\n");
+}
+
+int main() {
+ printf("===========================================\n");
+ printf("FFT-based DCT/IDCT Test Suite\n");
+ printf("===========================================\n\n");
+
+ test_dct_correctness();
+ test_idct_correctness();
+ test_roundtrip();
+ test_known_values();
+
+ printf("===========================================\n");
+ printf("All tests PASSED ✓\n");
+ printf("===========================================\n");
+
+ return 0;
+}
diff --git a/src/tests/audio/test_jittered_audio.cc b/src/tests/audio/test_jittered_audio.cc
new file mode 100644
index 0000000..d8260ec
--- /dev/null
+++ b/src/tests/audio/test_jittered_audio.cc
@@ -0,0 +1,161 @@
+// This file is part of the 64k demo project.
+// It tests the ring buffer under jittered consumption (stress test).
+
+#include <stdio.h>
+
+#if !defined(STRIP_ALL)
+
+#include "audio/audio.h"
+#include "audio/backend/jittered_audio_backend.h"
+#include "audio/synth.h"
+#include "audio/tracker.h"
+#include <assert.h>
+#include <chrono>
+#include <thread>
+
+void test_jittered_audio_basic() {
+ printf("Test: Basic jittered audio consumption...\n");
+
+ // Initialize audio system
+ synth_init();
+ tracker_init();
+
+ // Set up jittered backend with realistic parameters
+ // At 32kHz, 10ms = 320 samples = 160 frames (stereo)
+ // Jitter of ±5ms means 5-15ms intervals, or 80-240 frames
+ JitteredAudioBackend jittered_backend;
+ jittered_backend.set_base_interval(10.0f); // 10ms base interval
+ jittered_backend.set_jitter_amount(5.0f); // ±5ms jitter
+ jittered_backend.set_chunk_size_range(
+ 80, 240); // Realistic chunk sizes for 5-15ms
+
+ audio_set_backend(&jittered_backend);
+ audio_init();
+
+ // Start audio thread
+ audio_start();
+ assert(jittered_backend.is_running());
+
+ // Simulate main loop for 0.1 seconds (quick stress test)
+ const float total_time = 0.1f;
+ const float dt = 1.0f / 60.0f; // 60fps
+ float music_time = 0.0f;
+
+ for (float t = 0.0f; t < total_time; t += dt) {
+ music_time += dt; // Normal tempo
+
+ // Update tracker and fill buffer
+ tracker_update(music_time, dt);
+ audio_render_ahead(music_time, dt);
+
+ // Sleep minimal time to let audio thread run
+ std::this_thread::sleep_for(std::chrono::milliseconds(1));
+ }
+
+ // Stop audio
+ audio_shutdown();
+
+ // Check results
+ const int frames_consumed = jittered_backend.get_total_frames_consumed();
+ const int underruns = jittered_backend.get_underrun_count();
+
+ printf(" Frames consumed: %d\n", frames_consumed);
+ printf(" Underruns: %d\n", underruns);
+
+ // Should have consumed some audio (exact amount depends on timing/jitter)
+ // With minimal sleeps and 0.1s sim time, expect 50-1000 frames
+ assert(frames_consumed > 50); // At least some audio consumed
+ assert(frames_consumed < 2000); // Not excessive
+
+ // Underruns are acceptable in this test, but shouldn't be excessive
+ assert(underruns < 5); // Less than 5 underruns in 0.1 seconds
+
+ printf(" ✓ Basic jittered audio consumption PASSED\n");
+}
+
+void test_jittered_audio_with_acceleration() {
+ printf("Test: Jittered audio with tempo acceleration...\n");
+
+ // Initialize audio system
+ synth_init();
+ tracker_init();
+
+ // Set up jittered backend with aggressive settings for stress test
+ // At 32kHz, 15ms = 480 samples = 240 frames (stereo)
+ // Jitter of ±10ms means 5-25ms intervals, or 80-400 frames
+ JitteredAudioBackend jittered_backend;
+ jittered_backend.set_base_interval(15.0f); // Slower consumption
+ jittered_backend.set_jitter_amount(10.0f); // High jitter
+ jittered_backend.set_chunk_size_range(80, 400); // Realistic stress test range
+
+ audio_set_backend(&jittered_backend);
+ audio_init();
+
+ // Start audio thread
+ audio_start();
+
+ // Simulate acceleration scenario (similar to real demo)
+ const float total_time = 0.6f;
+ const float dt = 1.0f / 60.0f;
+ float music_time = 0.0f;
+ float physical_time = 0.0f;
+
+ for (int frame = 0; frame < 36; ++frame) { // 0.6 seconds @ 60fps
+ physical_time = frame * dt;
+
+ // Variable tempo (accelerate from 0.3-0.6s)
+ float tempo_scale = 1.0f;
+ if (physical_time >= 0.3f && physical_time < 0.6f) {
+ const float progress = (physical_time - 0.3f) / 0.3f;
+ tempo_scale = 1.0f + progress * 1.0f; // 1.0 → 2.0
+ }
+
+ music_time += dt * tempo_scale;
+
+ // Update tracker and fill buffer
+ tracker_update(music_time, dt * tempo_scale);
+ audio_render_ahead(music_time, dt);
+
+ // Sleep minimal time to let audio thread run
+ std::this_thread::sleep_for(std::chrono::milliseconds(1));
+ }
+ printf("\n");
+
+ // Stop audio
+ audio_shutdown();
+
+ // Check results
+ const int frames_consumed = jittered_backend.get_total_frames_consumed();
+ const int underruns = jittered_backend.get_underrun_count();
+
+ printf(" Total frames consumed: %d\n", frames_consumed);
+ printf(" Total underruns: %d\n", underruns);
+
+ // Should have consumed some audio (exact amount depends on timing/jitter)
+ // With minimal sleeps and 0.6s sim time, expect more than basic test
+ assert(frames_consumed > 200); // At least some audio consumed
+ assert(frames_consumed < 5000); // Not excessive
+
+ // During acceleration with jitter, some underruns are expected but not
+ // excessive
+ assert(underruns < 10); // Less than 10 underruns in 0.6 seconds
+
+ printf(" ✓ Jittered audio with acceleration PASSED\n");
+}
+
+int main() {
+ printf("Running Jittered Audio Backend tests...\n\n");
+ test_jittered_audio_basic();
+ test_jittered_audio_with_acceleration();
+ printf("\n✅ All Jittered Audio Backend tests PASSED\n");
+ return 0;
+}
+
+#else
+
+int main() {
+ printf("Jittered Audio Backend tests skipped (STRIP_ALL enabled)\n");
+ return 0;
+}
+
+#endif /* !defined(STRIP_ALL) */
diff --git a/src/tests/audio/test_mock_backend.cc b/src/tests/audio/test_mock_backend.cc
new file mode 100644
index 0000000..defd73d
--- /dev/null
+++ b/src/tests/audio/test_mock_backend.cc
@@ -0,0 +1,215 @@
+// This file is part of the 64k demo project.
+// It tests the MockAudioBackend implementation.
+// Verifies event recording, time tracking, and synth integration.
+
+#include "audio/audio.h"
+#include "audio/backend/mock_audio_backend.h"
+#include "audio/synth.h"
+#include <assert.h>
+#include <cmath>
+#include <stdio.h>
+
+#if !defined(STRIP_ALL)
+
+void test_event_recording() {
+ MockAudioBackend backend;
+
+ // Initially no events
+ assert(backend.get_events().size() == 0);
+ assert(backend.get_current_time() == 0.0f);
+
+ // Simulate voice trigger
+ backend.on_voice_triggered(0.5f, 3, 0.75f, -0.25f);
+
+ // Verify event recorded
+ const auto& events = backend.get_events();
+ assert(events.size() == 1);
+ assert(events[0].timestamp_sec == 0.5f);
+ assert(events[0].spectrogram_id == 3);
+ assert(events[0].volume == 0.75f);
+ assert(events[0].pan == -0.25f);
+
+ // Record multiple events
+ backend.on_voice_triggered(1.0f, 5, 1.0f, 0.0f);
+ backend.on_voice_triggered(1.5f, 3, 0.5f, 0.5f);
+
+ assert(backend.get_events().size() == 3);
+ assert(events[1].timestamp_sec == 1.0f);
+ assert(events[2].timestamp_sec == 1.5f);
+
+ // Clear events
+ backend.clear_events();
+ assert(backend.get_events().size() == 0);
+
+ printf("Event recording test PASSED\n");
+}
+
+void test_time_tracking() {
+ MockAudioBackend backend;
+
+ // Test manual time advance
+ assert(backend.get_current_time() == 0.0f);
+
+ backend.advance_time(0.5f);
+ assert(backend.get_current_time() == 0.5f);
+
+ backend.advance_time(1.0f);
+ assert(backend.get_current_time() == 1.5f);
+
+ // Test time setting
+ backend.set_time(10.0f);
+ assert(backend.get_current_time() == 10.0f);
+
+ printf("Time tracking test PASSED\n");
+}
+
+void test_frame_rendering() {
+ MockAudioBackend backend;
+
+ // Simulate frame rendering (32000 Hz sample rate)
+ // 1 second = 32000 frames
+ backend.on_frames_rendered(16000); // 0.5 seconds
+ assert(std::abs(backend.get_current_time() - 0.5f) < 0.001f);
+
+ backend.on_frames_rendered(16000); // Another 0.5 seconds
+ assert(std::abs(backend.get_current_time() - 1.0f) < 0.001f);
+
+ backend.on_frames_rendered(32000); // 1 second
+ assert(std::abs(backend.get_current_time() - 2.0f) < 0.001f);
+
+ printf("Frame rendering test PASSED\n");
+}
+
+void test_synth_integration() {
+ MockAudioBackend backend;
+ audio_set_backend(&backend);
+
+ synth_init();
+
+ // Create dummy spectrogram
+ float data[DCT_SIZE * 10] = {0};
+ data[0] = 100.0f; // DC component
+
+ Spectrogram spec = {data, data, 10};
+ int spec_id = synth_register_spectrogram(&spec);
+ assert(spec_id >= 0);
+
+ // Trigger voice - should be recorded at time 0
+ synth_trigger_voice(spec_id, 0.8f, -0.3f);
+
+ // Verify event recorded
+ const auto& events = backend.get_events();
+ assert(events.size() == 1);
+ assert(events[0].timestamp_sec == 0.0f); // Before any rendering
+ assert(events[0].spectrogram_id == spec_id);
+ assert(events[0].volume == 0.8f);
+ assert(events[0].pan == -0.3f);
+
+ // Render some frames to advance time
+ float output[1024] = {0};
+ synth_render(output, 512); // ~0.016 sec at 32kHz
+
+ // Verify synth updated its time
+ // (Note: synth time is internal, mock doesn't track it from render)
+
+ // Trigger another voice after rendering
+ synth_trigger_voice(spec_id, 1.0f, 0.5f);
+
+ assert(events.size() == 2);
+ // Second trigger should have timestamp > 0
+ assert(events[1].timestamp_sec > 0.0f);
+ assert(events[1].timestamp_sec < 0.02f); // ~512 frames = ~0.016 sec
+
+ printf("Synth integration test PASSED\n");
+}
+
+void test_multiple_voices() {
+ MockAudioBackend backend;
+ audio_set_backend(&backend);
+
+ synth_init();
+
+ // Create multiple spectrograms
+ float data1[DCT_SIZE * 5] = {0};
+ float data2[DCT_SIZE * 5] = {0};
+ float data3[DCT_SIZE * 5] = {0};
+
+ Spectrogram spec1 = {data1, data1, 5};
+ Spectrogram spec2 = {data2, data2, 5};
+ Spectrogram spec3 = {data3, data3, 5};
+
+ int id1 = synth_register_spectrogram(&spec1);
+ int id2 = synth_register_spectrogram(&spec2);
+ int id3 = synth_register_spectrogram(&spec3);
+
+ // Trigger multiple voices at once
+ synth_trigger_voice(id1, 1.0f, -1.0f);
+ synth_trigger_voice(id2, 0.5f, 0.0f);
+ synth_trigger_voice(id3, 0.75f, 1.0f);
+
+ // Verify all recorded
+ const auto& events = backend.get_events();
+ assert(events.size() == 3);
+
+ // Verify each has correct properties
+ assert(events[0].spectrogram_id == id1);
+ assert(events[1].spectrogram_id == id2);
+ assert(events[2].spectrogram_id == id3);
+
+ assert(events[0].volume == 1.0f);
+ assert(events[1].volume == 0.5f);
+ assert(events[2].volume == 0.75f);
+
+ assert(events[0].pan == -1.0f);
+ assert(events[1].pan == 0.0f);
+ assert(events[2].pan == 1.0f);
+
+ printf("Multiple voices test PASSED\n");
+}
+
+void test_audio_render_silent_integration() {
+ MockAudioBackend backend;
+ audio_set_backend(&backend);
+
+ audio_init();
+ synth_init();
+
+ // Create a spectrogram
+ float data[DCT_SIZE * 5] = {0};
+ Spectrogram spec = {data, data, 5};
+ int spec_id = synth_register_spectrogram(&spec);
+
+ // Trigger at t=0
+ synth_trigger_voice(spec_id, 1.0f, 0.0f);
+
+ // Simulate 2 seconds of silent rendering (seek/fast-forward)
+ audio_render_silent(2.0f);
+
+ // Verify backend time advanced via on_frames_rendered
+ const float expected_time = 2.0f;
+ const float actual_time = backend.get_current_time();
+ assert(std::abs(actual_time - expected_time) < 0.01f); // 10ms tolerance
+
+ audio_shutdown();
+
+ printf("audio_render_silent integration test PASSED\n");
+}
+
+#endif /* !defined(STRIP_ALL) */
+
+int main() {
+#if !defined(STRIP_ALL)
+ printf("Running MockAudioBackend tests...\n");
+ test_event_recording();
+ test_time_tracking();
+ test_frame_rendering();
+ test_synth_integration();
+ test_multiple_voices();
+ test_audio_render_silent_integration();
+ printf("All MockAudioBackend tests PASSED\n");
+ return 0;
+#else
+ printf("MockAudioBackend tests skipped (STRIP_ALL enabled)\n");
+ return 0;
+#endif /* !defined(STRIP_ALL) */
+}
diff --git a/src/tests/audio/test_silent_backend.cc b/src/tests/audio/test_silent_backend.cc
new file mode 100644
index 0000000..8daacf7
--- /dev/null
+++ b/src/tests/audio/test_silent_backend.cc
@@ -0,0 +1,211 @@
+// This file is part of the 64k demo project.
+// It tests the SilentBackend for audio testing without hardware.
+// Verifies audio.cc functionality using silent backend.
+
+#include "audio/audio.h"
+#include "audio/audio_engine.h"
+#include "audio/backend/silent_backend.h"
+#include "audio/synth.h"
+#include <assert.h>
+#include <stdio.h>
+
+#if !defined(STRIP_ALL)
+
+// Test: SilentBackend initialization and lifecycle
+void test_silent_backend_lifecycle() {
+ SilentBackend backend;
+
+ assert(!backend.is_initialized());
+ assert(!backend.is_started());
+
+ backend.init();
+ assert(backend.is_initialized());
+ assert(!backend.is_started());
+
+ backend.start();
+ assert(backend.is_initialized());
+ assert(backend.is_started());
+
+ backend.shutdown();
+ assert(!backend.is_initialized());
+ assert(!backend.is_started());
+
+ printf("SilentBackend lifecycle test PASSED\n");
+}
+
+// Test: Audio system with SilentBackend
+void test_audio_with_silent_backend() {
+ SilentBackend backend;
+ audio_set_backend(&backend);
+
+ audio_init();
+ assert(backend.is_initialized());
+
+ audio_start();
+ assert(backend.is_started());
+
+ audio_shutdown();
+ assert(!backend.is_initialized());
+
+ printf("Audio with SilentBackend test PASSED\n");
+}
+
+// Test: Peak control in SilentBackend
+void test_silent_backend_peak() {
+ SilentBackend backend;
+ audio_set_backend(&backend);
+
+ audio_init();
+
+ // Default peak should be 0
+ assert(backend.get_realtime_peak() == 0.0f);
+ assert(audio_get_realtime_peak() == 0.0f);
+
+ // Set test peak
+ backend.set_peak(0.75f);
+ assert(backend.get_realtime_peak() == 0.75f);
+ assert(audio_get_realtime_peak() == 0.75f);
+
+ // Reset
+ backend.set_peak(0.0f);
+ assert(backend.get_realtime_peak() == 0.0f);
+
+ audio_shutdown();
+
+ printf("SilentBackend peak control test PASSED\n");
+}
+
+// Test: Frame and voice tracking
+void test_silent_backend_tracking() {
+ SilentBackend backend;
+ audio_set_backend(&backend);
+
+ AudioEngine engine;
+ engine.init();
+
+ // Initial state
+ assert(backend.get_frames_rendered() == 0);
+ assert(backend.get_voice_trigger_count() == 0);
+
+ // Create a dummy spectrogram
+ float data[DCT_SIZE * 2] = {0};
+ Spectrogram spec = {data, data, 2};
+ int id = synth_register_spectrogram(&spec);
+
+ // Trigger a voice
+ synth_trigger_voice(id, 0.8f, 0.0f);
+ assert(backend.get_voice_trigger_count() == 1);
+
+ // Render audio (calls on_frames_rendered)
+ audio_render_ahead(0.0f, 0.1f); // Render ~0.1 seconds
+ assert(backend.get_frames_rendered() > 0);
+
+ // Reset stats
+ backend.reset_stats();
+ assert(backend.get_frames_rendered() == 0);
+ assert(backend.get_voice_trigger_count() == 0);
+
+ engine.shutdown();
+ audio_shutdown();
+
+ printf("SilentBackend tracking test PASSED\n");
+}
+
+// Test: Playback time with SilentBackend
+void test_audio_playback_time() {
+ SilentBackend backend;
+ audio_set_backend(&backend);
+
+ AudioEngine engine;
+ engine.init();
+ audio_start();
+
+ // Initial playback time should be 0
+ float t0 = audio_get_playback_time();
+ assert(t0 == 0.0f);
+
+ // Render some audio
+ audio_render_ahead(0.5f, 0.1f); // Advance music time to 0.5s
+
+ // Playback time should advance based on frames rendered
+ // Note: audio_get_playback_time() tracks cumulative frames consumed
+ float t1 = audio_get_playback_time();
+ assert(t1 >= 0.0f); // Should have advanced
+
+ // Render more
+ audio_render_ahead(1.0f, 0.5f);
+ float t2 = audio_get_playback_time();
+ assert(t2 >= t1); // Should continue advancing
+
+ engine.shutdown();
+ audio_shutdown();
+
+ printf("Audio playback time test PASSED\n");
+}
+
+// Test: Buffer management with partial writes
+void test_audio_buffer_partial_writes() {
+ SilentBackend backend;
+ audio_set_backend(&backend);
+
+ AudioEngine engine;
+ engine.init();
+ audio_start();
+
+ // Fill buffer multiple times to test wraparound
+ // Note: With SilentBackend, frames_rendered won't increase because
+ // there's no audio callback consuming from the ring buffer
+ for (int i = 0; i < 10; ++i) {
+ audio_render_ahead((float)i * 0.1f, 0.1f);
+ }
+
+ // Buffer should have handled multiple writes correctly (no crash)
+ // We can't check frames_rendered with SilentBackend since there's
+ // no audio callback to consume from the ring buffer
+ audio_update(); // Should not crash
+
+ engine.shutdown();
+ audio_shutdown();
+
+ printf("Audio buffer partial writes test PASSED\n");
+}
+
+// Test: audio_update() with SilentBackend
+void test_audio_update() {
+ SilentBackend backend;
+ audio_set_backend(&backend);
+
+ AudioEngine engine;
+ engine.init();
+ audio_start();
+
+ // audio_update() should be callable without crashing
+ audio_update();
+ audio_update();
+ audio_update();
+
+ engine.shutdown();
+ audio_shutdown();
+
+ printf("Audio update test PASSED\n");
+}
+
+#endif /* !defined(STRIP_ALL) */
+
+int main() {
+#if !defined(STRIP_ALL)
+ printf("Running SilentBackend tests...\n");
+ test_silent_backend_lifecycle();
+ test_audio_with_silent_backend();
+ test_silent_backend_peak();
+ test_silent_backend_tracking();
+ test_audio_playback_time();
+ test_audio_buffer_partial_writes();
+ test_audio_update();
+ printf("All SilentBackend tests PASSED\n");
+ return 0;
+#else
+ printf("SilentBackend tests skipped (STRIP_ALL enabled)\n");
+ return 0;
+#endif /* !defined(STRIP_ALL) */
+}
diff --git a/src/tests/audio/test_spectral_brush.cc b/src/tests/audio/test_spectral_brush.cc
new file mode 100644
index 0000000..ae1862a
--- /dev/null
+++ b/src/tests/audio/test_spectral_brush.cc
@@ -0,0 +1,243 @@
+// This file is part of the 64k demo project.
+// Unit tests for spectral brush primitives.
+// Tests linear Bezier interpolation, profiles, and spectrogram rendering.
+
+#include "audio/spectral_brush.h"
+
+#include <cassert>
+#include <cmath>
+#include <cstdio>
+#include <cstring>
+
+// Test tolerance for floating-point comparisons
+static const float EPSILON = 1e-5f;
+
+// Helper: Compare floats with tolerance
+static bool float_eq(float a, float b) {
+ return fabsf(a - b) < EPSILON;
+}
+
+// Test: Linear Bezier interpolation with 2 control points (simple line)
+void test_bezier_linear_2points() {
+ const float frames[] = {0.0f, 100.0f};
+ const float values[] = {50.0f, 150.0f};
+
+ // At control points, should return exact values
+ assert(float_eq(evaluate_bezier_linear(frames, values, 2, 0.0f), 50.0f));
+ assert(float_eq(evaluate_bezier_linear(frames, values, 2, 100.0f), 150.0f));
+
+ // Midpoint: linear interpolation
+ const float mid = evaluate_bezier_linear(frames, values, 2, 50.0f);
+ assert(float_eq(mid, 100.0f)); // (50 + 150) / 2
+
+ // Quarter point
+ const float quarter = evaluate_bezier_linear(frames, values, 2, 25.0f);
+ assert(float_eq(quarter, 75.0f)); // 50 + (150 - 50) * 0.25
+
+ printf("[PASS] test_bezier_linear_2points\n");
+}
+
+// Test: Linear Bezier interpolation with 4 control points
+void test_bezier_linear_4points() {
+ const float frames[] = {0.0f, 20.0f, 50.0f, 100.0f};
+ const float values[] = {200.0f, 80.0f, 60.0f, 50.0f};
+
+ // At control points
+ assert(float_eq(evaluate_bezier_linear(frames, values, 4, 0.0f), 200.0f));
+ assert(float_eq(evaluate_bezier_linear(frames, values, 4, 20.0f), 80.0f));
+ assert(float_eq(evaluate_bezier_linear(frames, values, 4, 50.0f), 60.0f));
+ assert(float_eq(evaluate_bezier_linear(frames, values, 4, 100.0f), 50.0f));
+
+ // Between first and second point (frame 10)
+ const float interp1 = evaluate_bezier_linear(frames, values, 4, 10.0f);
+ // t = (10 - 0) / (20 - 0) = 0.5
+ // value = 200 * 0.5 + 80 * 0.5 = 140
+ assert(float_eq(interp1, 140.0f));
+
+ // Between third and fourth point (frame 75)
+ const float interp2 = evaluate_bezier_linear(frames, values, 4, 75.0f);
+ // t = (75 - 50) / (100 - 50) = 0.5
+ // value = 60 * 0.5 + 50 * 0.5 = 55
+ assert(float_eq(interp2, 55.0f));
+
+ printf("[PASS] test_bezier_linear_4points\n");
+}
+
+// Test: Edge cases (single point, empty, out of range)
+void test_bezier_edge_cases() {
+ const float frames[] = {50.0f};
+ const float values[] = {123.0f};
+
+ // Single control point: always return that value
+ assert(float_eq(evaluate_bezier_linear(frames, values, 1, 0.0f), 123.0f));
+ assert(float_eq(evaluate_bezier_linear(frames, values, 1, 100.0f), 123.0f));
+
+ // Empty array: return 0
+ assert(float_eq(evaluate_bezier_linear(frames, values, 0, 50.0f), 0.0f));
+
+ // Out of range: clamp to endpoints
+ const float frames2[] = {10.0f, 90.0f};
+ const float values2[] = {100.0f, 200.0f};
+ assert(float_eq(evaluate_bezier_linear(frames2, values2, 2, 0.0f),
+ 100.0f)); // Before start
+ assert(float_eq(evaluate_bezier_linear(frames2, values2, 2, 100.0f),
+ 200.0f)); // After end
+
+ printf("[PASS] test_bezier_edge_cases\n");
+}
+
+// Test: Gaussian profile evaluation
+void test_profile_gaussian() {
+ // At center (distance = 0), should be 1.0
+ assert(float_eq(evaluate_profile(PROFILE_GAUSSIAN, 0.0f, 30.0f, 0.0f), 1.0f));
+
+ // Gaussian falloff: exp(-(dist^2 / sigma^2))
+ const float sigma = 30.0f;
+ const float dist = 15.0f;
+ const float expected = expf(-(dist * dist) / (sigma * sigma));
+ const float actual = evaluate_profile(PROFILE_GAUSSIAN, dist, sigma, 0.0f);
+ assert(float_eq(actual, expected));
+
+ // Far from center: should approach 0
+ const float far = evaluate_profile(PROFILE_GAUSSIAN, 100.0f, 30.0f, 0.0f);
+ assert(far < 0.01f); // Very small
+
+ printf("[PASS] test_profile_gaussian\n");
+}
+
+// Test: Decaying sinusoid profile evaluation
+void test_profile_decaying_sinusoid() {
+ const float decay = 0.15f;
+ const float omega = 0.8f;
+
+ // At center (distance = 0)
+ // exp(-0 * 0.15) * cos(0 * 0.8) = 1.0 * 1.0 = 1.0
+ assert(float_eq(
+ evaluate_profile(PROFILE_DECAYING_SINUSOID, 0.0f, decay, omega), 1.0f));
+
+ // At distance 10
+ const float dist = 10.0f;
+ const float expected = expf(-decay * dist) * cosf(omega * dist);
+ const float actual =
+ evaluate_profile(PROFILE_DECAYING_SINUSOID, dist, decay, omega);
+ assert(float_eq(actual, expected));
+
+ printf("[PASS] test_profile_decaying_sinusoid\n");
+}
+
+// Test: Noise profile evaluation (deterministic)
+void test_profile_noise() {
+ const float amplitude = 0.5f;
+ const uint32_t seed = 42;
+
+ // Same distance + seed should produce same value
+ const float val1 =
+ evaluate_profile(PROFILE_NOISE, 10.0f, amplitude, (float)seed);
+ const float val2 =
+ evaluate_profile(PROFILE_NOISE, 10.0f, amplitude, (float)seed);
+ assert(float_eq(val1, val2));
+
+ // Different distance should produce different value (with high probability)
+ const float val3 =
+ evaluate_profile(PROFILE_NOISE, 20.0f, amplitude, (float)seed);
+ assert(!float_eq(val1, val3));
+
+ // Should be in range [0, amplitude]
+ assert(val1 >= 0.0f && val1 <= amplitude);
+
+ printf("[PASS] test_profile_noise\n");
+}
+
+// Test: draw_bezier_curve full integration
+void test_draw_bezier_curve() {
+ const int dct_size = 512;
+ const int num_frames = 100;
+ float spectrogram[512 * 100];
+ memset(spectrogram, 0, sizeof(spectrogram));
+
+ // Simple curve: constant frequency, linearly decaying amplitude
+ const float frames[] = {0.0f, 100.0f};
+ const float freqs[] = {440.0f, 440.0f}; // A4 note (constant pitch)
+ const float amps[] = {1.0f, 0.0f}; // Fade out
+
+ draw_bezier_curve(spectrogram, dct_size, num_frames, frames, freqs, amps, 2,
+ PROFILE_GAUSSIAN, 30.0f);
+
+ // Verify: At frame 0, should have peak around 440 Hz bin
+ // bin = (440 / 16000) * 512 ≈ 14.08
+ const int expected_bin = 14;
+ const float val_at_peak = spectrogram[0 * dct_size + expected_bin];
+ assert(val_at_peak > 0.5f); // Should be near 1.0 due to Gaussian
+
+ // Verify: At frame 99 (end), amplitude should be near 0
+ const float val_at_end = spectrogram[99 * dct_size + expected_bin];
+ assert(val_at_end < 0.1f); // Near zero
+
+ // Verify: At frame 50 (midpoint), amplitude should be ~0.5
+ const float val_at_mid = spectrogram[50 * dct_size + expected_bin];
+ assert(val_at_mid > 0.3f && val_at_mid < 0.7f); // Around 0.5
+
+ printf("[PASS] test_draw_bezier_curve\n");
+}
+
+// Test: draw_bezier_curve_add (additive mode)
+void test_draw_bezier_curve_add() {
+ const int dct_size = 512;
+ const int num_frames = 100;
+ float spectrogram[512 * 100];
+ memset(spectrogram, 0, sizeof(spectrogram));
+
+ // Draw first curve
+ const float frames1[] = {0.0f, 100.0f};
+ const float freqs1[] = {440.0f, 440.0f};
+ const float amps1[] = {0.5f, 0.5f};
+ draw_bezier_curve(spectrogram, dct_size, num_frames, frames1, freqs1, amps1,
+ 2, PROFILE_GAUSSIAN, 30.0f);
+
+ const int bin = 14; // ~440 Hz
+ const float val_before_add = spectrogram[0 * dct_size + bin];
+
+ // Add second curve (same frequency, same amplitude)
+ draw_bezier_curve_add(spectrogram, dct_size, num_frames, frames1, freqs1,
+ amps1, 2, PROFILE_GAUSSIAN, 30.0f);
+
+ const float val_after_add = spectrogram[0 * dct_size + bin];
+
+ // Should be approximately doubled
+ assert(val_after_add > val_before_add * 1.8f); // Allow small error
+
+ printf("[PASS] test_draw_bezier_curve_add\n");
+}
+
+// Test: RNG determinism
+void test_rng_determinism() {
+ const uint32_t seed = 12345;
+
+ // Same seed should produce same value
+ const uint32_t val1 = spectral_brush_rand(seed);
+ const uint32_t val2 = spectral_brush_rand(seed);
+ assert(val1 == val2);
+
+ // Different seeds should produce different values
+ const uint32_t val3 = spectral_brush_rand(seed + 1);
+ assert(val1 != val3);
+
+ printf("[PASS] test_rng_determinism\n");
+}
+
+int main() {
+ printf("Running spectral brush tests...\n\n");
+
+ test_bezier_linear_2points();
+ test_bezier_linear_4points();
+ test_bezier_edge_cases();
+ test_profile_gaussian();
+ test_profile_decaying_sinusoid();
+ test_profile_noise();
+ test_draw_bezier_curve();
+ test_draw_bezier_curve_add();
+ test_rng_determinism();
+
+ printf("\n✓ All tests passed!\n");
+ return 0;
+}
diff --git a/src/tests/audio/test_synth.cc b/src/tests/audio/test_synth.cc
new file mode 100644
index 0000000..12cbc54
--- /dev/null
+++ b/src/tests/audio/test_synth.cc
@@ -0,0 +1,113 @@
+// This file is part of the 64k demo project.
+// It tests the core functionality of the audio synthesis engine.
+// Verifies voice triggering, registration, and rendering state.
+
+#include "audio/synth.h"
+#include <assert.h>
+#include <cmath>
+#include <stdio.h>
+
+void test_registration() {
+ synth_init();
+ float data[DCT_SIZE * 2] = {0};
+ Spectrogram spec = {data, data, 2};
+
+ int id = synth_register_spectrogram(&spec);
+ assert(id >= 0);
+ assert(synth_get_active_voice_count() == 0);
+
+ synth_unregister_spectrogram(id);
+ // Re-register to check slot reuse
+ int id2 = synth_register_spectrogram(&spec);
+ assert(id2 == id); // Should reuse the slot 0
+}
+
+void test_trigger() {
+ synth_init();
+ float data[DCT_SIZE * 2] = {0};
+ Spectrogram spec = {data, data, 2};
+ int id = synth_register_spectrogram(&spec);
+
+ synth_trigger_voice(id, 1.0f, 0.0f);
+ assert(synth_get_active_voice_count() == 1);
+}
+
+void test_render() {
+ synth_init();
+ float data[DCT_SIZE * 2] = {0};
+ // Put some signal in (DC component)
+ data[0] = 100.0f;
+
+ Spectrogram spec = {data, data, 2};
+ int id = synth_register_spectrogram(&spec);
+
+ synth_trigger_voice(id, 1.0f, 0.0f);
+
+ float output[1024] = {0};
+ synth_render(output, 256);
+
+ // Verify output is not all zero (IDCT of DC component should be constant)
+ bool non_zero = false;
+ for (int i = 0; i < 256; ++i) {
+ if (std::abs(output[i]) > 1e-6f)
+ non_zero = true;
+ }
+ assert(non_zero);
+
+ // Test render with no voices
+ synth_init(); // Reset
+ float output2[1024] = {0};
+ synth_render(output2, 256);
+ for (int i = 0; i < 256; ++i)
+ assert(output2[i] == 0.0f);
+}
+
+void test_update() {
+ synth_init();
+ float data[DCT_SIZE * 2] = {0};
+ Spectrogram spec = {data, data, 2};
+ int id = synth_register_spectrogram(&spec);
+
+ float* back_buf = synth_begin_update(id);
+ assert(back_buf != nullptr);
+ // Write something
+ back_buf[0] = 50.0f;
+ synth_commit_update(id);
+
+ // Test invalid ID
+ assert(synth_begin_update(-1) == nullptr);
+ synth_commit_update(-1); // Should not crash
+}
+
+void test_exhaustion() {
+ synth_init();
+ float data[DCT_SIZE * 2] = {0};
+ Spectrogram spec = {data, data, 2};
+
+ for (int i = 0; i < MAX_SPECTROGRAMS; ++i) {
+ int id = synth_register_spectrogram(&spec);
+ assert(id >= 0);
+ }
+ // Next one should fail
+ int id = synth_register_spectrogram(&spec);
+ assert(id == -1);
+}
+
+void test_peak() {
+ // Already called render in test_render.
+ // Just call the getter.
+ float peak = synth_get_output_peak();
+ assert(peak >= 0.0f);
+}
+
+int main() {
+ printf("Running SynthEngine tests...\n");
+ test_registration();
+ test_trigger();
+ test_render();
+ test_update();
+ test_exhaustion();
+ test_peak();
+ printf("SynthEngine tests PASSED\n");
+ return 0;
+} \ No newline at end of file
diff --git a/src/tests/audio/test_tracker.cc b/src/tests/audio/test_tracker.cc
new file mode 100644
index 0000000..6be2a8d
--- /dev/null
+++ b/src/tests/audio/test_tracker.cc
@@ -0,0 +1,73 @@
+// This file is part of the 64k demo project.
+// It tests the core functionality of the audio tracker engine.
+
+#include "audio/audio_engine.h"
+#include "audio/gen.h"
+#include "audio/synth.h"
+#include "audio/tracker.h"
+// #include "generated/music_data.h" // Will be generated by tracker_compiler
+#include <assert.h>
+#include <stdio.h>
+
+// Forward declaration for generated data
+extern const NoteParams g_tracker_samples[];
+extern const uint32_t g_tracker_samples_count;
+extern const TrackerPattern g_tracker_patterns[];
+extern const uint32_t g_tracker_patterns_count;
+extern const TrackerScore g_tracker_score;
+
+void test_tracker_init() {
+ AudioEngine engine;
+ engine.init();
+ printf("Tracker init test PASSED\n");
+ engine.shutdown();
+}
+
+void test_tracker_pattern_triggering() {
+ AudioEngine engine;
+ engine.init();
+
+ // At time 0.0f, 3 patterns are triggered:
+ // - crash (1 event at beat 0.0)
+ // - kick_basic (events at beat 0.0, 2.0, 2.5)
+ // - hihat_basic (events at beat 0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5)
+ // With event-based triggering, only events at beat 0.0 trigger immediately.
+
+ // Test 1: At music_time = 0.0f, events at beat 0.0 trigger
+ // drums_basic:
+ // 0.00, ASSET_KICK_1
+ // 0.00, NOTE_A4
+ engine.update(0.0f, 0.0f);
+ // Expect 2 voices: kick + note
+ assert(engine.get_active_voice_count() == 2);
+
+ // Test 2: At music_time = 0.25f (beat 0.5 @ 120 BPM), snare event triggers
+ // 0.25, ASSET_SNARE_1
+ engine.update(0.25f, 0.0f);
+ // Expect at least 2 voices (snare + maybe others)
+ // Exact count depends on sample duration (kick/note might have finished)
+ int voices = engine.get_active_voice_count();
+ assert(voices >= 2);
+
+ // Test 3: At music_time = 0.5f (beat 1.0), kick event triggers
+ // 0.50, ASSET_KICK_1
+ engine.update(0.5f, 0.0f);
+ // Expect at least 3 voices (new kick + others)
+ assert(engine.get_active_voice_count() >= 3);
+
+ // Test 4: Advance to 2.0f - new patterns trigger at time 2.0f
+ engine.update(2.0f, 0.0f);
+ // Many events have triggered by now
+ assert(engine.get_active_voice_count() > 5);
+
+ printf("Tracker pattern triggering test PASSED\n");
+ engine.shutdown();
+}
+
+int main() {
+ printf("Running Tracker tests...\n");
+ test_tracker_init();
+ test_tracker_pattern_triggering();
+ printf("Tracker tests PASSED\n");
+ return 0;
+}
diff --git a/src/tests/audio/test_tracker_timing.cc b/src/tests/audio/test_tracker_timing.cc
new file mode 100644
index 0000000..9f15197
--- /dev/null
+++ b/src/tests/audio/test_tracker_timing.cc
@@ -0,0 +1,309 @@
+// This file is part of the 64k demo project.
+// It tests tracker timing and synchronization using MockAudioBackend.
+// Verifies pattern triggers occur at correct times with proper BPM scaling.
+
+#include "audio/audio.h"
+#include "audio/audio_engine.h"
+#include "audio/backend/mock_audio_backend.h"
+#include "audio/synth.h"
+#include "audio/tracker.h"
+#include <assert.h>
+#include <cmath>
+#include <stdio.h>
+
+#if !defined(STRIP_ALL)
+
+// Helper: Setup audio engine for testing
+static void setup_audio_test(MockAudioBackend& backend, AudioEngine& engine) {
+ audio_set_backend(&backend);
+ engine.init();
+}
+
+// Helper: Check if a timestamp exists in events within tolerance
+static bool has_event_at_time(const std::vector<VoiceTriggerEvent>& events,
+ float expected_time, float tolerance = 0.001f) {
+ for (const auto& evt : events) {
+ if (std::abs(evt.timestamp_sec - expected_time) < tolerance) {
+ return true;
+ }
+ }
+ return false;
+}
+
+// Helper: Count events at a specific time
+static int count_events_at_time(const std::vector<VoiceTriggerEvent>& events,
+ float expected_time, float tolerance = 0.001f) {
+ int count = 0;
+ for (const auto& evt : events) {
+ if (std::abs(evt.timestamp_sec - expected_time) < tolerance) {
+ count++;
+ }
+ }
+ return count;
+}
+
+// Helper: Get all unique timestamps in events
+static std::vector<float>
+get_unique_timestamps(const std::vector<VoiceTriggerEvent>& events,
+ float tolerance = 0.001f) {
+ std::vector<float> timestamps;
+ for (const auto& evt : events) {
+ bool found = false;
+ for (float ts : timestamps) {
+ if (std::abs(evt.timestamp_sec - ts) < tolerance) {
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ timestamps.push_back(evt.timestamp_sec);
+ }
+ }
+ return timestamps;
+}
+
+void test_basic_event_recording() {
+ printf("Test: Basic event recording with mock backend...\n");
+
+ MockAudioBackend backend;
+ AudioEngine engine;
+ setup_audio_test(backend, engine);
+
+ engine.update(0.0f, 0.0f);
+ const auto& events = backend.get_events();
+ printf(" Events triggered at t=0.0: %zu\n", events.size());
+
+ assert(events.size() > 0);
+ for (const auto& evt : events) {
+ assert(evt.timestamp_sec < 0.1f);
+ }
+
+ engine.shutdown();
+ printf(" ✓ Basic event recording works\n");
+}
+
+void test_progressive_triggering() {
+ printf("Test: Progressive pattern triggering...\n");
+
+ MockAudioBackend backend;
+ AudioEngine engine;
+ setup_audio_test(backend, engine);
+
+ engine.update(0.0f, 0.0f);
+ const size_t events_at_0 = backend.get_events().size();
+ printf(" Events at t=0.0: %zu\n", events_at_0);
+
+ engine.update(1.0f, 0.0f);
+ const size_t events_at_1 = backend.get_events().size();
+ printf(" Events at t=1.0: %zu\n", events_at_1);
+
+ engine.update(2.0f, 0.0f);
+ const size_t events_at_2 = backend.get_events().size();
+ printf(" Events at t=2.0: %zu\n", events_at_2);
+
+ assert(events_at_1 >= events_at_0);
+ assert(events_at_2 >= events_at_1);
+
+ engine.shutdown();
+ printf(" ✓ Events accumulate over time\n");
+}
+
+void test_simultaneous_triggers() {
+ printf("Test: SIMULTANEOUS pattern triggers at same time...\n");
+
+ MockAudioBackend backend;
+ AudioEngine engine;
+ setup_audio_test(backend, engine);
+
+ backend.clear_events();
+ engine.update(0.0f, 0.0f);
+
+ const auto& events = backend.get_events();
+ if (events.size() == 0) {
+ printf(" No events at t=0.0, skipping test\n");
+ return;
+ }
+
+ // Check if we have multiple events at t=0
+ const int simultaneous_count = count_events_at_time(events, 0.0f, 0.001f);
+ printf(" Simultaneous events at t=0.0: %d out of %zu total\n",
+ simultaneous_count, events.size());
+
+ if (simultaneous_count > 1) {
+ // Verify all simultaneous events have EXACTLY the same timestamp
+ const float first_timestamp = events[0].timestamp_sec;
+ float max_delta = 0.0f;
+
+ for (size_t i = 1; i < events.size(); ++i) {
+ const float delta = std::abs(events[i].timestamp_sec - first_timestamp);
+ max_delta = std::fmaxf(max_delta, delta);
+ }
+
+ printf(" Maximum timestamp delta: %.6f seconds (%.3f ms)\n", max_delta,
+ max_delta * 1000.0f);
+
+ // Simultaneous events should have sub-millisecond timing
+ assert(max_delta < 0.001f); // Less than 1ms difference
+
+ printf(" ✓ All simultaneous events within 1ms of each other\n");
+ } else {
+ printf(" ℹ Only one event at t=0.0, cannot verify simultaneity\n");
+ }
+
+ engine.shutdown();
+}
+
+void test_timing_monotonicity() {
+ printf("Test: Event timestamps are monotonically increasing...\n");
+
+ MockAudioBackend backend;
+ AudioEngine engine;
+ setup_audio_test(backend, engine);
+
+ for (float t = 0.0f; t <= 5.0f; t += 0.5f) {
+ engine.update(t, 0.5f);
+ }
+
+ const auto& events = backend.get_events();
+ printf(" Total events recorded: %zu\n", events.size());
+
+ // Verify timestamps are monotonically increasing (non-decreasing)
+ for (size_t i = 1; i < events.size(); ++i) {
+ assert(events[i].timestamp_sec >= events[i - 1].timestamp_sec);
+ }
+
+ engine.shutdown();
+ printf(" ✓ All timestamps monotonically increasing\n");
+}
+
+void test_seek_simulation() {
+ printf("Test: Seek/fast-forward simulation...\n");
+
+ MockAudioBackend backend;
+ audio_set_backend(&backend);
+
+ audio_init();
+ AudioEngine engine;
+ engine.init();
+
+ // Simulate seeking to t=3.0s by rendering silent audio
+ // This should trigger all patterns in range [0, 3.0]
+ const float seek_target = 3.0f;
+
+ // Update tracker progressively (simulating real playback)
+ float t = 0.0f;
+ const float step = 0.1f;
+ while (t <= seek_target) {
+ engine.update(t, step);
+ // Simulate audio rendering
+ float dummy_buffer[512 * 2];
+ engine.render(dummy_buffer, 512);
+ t += step;
+ }
+
+ const auto& events = backend.get_events();
+ printf(" Events triggered during seek to %.1fs: %zu\n", seek_target,
+ events.size());
+
+ // Should have triggered multiple patterns
+ assert(events.size() > 0);
+
+ // All events should be before seek target time
+ for (const auto& evt : events) {
+ // Events can be slightly after due to synth processing
+ assert(evt.timestamp_sec <= seek_target + 0.5f);
+ }
+
+ engine.shutdown();
+ audio_shutdown();
+
+ printf(" ✓ Seek simulation works correctly\n");
+}
+
+void test_timestamp_clustering() {
+ printf("Test: Analyzing timestamp clustering...\n");
+
+ MockAudioBackend backend;
+ audio_set_backend(&backend);
+
+ AudioEngine engine;
+ engine.init();
+
+ // Update through the first 4 seconds
+ for (float t = 0.0f; t <= 4.0f; t += 0.1f) {
+ engine.update(t, 0.1f);
+ }
+
+ const auto& events = backend.get_events();
+ printf(" Total events: %zu\n", events.size());
+
+ // Get unique timestamps
+ auto unique_timestamps = get_unique_timestamps(events, 0.001f);
+ printf(" Unique trigger times: %zu\n", unique_timestamps.size());
+
+ // For each unique timestamp, count how many events occurred
+ for (float ts : unique_timestamps) {
+ const int count = count_events_at_time(events, ts, 0.001f);
+ if (count > 1) {
+ printf(" %.3fs: %d simultaneous events\n", ts, count);
+ }
+ }
+
+ engine.shutdown();
+ printf(" ✓ Timestamp clustering analyzed\n");
+}
+
+void test_render_integration() {
+ printf("Test: Integration with audio_render_silent...\n");
+
+ MockAudioBackend backend;
+ audio_set_backend(&backend);
+
+ audio_init();
+ AudioEngine engine;
+ engine.init();
+
+ // Trigger some patterns
+ engine.update(0.0f, 0.0f);
+ const size_t events_before = backend.get_events().size();
+
+ // Render 1 second of silent audio
+ audio_render_silent(1.0f);
+
+ // Check that backend time advanced
+ const float backend_time = backend.get_current_time();
+ printf(" Backend time after 1s render: %.3fs\n", backend_time);
+ assert(backend_time >= 0.9f && backend_time <= 1.1f);
+
+ // Trigger more patterns after time advance
+ engine.update(1.0f, 0.0f);
+ const size_t events_after = backend.get_events().size();
+
+ printf(" Events before: %zu, after: %zu\n", events_before, events_after);
+ assert(events_after >= events_before);
+
+ engine.shutdown();
+ audio_shutdown();
+
+ printf(" ✓ audio_render_silent integration works\n");
+}
+
+#endif /* !defined(STRIP_ALL) */
+
+int main() {
+#if !defined(STRIP_ALL)
+ printf("Running Tracker Timing tests...\n\n");
+ test_basic_event_recording();
+ test_progressive_triggering();
+ test_simultaneous_triggers();
+ test_timing_monotonicity();
+ test_seek_simulation();
+ test_timestamp_clustering();
+ test_render_integration();
+ printf("\n✅ All Tracker Timing tests PASSED\n");
+ return 0;
+#else
+ printf("Tracker Timing tests skipped (STRIP_ALL enabled)\n");
+ return 0;
+#endif /* !defined(STRIP_ALL) */
+}
diff --git a/src/tests/audio/test_variable_tempo.cc b/src/tests/audio/test_variable_tempo.cc
new file mode 100644
index 0000000..bbc9ebf
--- /dev/null
+++ b/src/tests/audio/test_variable_tempo.cc
@@ -0,0 +1,291 @@
+// This file is part of the 64k demo project.
+// It tests variable tempo system with music_time scaling.
+// Verifies 2x speed-up and 2x slow-down reset tricks.
+
+#include "audio/audio.h"
+#include "audio/audio_engine.h"
+#include "audio/backend/mock_audio_backend.h"
+#include "audio/tracker.h"
+#include <assert.h>
+#include <cmath>
+#include <stdio.h>
+
+#if !defined(STRIP_ALL)
+
+// Helper: Setup audio engine for testing
+static void setup_audio_test(MockAudioBackend& backend, AudioEngine& engine) {
+ audio_set_backend(&backend);
+ engine.init();
+ engine.load_music_data(&g_tracker_score, g_tracker_samples,
+ g_tracker_sample_assets, g_tracker_samples_count);
+}
+
+// Helper: Simulate tempo advancement with fixed steps
+static void simulate_tempo(AudioEngine& engine, float& music_time,
+ float duration, float tempo_scale, float dt = 0.1f) {
+ const int steps = (int)(duration / dt);
+ for (int i = 0; i < steps; ++i) {
+ music_time += dt * tempo_scale;
+ engine.update(music_time, dt * tempo_scale);
+ }
+}
+
+// Helper: Simulate tempo with variable scaling function
+static void simulate_tempo_fn(AudioEngine& engine, float& music_time,
+ float& physical_time, float duration, float dt,
+ float (*tempo_fn)(float)) {
+ const int steps = (int)(duration / dt);
+ for (int i = 0; i < steps; ++i) {
+ physical_time += dt;
+ const float tempo_scale = tempo_fn(physical_time);
+ music_time += dt * tempo_scale;
+ engine.update(music_time, dt * tempo_scale);
+ }
+}
+
+void test_basic_tempo_scaling() {
+ printf("Test: Basic tempo scaling (1.0x, 2.0x, 0.5x)...\n");
+
+ MockAudioBackend backend;
+ AudioEngine engine;
+ setup_audio_test(backend, engine);
+
+ // Test 1: Normal tempo (1.0x)
+ {
+ backend.clear_events();
+ float music_time = 0.0f;
+ simulate_tempo(engine, music_time, 1.0f, 1.0f);
+ printf(" 1.0x tempo: music_time = %.3f (expected ~1.0)\n", music_time);
+ assert(std::abs(music_time - 1.0f) < 0.01f);
+ }
+
+ // Test 2: Fast tempo (2.0x)
+ {
+ backend.clear_events();
+ engine.reset();
+ float music_time = 0.0f;
+ simulate_tempo(engine, music_time, 1.0f, 2.0f);
+ printf(" 2.0x tempo: music_time = %.3f (expected ~2.0)\n", music_time);
+ assert(std::abs(music_time - 2.0f) < 0.01f);
+ }
+
+ // Test 3: Slow tempo (0.5x)
+ {
+ backend.clear_events();
+ engine.reset();
+ float music_time = 0.0f;
+ simulate_tempo(engine, music_time, 1.0f, 0.5f);
+ printf(" 0.5x tempo: music_time = %.3f (expected ~0.5)\n", music_time);
+ assert(std::abs(music_time - 0.5f) < 0.01f);
+ }
+
+ engine.shutdown();
+ printf(" ✓ Basic tempo scaling works correctly\n");
+}
+
+void test_2x_speedup_reset_trick() {
+ printf("Test: 2x SPEED-UP reset trick...\n");
+
+ MockAudioBackend backend;
+ AudioEngine engine;
+ setup_audio_test(backend, engine);
+
+ float music_time = 0.0f;
+ float physical_time = 0.0f;
+ const float dt = 0.1f;
+
+ // Phase 1: Accelerate from 1.0x to 2.0x over 5 seconds
+ printf(" Phase 1: Accelerating 1.0x → 2.0x\n");
+ auto accel_fn = [](float t) { return fminf(1.0f + (t / 5.0f), 2.0f); };
+ simulate_tempo_fn(engine, music_time, physical_time, 5.0f, dt, accel_fn);
+
+ const float tempo_scale = accel_fn(physical_time);
+ printf(" After 5s physical: tempo=%.2fx, music_time=%.3f\n", tempo_scale,
+ music_time);
+ assert(tempo_scale >= 1.99f);
+
+ // Phase 2: RESET - back to 1.0x tempo
+ printf(" Phase 2: RESET to 1.0x tempo\n");
+ const float music_time_before_reset = music_time;
+ simulate_tempo(engine, music_time, 2.0f, 1.0f, dt);
+
+ printf(" After reset + 2s: tempo=1.0x, music_time=%.3f\n", music_time);
+ const float music_time_delta = music_time - music_time_before_reset;
+ printf(" Music time delta: %.3f (expected ~2.0)\n", music_time_delta);
+ assert(std::abs(music_time_delta - 2.0f) < 0.1f);
+
+ engine.shutdown();
+ printf(" ✓ 2x speed-up reset trick verified\n");
+}
+
+void test_2x_slowdown_reset_trick() {
+ printf("Test: 2x SLOW-DOWN reset trick...\n");
+
+ MockAudioBackend backend;
+ AudioEngine engine;
+ setup_audio_test(backend, engine);
+
+ float music_time = 0.0f;
+ float physical_time = 0.0f;
+ const float dt = 0.1f;
+
+ // Phase 1: Decelerate from 1.0x to 0.5x over 5 seconds
+ printf(" Phase 1: Decelerating 1.0x → 0.5x\n");
+ auto decel_fn = [](float t) { return fmaxf(1.0f - (t / 10.0f), 0.5f); };
+ simulate_tempo_fn(engine, music_time, physical_time, 5.0f, dt, decel_fn);
+
+ const float tempo_scale = decel_fn(physical_time);
+ printf(" After 5s physical: tempo=%.2fx, music_time=%.3f\n", tempo_scale,
+ music_time);
+ assert(tempo_scale <= 0.51f);
+
+ // Phase 2: RESET - back to 1.0x tempo
+ printf(" Phase 2: RESET to 1.0x tempo\n");
+ const float music_time_before_reset = music_time;
+ simulate_tempo(engine, music_time, 2.0f, 1.0f, dt);
+
+ printf(" After reset + 2s: tempo=1.0x, music_time=%.3f\n", music_time);
+ const float music_time_delta = music_time - music_time_before_reset;
+ printf(" Music time delta: %.3f (expected ~2.0)\n", music_time_delta);
+ assert(std::abs(music_time_delta - 2.0f) < 0.1f);
+
+ engine.shutdown();
+ printf(" ✓ 2x slow-down reset trick verified\n");
+}
+
+void test_pattern_density_swap() {
+ printf("Test: Pattern density swap at reset points...\n");
+
+ MockAudioBackend backend;
+ AudioEngine engine;
+ setup_audio_test(backend, engine);
+
+ float music_time = 0.0f;
+
+ // Phase 1: Sparse pattern at normal tempo
+ printf(" Phase 1: Sparse pattern, normal tempo\n");
+ simulate_tempo(engine, music_time, 3.0f, 1.0f);
+ const size_t sparse_events = backend.get_events().size();
+ printf(" Events during sparse phase: %zu\n", sparse_events);
+
+ // Phase 2: Accelerate to 2.0x
+ printf(" Phase 2: Accelerating to 2.0x\n");
+ simulate_tempo(engine, music_time, 2.0f, 2.0f);
+ const size_t events_at_2x = backend.get_events().size() - sparse_events;
+ printf(" Additional events during 2.0x: %zu\n", events_at_2x);
+
+ // Phase 3: Reset to 1.0x
+ printf(" Phase 3: Reset to 1.0x (simulating denser pattern)\n");
+ const size_t events_before_reset_phase = backend.get_events().size();
+ simulate_tempo(engine, music_time, 2.0f, 1.0f);
+ const size_t events_after_reset = backend.get_events().size();
+
+ printf(" Events during reset phase: %zu\n",
+ events_after_reset - events_before_reset_phase);
+ assert(backend.get_events().size() > 0);
+
+ engine.shutdown();
+ printf(" ✓ Pattern density swap points verified\n");
+}
+
+void test_continuous_acceleration() {
+ printf("Test: Continuous acceleration from 0.5x to 2.0x...\n");
+
+ MockAudioBackend backend;
+ AudioEngine engine;
+ setup_audio_test(backend, engine);
+
+ float music_time = 0.0f;
+ float physical_time = 0.0f;
+ const float dt = 0.05f;
+ const float min_tempo = 0.5f;
+ const float max_tempo = 2.0f;
+
+ printf(" Accelerating 0.5x → 2.0x over 10 seconds\n");
+
+ auto accel_fn = [min_tempo, max_tempo](float t) {
+ const float progress = t / 10.0f;
+ return fmaxf(
+ min_tempo,
+ fminf(max_tempo, min_tempo + progress * (max_tempo - min_tempo)));
+ };
+
+ const int steps = (int)(10.0f / dt);
+ for (int i = 0; i < steps; ++i) {
+ physical_time += dt;
+ const float tempo_scale = accel_fn(physical_time);
+ music_time += dt * tempo_scale;
+ engine.update(music_time, dt * tempo_scale);
+ if (i % 50 == 0) {
+ printf(" t=%.1fs: tempo=%.2fx, music_time=%.3f\n", physical_time,
+ tempo_scale, music_time);
+ }
+ }
+
+ const float final_tempo = accel_fn(physical_time);
+ printf(" Final: tempo=%.2fx, music_time=%.3f\n", final_tempo, music_time);
+ assert(final_tempo >= 1.99f);
+
+ // Verify music_time (integral: 0.5*10 + 1.5*10²/(2*10) = 12.5)
+ const float expected_music_time = 12.5f;
+ printf(" Expected music_time: %.3f, actual: %.3f\n", expected_music_time,
+ music_time);
+ assert(std::abs(music_time - expected_music_time) < 0.5f);
+
+ engine.shutdown();
+ printf(" ✓ Continuous acceleration verified\n");
+}
+
+void test_oscillating_tempo() {
+ printf("Test: Oscillating tempo (sine wave)...\n");
+
+ MockAudioBackend backend;
+ AudioEngine engine;
+ setup_audio_test(backend, engine);
+
+ float music_time = 0.0f;
+ float physical_time = 0.0f;
+ const float dt = 0.05f;
+
+ printf(" Oscillating tempo: 0.8x ↔ 1.2x\n");
+
+ auto oscil_fn = [](float t) { return 1.0f + 0.2f * sinf(t * 2.0f); };
+
+ const int steps = 100;
+ for (int i = 0; i < steps; ++i) {
+ physical_time += dt;
+ const float tempo_scale = oscil_fn(physical_time);
+ music_time += dt * tempo_scale;
+ engine.update(music_time, dt * tempo_scale);
+ if (i % 25 == 0) {
+ printf(" t=%.2fs: tempo=%.3fx, music_time=%.3f\n", physical_time,
+ tempo_scale, music_time);
+ }
+ }
+
+ printf(" Final: physical_time=%.2fs, music_time=%.3f (expected ~%.2f)\n",
+ physical_time, music_time, physical_time);
+ assert(std::abs(music_time - physical_time) < 0.5f);
+
+ engine.shutdown();
+ printf(" ✓ Oscillating tempo verified\n");
+}
+
+#endif /* !defined(STRIP_ALL) */
+
+int main() {
+#if !defined(STRIP_ALL)
+ printf("Running Variable Tempo tests...\n\n");
+ test_basic_tempo_scaling();
+ test_2x_speedup_reset_trick();
+ test_2x_slowdown_reset_trick();
+ test_pattern_density_swap();
+ test_continuous_acceleration();
+ test_oscillating_tempo();
+ printf("\n✅ All Variable Tempo tests PASSED\n");
+ return 0;
+#else
+ printf("Variable Tempo tests skipped (STRIP_ALL enabled)\n");
+ return 0;
+#endif /* !defined(STRIP_ALL) */
+}
diff --git a/src/tests/audio/test_wav_dump.cc b/src/tests/audio/test_wav_dump.cc
new file mode 100644
index 0000000..eb14652
--- /dev/null
+++ b/src/tests/audio/test_wav_dump.cc
@@ -0,0 +1,309 @@
+// This file is part of the 64k demo project.
+// Regression test for WAV dump backend to prevent format mismatches.
+
+#include "audio/audio.h"
+#include "audio/audio_engine.h"
+#include "audio/backend/wav_dump_backend.h"
+#include "audio/ring_buffer.h"
+#include <assert.h>
+#include <stdio.h>
+#include <string.h>
+#include <vector>
+
+#if !defined(STRIP_ALL)
+
+// Helper to read WAV header and verify format
+struct WavHeader {
+ char riff[4]; // "RIFF"
+ uint32_t chunk_size; // File size - 8
+ char wave[4]; // "WAVE"
+ char fmt[4]; // "fmt "
+ uint32_t subchunk1_size;
+ uint16_t audio_format; // 1 = PCM
+ uint16_t num_channels;
+ uint32_t sample_rate;
+ uint32_t byte_rate;
+ uint16_t block_align;
+ uint16_t bits_per_sample;
+ char data[4]; // "data"
+ uint32_t data_size;
+};
+
+void test_wav_format_matches_live_audio() {
+ printf("Test: WAV format matches live audio output...\n");
+
+ const char* test_file = "test_format.wav";
+
+ // Initialize audio system
+ audio_init();
+
+ // Initialize AudioEngine
+ AudioEngine engine;
+ engine.init();
+
+ // Create WAV dump backend
+ WavDumpBackend wav_backend;
+ wav_backend.set_output_file(test_file);
+ wav_backend.init();
+ wav_backend.start();
+
+ // Simulate 2 seconds of audio rendering (frontend-driven)
+ const float duration = 2.0f;
+ const float update_dt = 1.0f / 60.0f;
+ const int frames_per_update = (int)(32000 * update_dt);
+ const int samples_per_update = frames_per_update * 2; // Stereo
+
+ AudioRingBuffer* ring_buffer = audio_get_ring_buffer();
+ std::vector<float> chunk_buffer(samples_per_update);
+
+ float music_time = 0.0f;
+ for (float t = 0.0f; t < duration; t += update_dt) {
+ // Update audio engine (triggers patterns)
+ engine.update(music_time, update_dt);
+ music_time += update_dt;
+
+ // Render audio ahead
+ audio_render_ahead(music_time, update_dt);
+
+ // Read from ring buffer
+ if (ring_buffer != nullptr) {
+ ring_buffer->read(chunk_buffer.data(), samples_per_update);
+ }
+
+ // Write to WAV file
+ wav_backend.write_audio(chunk_buffer.data(), samples_per_update);
+ }
+
+ // Shutdown
+ wav_backend.shutdown();
+ engine.shutdown();
+ audio_shutdown();
+
+ // Read and verify WAV header
+ FILE* f = fopen(test_file, "rb");
+ assert(f != nullptr);
+
+ WavHeader header;
+ size_t bytes_read = fread(&header, 1, sizeof(WavHeader), f);
+ assert(bytes_read == sizeof(WavHeader));
+
+ // Verify RIFF header
+ assert(memcmp(header.riff, "RIFF", 4) == 0);
+ assert(memcmp(header.wave, "WAVE", 4) == 0);
+ assert(memcmp(header.fmt, "fmt ", 4) == 0);
+ assert(memcmp(header.data, "data", 4) == 0);
+
+ // CRITICAL: Verify stereo format (matches miniaudio config)
+ printf(" Checking num_channels...\n");
+ assert(header.num_channels == 2); // MUST be stereo!
+
+ // Verify sample rate matches miniaudio
+ printf(" Checking sample_rate...\n");
+ assert(header.sample_rate == 32000);
+
+ // Verify bit depth
+ printf(" Checking bits_per_sample...\n");
+ assert(header.bits_per_sample == 16);
+
+ // Verify audio format is PCM
+ printf(" Checking audio_format...\n");
+ assert(header.audio_format == 1); // PCM
+
+ // Verify calculated values
+ printf(" Checking byte_rate...\n");
+ const uint32_t expected_byte_rate =
+ header.sample_rate * header.num_channels * (header.bits_per_sample / 8);
+ assert(header.byte_rate == expected_byte_rate);
+
+ printf(" Checking block_align...\n");
+ const uint16_t expected_block_align =
+ header.num_channels * (header.bits_per_sample / 8);
+ assert(header.block_align == expected_block_align);
+
+ // Verify data size is reasonable (2 seconds of audio)
+ printf(" Checking data_size...\n");
+ const uint32_t bytes_per_sample = header.bits_per_sample / 8;
+ const uint32_t expected_bytes_per_sec =
+ header.sample_rate * header.num_channels * bytes_per_sample;
+ const uint32_t expected_size_2s = expected_bytes_per_sec * 2;
+
+ printf(" Data size: %u bytes (expected ~%u bytes for 2s)\n",
+ header.data_size, expected_size_2s);
+
+ // Be lenient: allow 1.5-2.5 seconds worth of data
+ const uint32_t expected_min_size = expected_bytes_per_sec * 1.5;
+ const uint32_t expected_max_size = expected_bytes_per_sec * 2.5;
+
+ // For now, accept if stereo format is correct (main regression test goal)
+ if (header.data_size < expected_min_size ||
+ header.data_size > expected_max_size) {
+ printf(" WARNING: Data size outside expected range\n");
+ // Don't fail on this for now - stereo format is the critical check
+ }
+
+ // Verify file contains actual audio data (not all zeros)
+ fseek(f, sizeof(WavHeader), SEEK_SET);
+ int16_t samples[1000];
+ size_t samples_read = fread(samples, sizeof(int16_t), 1000, f);
+ assert(samples_read == 1000);
+
+ int non_zero_count = 0;
+ for (int i = 0; i < 1000; ++i) {
+ if (samples[i] != 0) {
+ non_zero_count++;
+ }
+ }
+
+ printf(" Checking for actual audio data...\n");
+ printf(" Non-zero samples: %d / 1000\n", non_zero_count);
+ assert(non_zero_count > 100); // Should have plenty of non-zero samples
+
+ fclose(f);
+
+ // Clean up test file
+ remove(test_file);
+
+ printf(" ✓ WAV format verified: stereo, 32kHz, 16-bit PCM\n");
+ printf(" ✓ Matches live audio output configuration\n");
+ printf(" ✓ Backend is passive (frontend-driven)\n");
+}
+
+void test_wav_stereo_buffer_size() {
+ printf("Test: WAV buffer handles stereo correctly...\n");
+
+ // This test verifies that the buffer size calculations are correct
+ // for stereo audio (frames * 2 samples per frame)
+
+ const int sample_rate = 32000;
+ const float update_dt = 1.0f / 60.0f;
+ const int frames_per_update = (int)(sample_rate * update_dt); // ~533
+ const int samples_per_update = frames_per_update * 2; // ~1066 (stereo)
+
+ printf(" Update rate: 60 Hz\n");
+ printf(" Frames per update: %d\n", frames_per_update);
+ printf(" Samples per update: %d (stereo)\n", samples_per_update);
+
+ // Verify calculations
+ assert(frames_per_update > 500 && frames_per_update < 550);
+ assert(samples_per_update == frames_per_update * 2);
+
+ printf(" ✓ Buffer size calculations correct for stereo\n");
+}
+
+void test_clipping_detection() {
+ printf("Test: Clipping detection and reporting...\n");
+
+ const char* test_file = "test_clipping.wav";
+
+ audio_init();
+ AudioEngine engine;
+ engine.init();
+
+ WavDumpBackend wav_backend;
+ wav_backend.set_output_file(test_file);
+ wav_backend.init();
+ wav_backend.start();
+
+ // Create test samples with intentional clipping
+ const int num_samples = 1000;
+ float test_samples[1000];
+
+ // Mix of normal and clipped samples
+ for (int i = 0; i < num_samples; ++i) {
+ if (i % 10 == 0) {
+ test_samples[i] = 1.5f; // Clipped high
+ } else if (i % 10 == 1) {
+ test_samples[i] = -1.2f; // Clipped low
+ } else {
+ test_samples[i] = 0.5f; // Normal
+ }
+ }
+
+ // Write samples
+ wav_backend.write_audio(test_samples, num_samples);
+
+ // Verify clipping was detected (20% of samples should be clipped)
+ const size_t clipped = wav_backend.get_clipped_samples();
+ assert(clipped == 200); // 10% + 10% = 20% of 1000
+
+ printf(" Detected %zu clipped samples (expected 200)\n", clipped);
+
+ wav_backend.shutdown();
+ engine.shutdown();
+ audio_shutdown();
+
+ // Clean up
+ remove(test_file);
+
+ printf(" ✓ Clipping detection works correctly\n");
+}
+
+void test_invalid_file_paths() {
+ printf("Test: Error handling for invalid file paths...\n");
+
+ // Test 1: Null filename (should handle gracefully)
+ {
+ WavDumpBackend wav_backend;
+ wav_backend.set_output_file(nullptr);
+ wav_backend.init(); // Should print error but not crash
+
+ // Verify file didn't open
+ float samples[10] = {0.5f};
+ wav_backend.write_audio(samples, 10); // Should do nothing
+
+ assert(wav_backend.get_samples_written() == 0);
+ wav_backend.shutdown();
+
+ printf(" ✓ Null filename handled gracefully\n");
+ }
+
+ // Test 2: Invalid directory path
+ {
+ WavDumpBackend wav_backend;
+ wav_backend.set_output_file("/nonexistent/directory/test.wav");
+ wav_backend.init(); // Should print error but not crash
+
+ float samples[10] = {0.5f};
+ wav_backend.write_audio(samples, 10); // Should do nothing
+
+ assert(wav_backend.get_samples_written() == 0);
+ wav_backend.shutdown();
+
+ printf(" ✓ Invalid directory path handled gracefully\n");
+ }
+
+ // Test 3: Read-only location (permissions error)
+ {
+ WavDumpBackend wav_backend;
+ wav_backend.set_output_file(
+ "/test.wav"); // Root directory (no write permission)
+ wav_backend.init(); // Should print error but not crash
+
+ float samples[10] = {0.5f};
+ wav_backend.write_audio(samples, 10); // Should do nothing
+
+ assert(wav_backend.get_samples_written() == 0);
+ wav_backend.shutdown();
+
+ printf(" ✓ Permission denied handled gracefully\n");
+ }
+
+ printf(" ✓ All error cases handled without crashes\n");
+}
+
+#endif /* !defined(STRIP_ALL) */
+
+int main() {
+#if !defined(STRIP_ALL)
+ printf("Running WAV Dump Backend tests...\n\n");
+ test_wav_format_matches_live_audio();
+ test_wav_stereo_buffer_size();
+ test_clipping_detection();
+ test_invalid_file_paths();
+ printf("\n✅ All WAV Dump tests PASSED\n");
+ return 0;
+#else
+ printf("WAV Dump tests skipped (STRIP_ALL enabled)\n");
+ return 0;
+#endif /* !defined(STRIP_ALL) */
+}
diff --git a/src/tests/audio/test_window.cc b/src/tests/audio/test_window.cc
new file mode 100644
index 0000000..bac4a4b
--- /dev/null
+++ b/src/tests/audio/test_window.cc
@@ -0,0 +1,28 @@
+// This file is part of the 64k demo project.
+// It validates the mathematical properties of the Hamming window.
+// Ensures the window peaks at the center and has correct symmetry.
+
+#include "audio/window.h"
+#include <assert.h>
+#include <math.h>
+#include <stdio.h>
+
+int main() {
+ printf("Running HammingWindow tests...\n");
+
+ float window[WINDOW_SIZE];
+ hamming_window_512(window);
+
+ // Check symmetry
+ for (int i = 0; i < WINDOW_SIZE / 2; ++i) {
+ assert(fabsf(window[i] - window[WINDOW_SIZE - 1 - i]) < 1e-6f);
+ }
+
+ // Check peak (should be at the center for even size, it's actually split
+ // between 255 and 256)
+ assert(window[255] > 0.99f);
+ assert(window[256] > 0.99f);
+
+ printf("HammingWindow tests PASSED\n");
+ return 0;
+}