From 5a1adde097e489c259bd052971546e95683c3596 Mon Sep 17 00:00:00 2001 From: skal Date: Fri, 6 Feb 2026 11:12:34 +0100 Subject: feat(audio): Add Spectral Brush runtime (Phase 1 of Task #5) Implement C++ runtime foundation for procedural audio tracing tool. Changes: - Created spectral_brush.h/cc with core API - Linear Bezier interpolation - Vertical profile evaluation (Gaussian, Decaying Sinusoid, Noise) - draw_bezier_curve() for spectrogram rendering - Home-brew deterministic RNG for noise profile - Added comprehensive unit tests (test_spectral_brush.cc) - Tests Bezier interpolation, profiles, edge cases - Tests full spectrogram rendering pipeline - All 9 tests pass - Integrated into CMake build system - Fixed test_assets.cc include (asset_manager_utils.h) Design: - Spectral Brush = Central Curve (Bezier) + Vertical Profile - Enables 50-100x compression (5KB .spec to 100 bytes C++ code) - Future: Cubic Bezier, composite profiles, multi-dimensional curves Documentation: - Added doc/SPECTRAL_BRUSH_EDITOR.md (complete architecture) - Updated TODO.md with Phase 1-4 implementation plan - Updated PROJECT_CONTEXT.md to mark Task #5 in progress Test results: 21/21 tests pass (100%) Co-Authored-By: Claude Sonnet 4.5 --- src/tests/test_spectral_brush.cc | 236 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 src/tests/test_spectral_brush.cc (limited to 'src/tests/test_spectral_brush.cc') diff --git a/src/tests/test_spectral_brush.cc b/src/tests/test_spectral_brush.cc new file mode 100644 index 0000000..1431ba7 --- /dev/null +++ b/src/tests/test_spectral_brush.cc @@ -0,0 +1,236 @@ +// 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 +#include +#include +#include + +// 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; +} -- cgit v1.2.3