From a6a7bf0440dbabdc6c994c0fb21a8ac31c27be07 Mon Sep 17 00:00:00 2001 From: skal Date: Sat, 7 Feb 2026 14:00:23 +0100 Subject: feat(audio): Add SilentBackend, fix peak measurement, reorganize backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Critical Fixes **Peak Measurement Timing:** - Fixed 400ms audio-visual desync by measuring peak at playback time - Added get_realtime_peak() to AudioBackend interface - Implemented real-time measurement in MiniaudioBackend audio callback - Updated main.cc and test_demo.cc to use audio_get_realtime_peak() **Peak Decay Rate:** - Fixed slow decay (0.95 → 0.7 per callback) - Old: 5.76 seconds to fade to 10% (constant flashing in test_demo) - New: 1.15 seconds to fade to 10% (proper visual sync) ## New Features **SilentBackend:** - Test-only backend for testing audio.cc without hardware - Controllable peak for testing edge cases - Tracks frames rendered and voice triggers - Added 7 comprehensive tests covering: - Lifecycle (init/start/shutdown) - Peak control and tracking - Playback time and buffer management - Integration with AudioEngine ## Refactoring **Backend Organization:** - Created src/audio/backend/ directory - Moved all backend implementations to subdirectory - Updated include paths and CMakeLists.txt - Cleaner codebase structure **Code Cleanup:** - Removed unused register_spec_asset() function - Added deprecation note to synth_get_output_peak() ## Testing - All 28 tests passing (100%) - New test: test_silent_backend - Improved audio.cc test coverage significantly ## Documentation - Created PEAK_FIX_SUMMARY.md with technical details - Created TASKS_SUMMARY.md with complete task report Co-Authored-By: Claude Sonnet 4.5 --- src/audio/backend/miniaudio_backend.cc | 286 +++++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 src/audio/backend/miniaudio_backend.cc (limited to 'src/audio/backend/miniaudio_backend.cc') diff --git a/src/audio/backend/miniaudio_backend.cc b/src/audio/backend/miniaudio_backend.cc new file mode 100644 index 0000000..da8d558 --- /dev/null +++ b/src/audio/backend/miniaudio_backend.cc @@ -0,0 +1,286 @@ +// This file is part of the 64k demo project. +// It implements the production audio backend using miniaudio. +// Moved from audio.cc to enable backend abstraction for testing. + +#include "miniaudio_backend.h" +#include "../audio.h" +#include "../ring_buffer.h" +#include "util/debug.h" +#include "util/fatal_error.h" +#include + +// Real-time peak measured at actual playback time +// Updated in audio_callback when samples are read from ring buffer +volatile float MiniaudioBackend::realtime_peak_ = 0.0f; + +// Static callback for miniaudio (C API requirement) +void MiniaudioBackend::audio_callback(ma_device* pDevice, void* pOutput, + const void* pInput, + ma_uint32 frameCount) { + (void)pInput; + +#if defined(DEBUG_LOG_AUDIO) + // Validate callback parameters + static ma_uint32 last_frameCount = 0; + static int callback_reentry = 0; + static double last_time = 0.0; + static int timing_initialized = 0; + static uint64_t total_frames_requested = 0; + static uint64_t callback_number = 0; + + callback_number++; + total_frames_requested += frameCount; + + // Track timing + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + double now = ts.tv_sec + ts.tv_nsec / 1000000000.0; + + if (timing_initialized) { + double delta = (now - last_time) * 1000.0; // ms + double expected = ((double)frameCount / pDevice->sampleRate) * 1000.0; + double jitter = delta - expected; + + // Enhanced logging: Log first 20 callbacks in detail, then periodic summary + if (callback_number <= 20 || callback_number % 50 == 0) { + const double elapsed_by_frames = + (double)total_frames_requested / pDevice->sampleRate * 1000.0; + const double elapsed_by_time = now * 1000.0; // Convert to ms + DEBUG_AUDIO( + "[CB#%llu] frameCount=%u, Delta=%.2fms, Expected=%.2fms, " + "Jitter=%.2fms, " + "TotalFrames=%llu (%.1fms), TotalTime=%.1fms, Drift=%.2fms\n", + callback_number, frameCount, delta, expected, jitter, + total_frames_requested, elapsed_by_frames, elapsed_by_time, + elapsed_by_time - elapsed_by_frames); + } + + // Detect large timing anomalies (>5ms off from expected) + if (fabs(jitter) > 5.0) { + DEBUG_AUDIO( + "[TIMING ANOMALY] CB#%llu Delta=%.2fms, Expected=%.2fms, " + "Jitter=%.2fms\n", + callback_number, delta, expected, jitter); + } + } + last_time = now; + timing_initialized = 1; + + // Check for re-entrant calls + FATAL_CODE_BEGIN + if (callback_reentry > 0) { + FATAL_ERROR("Callback re-entered! depth=%d", callback_reentry); + } + callback_reentry++; + FATAL_CODE_END + + // Check if frameCount changed unexpectedly + if (last_frameCount != 0 && frameCount != last_frameCount) { + DEBUG_AUDIO("WARNING: frameCount changed! was=%u, now=%u\n", + last_frameCount, frameCount); + } + last_frameCount = frameCount; + + // Validate device state + FATAL_CHECK(!pDevice || pDevice->sampleRate == 0, + "Invalid device in callback!\n"); + + // Check actual sample rate matches our expectation + if (pDevice->sampleRate != 32000) { + static int rate_warning = 0; + if (rate_warning++ == 0) { + DEBUG_AUDIO( + "WARNING: Device sample rate is %u, not 32000! Resampling may " + "occur.\n", + pDevice->sampleRate); + } + } +#endif /* defined(DEBUG_LOG_AUDIO) */ + + float* fOutput = (float*)pOutput; + + // BOUNDS CHECK: Sanity check on frameCount + FATAL_CHECK(frameCount > 8192 || frameCount == 0, + "AUDIO CALLBACK ERROR: frameCount=%u (unreasonable!)\n", + frameCount); + + // Read from ring buffer instead of calling synth directly + AudioRingBuffer* ring_buffer = audio_get_ring_buffer(); + if (ring_buffer != nullptr) { + const int samples_to_read = (int)frameCount * 2; // Stereo + +#if defined(DEBUG_LOG_RING_BUFFER) + // Track buffer level and detect drops + static int min_available = 99999; + const int available = ring_buffer->available_read(); + + if (available < min_available) { + min_available = available; + DEBUG_RING_BUFFER("[BUFFER] CB#%llu NEW MIN: available=%d (%.1fms)\n", + callback_number, available, + (float)available / (32000.0f * 2.0f) * 1000.0f); + } + + // Log buffer state for first 20 callbacks and periodically + if (callback_number <= 20 || callback_number % 50 == 0) { + DEBUG_RING_BUFFER( + "[BUFFER] CB#%llu requested=%d, available=%d (%.1fms), min=%d\n", + callback_number, samples_to_read, available, + (float)available / (32000.0f * 2.0f) * 1000.0f, min_available); + } + + // CRITICAL: Verify we have enough samples + if (available < samples_to_read) { + DEBUG_RING_BUFFER( + "[BUFFER UNDERRUN] CB#%llu requested=%d, available=%d, SHORT=%d\n", + callback_number, samples_to_read, available, + samples_to_read - available); + } +#endif /* defined(DEBUG_LOG_RING_BUFFER) */ + + const int actually_read = ring_buffer->read(fOutput, samples_to_read); + +#if defined(DEBUG_LOG_RING_BUFFER) + if (actually_read < samples_to_read) { + DEBUG_RING_BUFFER( + "[PARTIAL READ] CB#%llu requested=%d, got=%d, padded=%d with " + "silence\n", + callback_number, samples_to_read, actually_read, + samples_to_read - actually_read); + } +#endif /* defined(DEBUG_LOG_RING_BUFFER) */ + + // Measure peak of samples being played RIGHT NOW (real-time sync) + // This ensures visual effects trigger at the same moment audio is heard + float frame_peak = 0.0f; + for (int i = 0; i < actually_read; ++i) { + frame_peak = fmaxf(frame_peak, fabsf(fOutput[i])); + } + + // Exponential averaging: instant attack, fast decay + // Decay rate of 0.7 gives ~1 second decay time for visual sync + // (At 128ms callbacks: 0.7^7.8 ≈ 0.1 after ~1 second) + if (frame_peak > realtime_peak_) { + realtime_peak_ = frame_peak; // Attack: instant + } else { + realtime_peak_ *= 0.7f; // Decay: fast (30% per callback) + } + } + +#if defined(DEBUG_LOG_AUDIO) + // Clear reentry flag + FATAL_CODE_BEGIN + callback_reentry--; + FATAL_CODE_END +#endif /* defined(DEBUG_LOG_AUDIO) */ +} + +MiniaudioBackend::MiniaudioBackend() : initialized_(false) { +} + +MiniaudioBackend::~MiniaudioBackend() { + if (initialized_) { + shutdown(); + } +} + +void MiniaudioBackend::init() { + if (initialized_) { + return; + } + + ma_device_config config = ma_device_config_init(ma_device_type_playback); + config.playback.format = ma_format_f32; + config.playback.channels = 2; + config.sampleRate = 32000; + + // Core Audio Backend-Specific Configuration + // Problem: Core Audio uses 10ms periods optimized for 44.1kHz, causing + // uneven callback timing (10ms/10ms/20ms) when resampling to 32kHz + // + // Solution 1: Force OS-level sample rate to 32kHz to avoid resampling + config.coreaudio.allowNominalSampleRateChange = MA_TRUE; + + // Solution 2: Use conservative performance profile for larger buffers + config.performanceProfile = ma_performance_profile_conservative; + + // Let Core Audio choose the period size based on conservative profile + config.periodSizeInFrames = 0; // 0 = let backend decide + config.periods = 0; // 0 = let backend decide based on performance profile + + config.dataCallback = MiniaudioBackend::audio_callback; + config.pUserData = this; + + if (ma_device_init(NULL, &config, &device_) != MA_SUCCESS) { + printf("Failed to open playback device.\n"); + return; + } + +#if defined(DEBUG_LOG_AUDIO) + // Log actual device configuration (to stderr for visibility) + DEBUG_AUDIO("\n=== MINIAUDIO DEVICE CONFIGURATION ===\n"); + DEBUG_AUDIO(" Sample rate: %u (requested: 32000)\n", device_.sampleRate); + DEBUG_AUDIO(" Channels: %u (requested: 2)\n", device_.playback.channels); + DEBUG_AUDIO(" Format: %d (requested: %d, f32=%d)\n", device_.playback.format, + config.playback.format, ma_format_f32); + DEBUG_AUDIO(" Period size: %u frames (%.1fms at %uHz)\n", + device_.playback.internalPeriodSizeInFrames, + (float)device_.playback.internalPeriodSizeInFrames / + device_.sampleRate * 1000.0f, + device_.sampleRate); + DEBUG_AUDIO(" Periods: %u (buffer multiplier)\n", + device_.playback.internalPeriods); + DEBUG_AUDIO(" Backend: %s\n", + ma_get_backend_name(device_.pContext->backend)); + DEBUG_AUDIO(" Total buffer size: %u frames (%.2fms) [period * periods]\n", + device_.playback.internalPeriodSizeInFrames * + device_.playback.internalPeriods, + (float)(device_.playback.internalPeriodSizeInFrames * + device_.playback.internalPeriods) / + device_.sampleRate * 1000.0f); + + // Calculate expected callback interval + if (device_.playback.internalPeriodSizeInFrames > 0) { + const float expected_callback_ms = + (float)device_.playback.internalPeriodSizeInFrames / + device_.sampleRate * 1000.0f; + DEBUG_AUDIO(" Expected callback interval: %.2fms (based on period size)\n", + expected_callback_ms); + DEBUG_AUDIO( + " WARNING: If actual callback interval differs, audio corruption may " + "occur!\n"); + } + DEBUG_AUDIO("======================================\n\n"); + fflush(stderr); +#endif /* defined(DEBUG_LOG_AUDIO) */ + + initialized_ = true; +} + +void MiniaudioBackend::start() { + if (!initialized_) { + printf("Cannot start: backend not initialized.\n"); + return; + } + + if (ma_device_start(&device_) != MA_SUCCESS) { + printf("Failed to start playback device.\n"); + ma_device_uninit(&device_); + initialized_ = false; + return; + } +} + +void MiniaudioBackend::shutdown() { + if (!initialized_) { + return; + } + + ma_device_stop(&device_); + ma_device_uninit(&device_); + initialized_ = false; +} + +float MiniaudioBackend::get_realtime_peak() { + return realtime_peak_; +} -- cgit v1.2.3