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/audio.cc | 26 +-- src/audio/audio.h | 8 +- src/audio/audio_backend.h | 5 + src/audio/backend/jittered_audio_backend.cc | 118 ++++++++++++ src/audio/backend/jittered_audio_backend.h | 60 ++++++ src/audio/backend/miniaudio_backend.cc | 286 ++++++++++++++++++++++++++++ src/audio/backend/miniaudio_backend.h | 38 ++++ src/audio/backend/mock_audio_backend.cc | 51 +++++ src/audio/backend/mock_audio_backend.h | 65 +++++++ src/audio/backend/silent_backend.cc | 50 +++++ src/audio/backend/silent_backend.h | 52 +++++ src/audio/backend/wav_dump_backend.cc | 159 ++++++++++++++++ src/audio/backend/wav_dump_backend.h | 65 +++++++ src/audio/jittered_audio_backend.cc | 112 ----------- src/audio/jittered_audio_backend.h | 59 ------ src/audio/miniaudio_backend.cc | 261 ------------------------- src/audio/miniaudio_backend.h | 33 ---- src/audio/mock_audio_backend.cc | 45 ----- src/audio/mock_audio_backend.h | 64 ------- src/audio/synth.h | 4 + src/audio/wav_dump_backend.cc | 153 --------------- src/audio/wav_dump_backend.h | 64 ------- 22 files changed, 967 insertions(+), 811 deletions(-) create mode 100644 src/audio/backend/jittered_audio_backend.cc create mode 100644 src/audio/backend/jittered_audio_backend.h create mode 100644 src/audio/backend/miniaudio_backend.cc create mode 100644 src/audio/backend/miniaudio_backend.h create mode 100644 src/audio/backend/mock_audio_backend.cc create mode 100644 src/audio/backend/mock_audio_backend.h create mode 100644 src/audio/backend/silent_backend.cc create mode 100644 src/audio/backend/silent_backend.h create mode 100644 src/audio/backend/wav_dump_backend.cc create mode 100644 src/audio/backend/wav_dump_backend.h delete mode 100644 src/audio/jittered_audio_backend.cc delete mode 100644 src/audio/jittered_audio_backend.h delete mode 100644 src/audio/miniaudio_backend.cc delete mode 100644 src/audio/miniaudio_backend.h delete mode 100644 src/audio/mock_audio_backend.cc delete mode 100644 src/audio/mock_audio_backend.h delete mode 100644 src/audio/wav_dump_backend.cc delete mode 100644 src/audio/wav_dump_backend.h (limited to 'src/audio') diff --git a/src/audio/audio.cc b/src/audio/audio.cc index b00d416..67345cf 100644 --- a/src/audio/audio.cc +++ b/src/audio/audio.cc @@ -4,7 +4,7 @@ #include "audio.h" #include "audio_backend.h" -#include "miniaudio_backend.h" +#include "backend/miniaudio_backend.h" #include "ring_buffer.h" #include "synth.h" #include "util/asset_manager.h" @@ -40,23 +40,6 @@ AudioBackend* audio_get_backend() { } #endif /* !defined(STRIP_ALL) */ -int register_spec_asset(AssetId id) { - size_t size; - const uint8_t* data = GetAsset(id, &size); - if (!data || size < sizeof(SpecHeader)) - return -1; - - const SpecHeader* header = (const SpecHeader*)data; - const float* spectral_data = (const float*)(data + sizeof(SpecHeader)); - - Spectrogram spec; - spec.spectral_data_a = spectral_data; - spec.spectral_data_b = spectral_data; // No double-buffer for static assets - spec.num_frames = header->num_frames; - - return synth_register_spectrogram(&spec); -} - void audio_init() { // Note: synth_init() must be called separately before using audio system. // In production code, use AudioEngine::init() which manages initialization @@ -189,6 +172,13 @@ float audio_get_playback_time() { (RING_BUFFER_SAMPLE_RATE * RING_BUFFER_CHANNELS); } +float audio_get_realtime_peak() { + if (g_audio_backend == nullptr) { + return 0.0f; + } + return g_audio_backend->get_realtime_peak(); +} + // Expose ring buffer for backends AudioRingBuffer* audio_get_ring_buffer() { return &g_ring_buffer; diff --git a/src/audio/audio.h b/src/audio/audio.h index aaa5d45..14fe615 100644 --- a/src/audio/audio.h +++ b/src/audio/audio.h @@ -29,6 +29,12 @@ void audio_render_ahead(float music_time, float dt); // Get current playback time (in seconds) based on samples consumed float audio_get_playback_time(); +// Get peak amplitude of samples currently being played (real-time sync) +// Returns: Peak amplitude in range [0.0, 1.0+] +// Use this for visual effects to ensure audio-visual synchronization +// Note: Measured at actual playback time, not pre-buffer time (~400ms ahead) +float audio_get_realtime_peak(); + #if !defined(STRIP_ALL) void audio_render_silent(float duration_sec); // Fast-forwards audio state // Backend injection for testing @@ -37,5 +43,3 @@ AudioBackend* audio_get_backend(); #endif /* !defined(STRIP_ALL) */ void audio_update(); void audio_shutdown(); - -int register_spec_asset(AssetId id); diff --git a/src/audio/audio_backend.h b/src/audio/audio_backend.h index 940e2b2..d9c4690 100644 --- a/src/audio/audio_backend.h +++ b/src/audio/audio_backend.h @@ -20,6 +20,11 @@ class AudioBackend { // Clean up backend resources virtual void shutdown() = 0; + // Get peak amplitude of samples currently being played (real-time sync) + // Returns: Peak amplitude in range [0.0, 1.0+] + // Note: This should measure peak at actual playback time, not pre-buffer time + virtual float get_realtime_peak() = 0; + #if !defined(STRIP_ALL) // Hook called when a voice is triggered (test-only) // timestamp: Time in seconds when voice was triggered diff --git a/src/audio/backend/jittered_audio_backend.cc b/src/audio/backend/jittered_audio_backend.cc new file mode 100644 index 0000000..0c1c4a6 --- /dev/null +++ b/src/audio/backend/jittered_audio_backend.cc @@ -0,0 +1,118 @@ +// This file is part of the 64k demo project. +// It implements a test backend that simulates jittered audio consumption. + +#if !defined(STRIP_ALL) + +#include "jittered_audio_backend.h" +#include "../audio.h" +#include "../ring_buffer.h" +#include +#include +#include +#include +#include + +JitteredAudioBackend::JitteredAudioBackend() + : running_(false), should_stop_(false), jitter_ms_(5.0f), + base_interval_ms_(10.0f), min_chunk_frames_(256), max_chunk_frames_(1024), + total_frames_consumed_(0), underrun_count_(0) { +} + +JitteredAudioBackend::~JitteredAudioBackend() { + shutdown(); +} + +void JitteredAudioBackend::init() { + // Nothing to initialize +} + +void JitteredAudioBackend::start() { + if (running_.load()) + return; + + should_stop_.store(false); + running_.store(true); + + // Start audio thread + audio_thread_ = std::thread(&JitteredAudioBackend::audio_thread_loop, this); +} + +void JitteredAudioBackend::shutdown() { + if (!running_.load()) + return; + + should_stop_.store(true); + + if (audio_thread_.joinable()) { + audio_thread_.join(); + } + + running_.store(false); +} + +void JitteredAudioBackend::set_jitter_amount(float jitter_ms) { + jitter_ms_ = jitter_ms; +} + +void JitteredAudioBackend::set_base_interval(float interval_ms) { + base_interval_ms_ = interval_ms; +} + +void JitteredAudioBackend::set_chunk_size_range(int min_frames, + int max_frames) { + min_chunk_frames_ = min_frames; + max_chunk_frames_ = max_frames; +} + +void JitteredAudioBackend::audio_thread_loop() { + AudioRingBuffer* ring_buffer = audio_get_ring_buffer(); + if (ring_buffer == nullptr) + return; + + // Random number generator for jitter + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_real_distribution jitter_dist(-jitter_ms_, jitter_ms_); + std::uniform_int_distribution chunk_dist(min_chunk_frames_, + max_chunk_frames_); + + while (!should_stop_.load()) { + // Calculate jittered wait time + const float jitter = jitter_dist(gen); + const float wait_ms = base_interval_ms_ + jitter; + const int wait_us = (int)(wait_ms * 1000.0f); + + if (wait_us > 0) { + std::this_thread::sleep_for(std::chrono::microseconds(wait_us)); + } + + // Random chunk size + const int chunk_frames = chunk_dist(gen); + const int chunk_samples = chunk_frames * 2; // Stereo + + // Read from ring buffer + float* temp_buffer = new float[chunk_samples]; + const int read_samples = ring_buffer->read(temp_buffer, chunk_samples); + + // Check for underrun + if (read_samples < chunk_samples) { + underrun_count_.fetch_add(1); + } + + // Track consumption + total_frames_consumed_.fetch_add(read_samples / 2); + + // Notify of frames rendered (for tracking) + on_frames_rendered(read_samples / 2); + + delete[] temp_buffer; + } +} + +float JitteredAudioBackend::get_realtime_peak() { + // Jittered backend: No real-time playback, return 0 + // This backend simulates timing jitter but doesn't play audio + return 0.0f; +} + +#endif /* !defined(STRIP_ALL) */ diff --git a/src/audio/backend/jittered_audio_backend.h b/src/audio/backend/jittered_audio_backend.h new file mode 100644 index 0000000..8bda4c7 --- /dev/null +++ b/src/audio/backend/jittered_audio_backend.h @@ -0,0 +1,60 @@ +// This file is part of the 64k demo project. +// It implements a test backend that simulates jittered audio consumption. +// Useful for stress-testing ring buffer under realistic timing conditions. + +#pragma once + +#if !defined(STRIP_ALL) + +#include "../audio_backend.h" +#include +#include + +// Simulates a real audio device with timing jitter and variable chunk sizes +class JitteredAudioBackend : public AudioBackend { + public: + JitteredAudioBackend(); + ~JitteredAudioBackend() override; + + void init() override; + void start() override; + void shutdown() override; + float get_realtime_peak() override; + + // Control simulation + void set_jitter_amount(float jitter_ms); // Random jitter in ms (default: 5ms) + void set_base_interval( + float interval_ms); // Base interval between reads (default: 10ms) + void set_chunk_size_range(int min_frames, + int max_frames); // Variable chunk sizes + + // Query state + int get_total_frames_consumed() const { + return total_frames_consumed_.load(); + } + int get_underrun_count() const { + return underrun_count_.load(); + } + bool is_running() const { + return running_.load(); + } + + private: + void audio_thread_loop(); + + std::thread audio_thread_; + std::atomic running_; + std::atomic should_stop_; + + // Configuration + float jitter_ms_; + float base_interval_ms_; + int min_chunk_frames_; + int max_chunk_frames_; + + // Statistics + std::atomic total_frames_consumed_; + std::atomic underrun_count_; // How many times buffer was empty +}; + +#endif /* !defined(STRIP_ALL) */ 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_; +} diff --git a/src/audio/backend/miniaudio_backend.h b/src/audio/backend/miniaudio_backend.h new file mode 100644 index 0000000..eb9019c --- /dev/null +++ b/src/audio/backend/miniaudio_backend.h @@ -0,0 +1,38 @@ +// This file is part of the 64k demo project. +// It implements the production audio backend using miniaudio. +// This is the default backend for the final build. + +#pragma once + +#include "../audio_backend.h" +#include "miniaudio.h" + +// Production audio backend using miniaudio library +// Manages real hardware audio device and playback +class MiniaudioBackend : public AudioBackend { + public: + MiniaudioBackend(); + ~MiniaudioBackend() override; + + void init() override; + void start() override; + void shutdown() override; + float get_realtime_peak() override; + + // Get the underlying miniaudio device (for internal use) + ma_device* get_device() { + return &device_; + } + + private: + ma_device device_; + bool initialized_; + + // Real-time peak measured at actual playback time (not pre-buffer) + // Updated in audio_callback when samples are read from ring buffer + static volatile float realtime_peak_; + + // Static callback required by miniaudio C API + static void audio_callback(ma_device* pDevice, void* pOutput, + const void* pInput, ma_uint32 frameCount); +}; diff --git a/src/audio/backend/mock_audio_backend.cc b/src/audio/backend/mock_audio_backend.cc new file mode 100644 index 0000000..068d8a3 --- /dev/null +++ b/src/audio/backend/mock_audio_backend.cc @@ -0,0 +1,51 @@ +// This file is part of the 64k demo project. +// It implements the mock audio backend for testing. +// Records voice trigger events with precise timestamps. + +#include "mock_audio_backend.h" + +#if !defined(STRIP_ALL) + +MockAudioBackend::MockAudioBackend() : current_time_sec_(0.0f) { +} + +MockAudioBackend::~MockAudioBackend() { +} + +void MockAudioBackend::init() { + // No-op for mock backend +} + +void MockAudioBackend::start() { + // No-op for mock backend +} + +void MockAudioBackend::shutdown() { + // No-op for mock backend +} + +float MockAudioBackend::get_realtime_peak() { + // Mock backend: Return synthetic peak for testing + // Can be enhanced later to track actual synth output if needed + return 0.5f; +} + +void MockAudioBackend::on_voice_triggered(float timestamp, int spectrogram_id, + float volume, float pan) { + // Record the event with the timestamp provided by synth + VoiceTriggerEvent event; + event.timestamp_sec = timestamp; + event.spectrogram_id = spectrogram_id; + event.volume = volume; + event.pan = pan; + recorded_events_.push_back(event); +} + +void MockAudioBackend::on_frames_rendered(int num_frames) { + // Update internal time based on frames rendered + // This is called by audio_render_silent() for seek/fast-forward simulation + const float delta_sec = (float)num_frames / (float)kSampleRate; + current_time_sec_ += delta_sec; +} + +#endif /* !defined(STRIP_ALL) */ diff --git a/src/audio/backend/mock_audio_backend.h b/src/audio/backend/mock_audio_backend.h new file mode 100644 index 0000000..55e89c5 --- /dev/null +++ b/src/audio/backend/mock_audio_backend.h @@ -0,0 +1,65 @@ +// This file is part of the 64k demo project. +// It implements a test-only mock audio backend for event recording. +// Used for tracker timing verification and audio synchronization tests. + +#pragma once + +#if !defined(STRIP_ALL) + +#include "../audio_backend.h" +#include + +// Event structure for recorded voice triggers +struct VoiceTriggerEvent { + float timestamp_sec; + int spectrogram_id; + float volume; + float pan; +}; + +// Mock audio backend that records all voice trigger events +// Used for testing tracker timing and synchronization +class MockAudioBackend : public AudioBackend { + public: + MockAudioBackend(); + ~MockAudioBackend() override; + + // AudioBackend interface (no-ops for mock) + void init() override; + void start() override; + void shutdown() override; + float get_realtime_peak() override; + + // Event recording hooks + void on_voice_triggered(float timestamp, int spectrogram_id, float volume, + float pan) override; + void on_frames_rendered(int num_frames) override; + + // Test interface methods + const std::vector& get_events() const { + return recorded_events_; + } + void clear_events() { + recorded_events_.clear(); + } + + // Manual time control for deterministic testing + void advance_time(float delta_sec) { + current_time_sec_ += delta_sec; + } + void set_time(float time_sec) { + current_time_sec_ = time_sec; + } + float get_current_time() const { + return current_time_sec_; + } + + // Sample rate used for frame-to-time conversion + static const int kSampleRate = 32000; + + private: + std::vector recorded_events_; + float current_time_sec_; +}; + +#endif /* !defined(STRIP_ALL) */ diff --git a/src/audio/backend/silent_backend.cc b/src/audio/backend/silent_backend.cc new file mode 100644 index 0000000..637dd68 --- /dev/null +++ b/src/audio/backend/silent_backend.cc @@ -0,0 +1,50 @@ +// This file is part of the 64k demo project. +// Implementation of silent backend for testing audio.cc. + +#include "silent_backend.h" + +#if !defined(STRIP_ALL) + +SilentBackend::SilentBackend() + : initialized_(false), started_(false), frames_rendered_(0), + voice_trigger_count_(0), test_peak_(0.0f) { +} + +SilentBackend::~SilentBackend() { + shutdown(); +} + +void SilentBackend::init() { + initialized_ = true; +} + +void SilentBackend::start() { + started_ = true; +} + +void SilentBackend::shutdown() { + initialized_ = false; + started_ = false; +} + +float SilentBackend::get_realtime_peak() { + // Return controllable test peak + return test_peak_; +} + +void SilentBackend::on_voice_triggered(float timestamp, int spectrogram_id, + float volume, float pan) { + // Track voice triggers for testing + (void)timestamp; + (void)spectrogram_id; + (void)volume; + (void)pan; + voice_trigger_count_.fetch_add(1); +} + +void SilentBackend::on_frames_rendered(int num_frames) { + // Track total frames rendered for testing + frames_rendered_.fetch_add(num_frames); +} + +#endif /* !defined(STRIP_ALL) */ diff --git a/src/audio/backend/silent_backend.h b/src/audio/backend/silent_backend.h new file mode 100644 index 0000000..f7da42d --- /dev/null +++ b/src/audio/backend/silent_backend.h @@ -0,0 +1,52 @@ +// This file is part of the 64k demo project. +// It implements a silent test backend for testing audio.cc without hardware. +// Useful for achieving high test coverage and triggering edge cases. + +#pragma once + +#if !defined(STRIP_ALL) + +#include "../audio_backend.h" +#include + +// Silent backend for testing - no audio output, pure inspection +// Allows testing audio.cc logic (buffer management, playback time tracking) +// without requiring audio hardware or miniaudio +class SilentBackend : public AudioBackend { + public: + SilentBackend(); + ~SilentBackend() override; + + // AudioBackend interface + void init() override; + void start() override; + void shutdown() override; + float get_realtime_peak() override; + + // Test inspection interface + bool is_initialized() const { return initialized_; } + bool is_started() const { return started_; } + int get_frames_rendered() const { return frames_rendered_.load(); } + int get_voice_trigger_count() const { return voice_trigger_count_.load(); } + + // Manual control for testing edge cases + void set_peak(float peak) { test_peak_ = peak; } + void reset_stats() { + frames_rendered_ = 0; + voice_trigger_count_ = 0; + } + + // Event hooks (inherited from AudioBackend) + void on_voice_triggered(float timestamp, int spectrogram_id, float volume, + float pan) override; + void on_frames_rendered(int num_frames) override; + + private: + bool initialized_; + bool started_; + std::atomic frames_rendered_; + std::atomic voice_trigger_count_; + float test_peak_; // Controllable peak for testing +}; + +#endif /* !defined(STRIP_ALL) */ diff --git a/src/audio/backend/wav_dump_backend.cc b/src/audio/backend/wav_dump_backend.cc new file mode 100644 index 0000000..1158fb2 --- /dev/null +++ b/src/audio/backend/wav_dump_backend.cc @@ -0,0 +1,159 @@ +// This file is part of the 64k demo project. +// Implementation of WAV dump backend for debugging. + +#include "wav_dump_backend.h" + +#if !defined(STRIP_ALL) + +#include +#include +#include + +WavDumpBackend::WavDumpBackend() + : wav_file_(nullptr), samples_written_(0), clipped_samples_(0), + output_filename_("audio_dump.wav"), is_active_(false), + duration_sec_(0.0f) { + sample_buffer_.resize(kBufferSize); +} + +WavDumpBackend::~WavDumpBackend() { + shutdown(); +} + +void WavDumpBackend::set_output_file(const char* filename) { + output_filename_ = filename; +} + +void WavDumpBackend::init() { + // Open WAV file for writing + wav_file_ = fopen(output_filename_, "wb"); + if (wav_file_ == nullptr) { + fprintf(stderr, "Error: Failed to open WAV file: %s\n", output_filename_); + return; + } + + // Write placeholder header (we'll update it in shutdown()) + write_wav_header(wav_file_, 0); + + samples_written_ = 0; + clipped_samples_ = 0; + printf("WAV dump backend initialized: %s\n", output_filename_); +} + +void WavDumpBackend::start() { + is_active_ = true; + printf("WAV dump started (passive mode - frontend drives rendering)\n"); +} + +void WavDumpBackend::write_audio(const float* samples, int num_samples) { + if (!is_active_ || wav_file_ == nullptr) { + return; + } + + // CRITICAL: This method must match MiniaudioBackend's sample handling + // behavior to ensure WAV dumps accurately reflect live audio output. + // + // Current behavior (verified 2026-02-07): + // - MiniaudioBackend passes float samples directly to miniaudio without + // clamping (see miniaudio_backend.cc:140) + // - Miniaudio internally converts float→int16 and handles overflow + // - We replicate this: no clamping, count out-of-range samples for diagnostics + // + // If miniaudio's sample handling changes (e.g., they add clamping or + // different overflow behavior), this code MUST be updated to match. + // Verify by checking: src/audio/miniaudio_backend.cc data_callback() + + for (int i = 0; i < num_samples; ++i) { + float sample = samples[i]; + + // Track samples outside [-1.0, 1.0] range for diagnostic reporting + // This helps identify audio distortion issues during development + if (sample > 1.0f || sample < -1.0f) { + clipped_samples_++; + } + + // Convert float→int16 with same overflow behavior as miniaudio + // Values outside [-1.0, 1.0] will wrap/clip during conversion + const int16_t sample_i16 = (int16_t)(sample * 32767.0f); + fwrite(&sample_i16, sizeof(int16_t), 1, wav_file_); + } + + samples_written_ += num_samples; +} + +void WavDumpBackend::shutdown() { + if (wav_file_ != nullptr) { + // Update header with final sample count + update_wav_header(); + fclose(wav_file_); + wav_file_ = nullptr; + + const float duration_sec = (float)samples_written_ / (kSampleRate * 2); + printf("WAV file written: %s (%.2f seconds, %zu samples)\n", + output_filename_, duration_sec, samples_written_); + + // Report clipping diagnostics + if (clipped_samples_ > 0) { + const float clip_percent = + (float)clipped_samples_ / (float)samples_written_ * 100.0f; + printf(" WARNING: %zu samples clipped (%.2f%% of total)\n", + clipped_samples_, clip_percent); + printf(" This indicates audio distortion - consider reducing volume\n"); + } else { + printf(" ✓ No clipping detected\n"); + } + } + + is_active_ = false; +} + +float WavDumpBackend::get_realtime_peak() { + // WAV dump: No real-time playback, return 0 + // Could optionally track peak of last written chunk if needed + return 0.0f; +} + +void WavDumpBackend::write_wav_header(FILE* file, uint32_t num_samples) { + // WAV file header structure + // Reference: http://soundfile.sapp.org/doc/WaveFormat/ + + const uint32_t num_channels = 2; // Stereo (matches miniaudio config) + const uint32_t sample_rate = kSampleRate; + const uint32_t bits_per_sample = 16; + const uint32_t byte_rate = sample_rate * num_channels * bits_per_sample / 8; + const uint16_t block_align = num_channels * bits_per_sample / 8; + const uint32_t data_size = num_samples * num_channels * bits_per_sample / 8; + + // RIFF header + fwrite("RIFF", 1, 4, file); + const uint32_t chunk_size = 36 + data_size; + fwrite(&chunk_size, 4, 1, file); + fwrite("WAVE", 1, 4, file); + + // fmt subchunk + fwrite("fmt ", 1, 4, file); + const uint32_t subchunk1_size = 16; + fwrite(&subchunk1_size, 4, 1, file); + const uint16_t audio_format = 1; // PCM + fwrite(&audio_format, 2, 1, file); + fwrite(&num_channels, 2, 1, file); + fwrite(&sample_rate, 4, 1, file); + fwrite(&byte_rate, 4, 1, file); + fwrite(&block_align, 2, 1, file); + fwrite(&bits_per_sample, 2, 1, file); + + // data subchunk header + fwrite("data", 1, 4, file); + fwrite(&data_size, 4, 1, file); +} + +void WavDumpBackend::update_wav_header() { + if (wav_file_ == nullptr) + return; + + // Seek to beginning and rewrite header with actual sample count + fseek(wav_file_, 0, SEEK_SET); + write_wav_header(wav_file_, samples_written_); +} + +#endif /* !defined(STRIP_ALL) */ diff --git a/src/audio/backend/wav_dump_backend.h b/src/audio/backend/wav_dump_backend.h new file mode 100644 index 0000000..de445d6 --- /dev/null +++ b/src/audio/backend/wav_dump_backend.h @@ -0,0 +1,65 @@ +// This file is part of the 64k demo project. +// WAV dump backend for debugging audio output to file instead of device. +// Stripped in final build (STRIP_ALL). + +#if !defined(DEMO_AUDIO_WAV_DUMP_BACKEND_H) +#define DEMO_AUDIO_WAV_DUMP_BACKEND_H + +#include "../audio_backend.h" +#include +#include + +#if !defined(STRIP_ALL) + +// WAV file dump backend for debugging +// Captures audio from synth_render() and writes to .wav file +class WavDumpBackend : public AudioBackend { + public: + WavDumpBackend(); + ~WavDumpBackend(); + + // AudioBackend interface + void init() override; + void start() override; + void shutdown() override; + float get_realtime_peak() override; + + // Set output filename (call before init()) + void set_output_file(const char* filename); + + // Write audio data to WAV file (stereo interleaved float samples) + // num_samples: Total number of samples (2x num_frames for stereo) + void write_audio(const float* samples, int num_samples); + + // Get total samples written + size_t get_samples_written() const { + return samples_written_; + } + + // Get number of samples that were clipped (diagnostic metric) + size_t get_clipped_samples() const { + return clipped_samples_; + } + + private: + // Write WAV header with known sample count + void write_wav_header(FILE* file, uint32_t num_samples); + + // Update WAV header with final sample count + void update_wav_header(); + + FILE* wav_file_; + std::vector sample_buffer_; + size_t samples_written_; + size_t clipped_samples_; + const char* output_filename_; + bool is_active_; + float duration_sec_; + + static const int kSampleRate = 32000; + static const int kBufferSize = 1024; +}; + +#endif /* !defined(STRIP_ALL) */ + +#endif /* DEMO_AUDIO_WAV_DUMP_BACKEND_H */ diff --git a/src/audio/jittered_audio_backend.cc b/src/audio/jittered_audio_backend.cc deleted file mode 100644 index 8742aba..0000000 --- a/src/audio/jittered_audio_backend.cc +++ /dev/null @@ -1,112 +0,0 @@ -// This file is part of the 64k demo project. -// It implements a test backend that simulates jittered audio consumption. - -#if !defined(STRIP_ALL) - -#include "jittered_audio_backend.h" -#include "audio.h" -#include "ring_buffer.h" -#include -#include -#include -#include -#include - -JitteredAudioBackend::JitteredAudioBackend() - : running_(false), should_stop_(false), jitter_ms_(5.0f), - base_interval_ms_(10.0f), min_chunk_frames_(256), max_chunk_frames_(1024), - total_frames_consumed_(0), underrun_count_(0) { -} - -JitteredAudioBackend::~JitteredAudioBackend() { - shutdown(); -} - -void JitteredAudioBackend::init() { - // Nothing to initialize -} - -void JitteredAudioBackend::start() { - if (running_.load()) - return; - - should_stop_.store(false); - running_.store(true); - - // Start audio thread - audio_thread_ = std::thread(&JitteredAudioBackend::audio_thread_loop, this); -} - -void JitteredAudioBackend::shutdown() { - if (!running_.load()) - return; - - should_stop_.store(true); - - if (audio_thread_.joinable()) { - audio_thread_.join(); - } - - running_.store(false); -} - -void JitteredAudioBackend::set_jitter_amount(float jitter_ms) { - jitter_ms_ = jitter_ms; -} - -void JitteredAudioBackend::set_base_interval(float interval_ms) { - base_interval_ms_ = interval_ms; -} - -void JitteredAudioBackend::set_chunk_size_range(int min_frames, - int max_frames) { - min_chunk_frames_ = min_frames; - max_chunk_frames_ = max_frames; -} - -void JitteredAudioBackend::audio_thread_loop() { - AudioRingBuffer* ring_buffer = audio_get_ring_buffer(); - if (ring_buffer == nullptr) - return; - - // Random number generator for jitter - std::random_device rd; - std::mt19937 gen(rd()); - std::uniform_real_distribution jitter_dist(-jitter_ms_, jitter_ms_); - std::uniform_int_distribution chunk_dist(min_chunk_frames_, - max_chunk_frames_); - - while (!should_stop_.load()) { - // Calculate jittered wait time - const float jitter = jitter_dist(gen); - const float wait_ms = base_interval_ms_ + jitter; - const int wait_us = (int)(wait_ms * 1000.0f); - - if (wait_us > 0) { - std::this_thread::sleep_for(std::chrono::microseconds(wait_us)); - } - - // Random chunk size - const int chunk_frames = chunk_dist(gen); - const int chunk_samples = chunk_frames * 2; // Stereo - - // Read from ring buffer - float* temp_buffer = new float[chunk_samples]; - const int read_samples = ring_buffer->read(temp_buffer, chunk_samples); - - // Check for underrun - if (read_samples < chunk_samples) { - underrun_count_.fetch_add(1); - } - - // Track consumption - total_frames_consumed_.fetch_add(read_samples / 2); - - // Notify of frames rendered (for tracking) - on_frames_rendered(read_samples / 2); - - delete[] temp_buffer; - } -} - -#endif /* !defined(STRIP_ALL) */ diff --git a/src/audio/jittered_audio_backend.h b/src/audio/jittered_audio_backend.h deleted file mode 100644 index c246c48..0000000 --- a/src/audio/jittered_audio_backend.h +++ /dev/null @@ -1,59 +0,0 @@ -// This file is part of the 64k demo project. -// It implements a test backend that simulates jittered audio consumption. -// Useful for stress-testing ring buffer under realistic timing conditions. - -#pragma once - -#if !defined(STRIP_ALL) - -#include "audio_backend.h" -#include -#include - -// Simulates a real audio device with timing jitter and variable chunk sizes -class JitteredAudioBackend : public AudioBackend { - public: - JitteredAudioBackend(); - ~JitteredAudioBackend() override; - - void init() override; - void start() override; - void shutdown() override; - - // Control simulation - void set_jitter_amount(float jitter_ms); // Random jitter in ms (default: 5ms) - void set_base_interval( - float interval_ms); // Base interval between reads (default: 10ms) - void set_chunk_size_range(int min_frames, - int max_frames); // Variable chunk sizes - - // Query state - int get_total_frames_consumed() const { - return total_frames_consumed_.load(); - } - int get_underrun_count() const { - return underrun_count_.load(); - } - bool is_running() const { - return running_.load(); - } - - private: - void audio_thread_loop(); - - std::thread audio_thread_; - std::atomic running_; - std::atomic should_stop_; - - // Configuration - float jitter_ms_; - float base_interval_ms_; - int min_chunk_frames_; - int max_chunk_frames_; - - // Statistics - std::atomic total_frames_consumed_; - std::atomic underrun_count_; // How many times buffer was empty -}; - -#endif /* !defined(STRIP_ALL) */ diff --git a/src/audio/miniaudio_backend.cc b/src/audio/miniaudio_backend.cc deleted file mode 100644 index 0e6fce5..0000000 --- a/src/audio/miniaudio_backend.cc +++ /dev/null @@ -1,261 +0,0 @@ -// 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" - -// 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) */ - } - -#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; -} diff --git a/src/audio/miniaudio_backend.h b/src/audio/miniaudio_backend.h deleted file mode 100644 index 82c7b76..0000000 --- a/src/audio/miniaudio_backend.h +++ /dev/null @@ -1,33 +0,0 @@ -// This file is part of the 64k demo project. -// It implements the production audio backend using miniaudio. -// This is the default backend for the final build. - -#pragma once - -#include "audio_backend.h" -#include "miniaudio.h" - -// Production audio backend using miniaudio library -// Manages real hardware audio device and playback -class MiniaudioBackend : public AudioBackend { - public: - MiniaudioBackend(); - ~MiniaudioBackend() override; - - void init() override; - void start() override; - void shutdown() override; - - // Get the underlying miniaudio device (for internal use) - ma_device* get_device() { - return &device_; - } - - private: - ma_device device_; - bool initialized_; - - // Static callback required by miniaudio C API - static void audio_callback(ma_device* pDevice, void* pOutput, - const void* pInput, ma_uint32 frameCount); -}; diff --git a/src/audio/mock_audio_backend.cc b/src/audio/mock_audio_backend.cc deleted file mode 100644 index 33ed35a..0000000 --- a/src/audio/mock_audio_backend.cc +++ /dev/null @@ -1,45 +0,0 @@ -// This file is part of the 64k demo project. -// It implements the mock audio backend for testing. -// Records voice trigger events with precise timestamps. - -#include "mock_audio_backend.h" - -#if !defined(STRIP_ALL) - -MockAudioBackend::MockAudioBackend() : current_time_sec_(0.0f) { -} - -MockAudioBackend::~MockAudioBackend() { -} - -void MockAudioBackend::init() { - // No-op for mock backend -} - -void MockAudioBackend::start() { - // No-op for mock backend -} - -void MockAudioBackend::shutdown() { - // No-op for mock backend -} - -void MockAudioBackend::on_voice_triggered(float timestamp, int spectrogram_id, - float volume, float pan) { - // Record the event with the timestamp provided by synth - VoiceTriggerEvent event; - event.timestamp_sec = timestamp; - event.spectrogram_id = spectrogram_id; - event.volume = volume; - event.pan = pan; - recorded_events_.push_back(event); -} - -void MockAudioBackend::on_frames_rendered(int num_frames) { - // Update internal time based on frames rendered - // This is called by audio_render_silent() for seek/fast-forward simulation - const float delta_sec = (float)num_frames / (float)kSampleRate; - current_time_sec_ += delta_sec; -} - -#endif /* !defined(STRIP_ALL) */ diff --git a/src/audio/mock_audio_backend.h b/src/audio/mock_audio_backend.h deleted file mode 100644 index a4ee36d..0000000 --- a/src/audio/mock_audio_backend.h +++ /dev/null @@ -1,64 +0,0 @@ -// This file is part of the 64k demo project. -// It implements a test-only mock audio backend for event recording. -// Used for tracker timing verification and audio synchronization tests. - -#pragma once - -#if !defined(STRIP_ALL) - -#include "audio_backend.h" -#include - -// Event structure for recorded voice triggers -struct VoiceTriggerEvent { - float timestamp_sec; - int spectrogram_id; - float volume; - float pan; -}; - -// Mock audio backend that records all voice trigger events -// Used for testing tracker timing and synchronization -class MockAudioBackend : public AudioBackend { - public: - MockAudioBackend(); - ~MockAudioBackend() override; - - // AudioBackend interface (no-ops for mock) - void init() override; - void start() override; - void shutdown() override; - - // Event recording hooks - void on_voice_triggered(float timestamp, int spectrogram_id, float volume, - float pan) override; - void on_frames_rendered(int num_frames) override; - - // Test interface methods - const std::vector& get_events() const { - return recorded_events_; - } - void clear_events() { - recorded_events_.clear(); - } - - // Manual time control for deterministic testing - void advance_time(float delta_sec) { - current_time_sec_ += delta_sec; - } - void set_time(float time_sec) { - current_time_sec_ = time_sec; - } - float get_current_time() const { - return current_time_sec_; - } - - // Sample rate used for frame-to-time conversion - static const int kSampleRate = 32000; - - private: - std::vector recorded_events_; - float current_time_sec_; -}; - -#endif /* !defined(STRIP_ALL) */ diff --git a/src/audio/synth.h b/src/audio/synth.h index cb0d1df..ba96167 100644 --- a/src/audio/synth.h +++ b/src/audio/synth.h @@ -44,4 +44,8 @@ void synth_set_tempo_scale( float tempo_scale); // Set playback speed (1.0 = normal) int synth_get_active_voice_count(); + +// Get peak amplitude of synthesized audio (measured at ring buffer write time) +// NOTE: For audio-visual synchronization, use audio_get_realtime_peak() instead +// This function measures peak ~400ms ahead of playback (at synth_render time) float synth_get_output_peak(); diff --git a/src/audio/wav_dump_backend.cc b/src/audio/wav_dump_backend.cc deleted file mode 100644 index df82f90..0000000 --- a/src/audio/wav_dump_backend.cc +++ /dev/null @@ -1,153 +0,0 @@ -// This file is part of the 64k demo project. -// Implementation of WAV dump backend for debugging. - -#include "wav_dump_backend.h" - -#if !defined(STRIP_ALL) - -#include -#include -#include - -WavDumpBackend::WavDumpBackend() - : wav_file_(nullptr), samples_written_(0), clipped_samples_(0), - output_filename_("audio_dump.wav"), is_active_(false), - duration_sec_(0.0f) { - sample_buffer_.resize(kBufferSize); -} - -WavDumpBackend::~WavDumpBackend() { - shutdown(); -} - -void WavDumpBackend::set_output_file(const char* filename) { - output_filename_ = filename; -} - -void WavDumpBackend::init() { - // Open WAV file for writing - wav_file_ = fopen(output_filename_, "wb"); - if (wav_file_ == nullptr) { - fprintf(stderr, "Error: Failed to open WAV file: %s\n", output_filename_); - return; - } - - // Write placeholder header (we'll update it in shutdown()) - write_wav_header(wav_file_, 0); - - samples_written_ = 0; - clipped_samples_ = 0; - printf("WAV dump backend initialized: %s\n", output_filename_); -} - -void WavDumpBackend::start() { - is_active_ = true; - printf("WAV dump started (passive mode - frontend drives rendering)\n"); -} - -void WavDumpBackend::write_audio(const float* samples, int num_samples) { - if (!is_active_ || wav_file_ == nullptr) { - return; - } - - // CRITICAL: This method must match MiniaudioBackend's sample handling - // behavior to ensure WAV dumps accurately reflect live audio output. - // - // Current behavior (verified 2026-02-07): - // - MiniaudioBackend passes float samples directly to miniaudio without - // clamping (see miniaudio_backend.cc:140) - // - Miniaudio internally converts float→int16 and handles overflow - // - We replicate this: no clamping, count out-of-range samples for diagnostics - // - // If miniaudio's sample handling changes (e.g., they add clamping or - // different overflow behavior), this code MUST be updated to match. - // Verify by checking: src/audio/miniaudio_backend.cc data_callback() - - for (int i = 0; i < num_samples; ++i) { - float sample = samples[i]; - - // Track samples outside [-1.0, 1.0] range for diagnostic reporting - // This helps identify audio distortion issues during development - if (sample > 1.0f || sample < -1.0f) { - clipped_samples_++; - } - - // Convert float→int16 with same overflow behavior as miniaudio - // Values outside [-1.0, 1.0] will wrap/clip during conversion - const int16_t sample_i16 = (int16_t)(sample * 32767.0f); - fwrite(&sample_i16, sizeof(int16_t), 1, wav_file_); - } - - samples_written_ += num_samples; -} - -void WavDumpBackend::shutdown() { - if (wav_file_ != nullptr) { - // Update header with final sample count - update_wav_header(); - fclose(wav_file_); - wav_file_ = nullptr; - - const float duration_sec = (float)samples_written_ / (kSampleRate * 2); - printf("WAV file written: %s (%.2f seconds, %zu samples)\n", - output_filename_, duration_sec, samples_written_); - - // Report clipping diagnostics - if (clipped_samples_ > 0) { - const float clip_percent = - (float)clipped_samples_ / (float)samples_written_ * 100.0f; - printf(" WARNING: %zu samples clipped (%.2f%% of total)\n", - clipped_samples_, clip_percent); - printf(" This indicates audio distortion - consider reducing volume\n"); - } else { - printf(" ✓ No clipping detected\n"); - } - } - - is_active_ = false; -} - -void WavDumpBackend::write_wav_header(FILE* file, uint32_t num_samples) { - // WAV file header structure - // Reference: http://soundfile.sapp.org/doc/WaveFormat/ - - const uint32_t num_channels = 2; // Stereo (matches miniaudio config) - const uint32_t sample_rate = kSampleRate; - const uint32_t bits_per_sample = 16; - const uint32_t byte_rate = sample_rate * num_channels * bits_per_sample / 8; - const uint16_t block_align = num_channels * bits_per_sample / 8; - const uint32_t data_size = num_samples * num_channels * bits_per_sample / 8; - - // RIFF header - fwrite("RIFF", 1, 4, file); - const uint32_t chunk_size = 36 + data_size; - fwrite(&chunk_size, 4, 1, file); - fwrite("WAVE", 1, 4, file); - - // fmt subchunk - fwrite("fmt ", 1, 4, file); - const uint32_t subchunk1_size = 16; - fwrite(&subchunk1_size, 4, 1, file); - const uint16_t audio_format = 1; // PCM - fwrite(&audio_format, 2, 1, file); - fwrite(&num_channels, 2, 1, file); - fwrite(&sample_rate, 4, 1, file); - fwrite(&byte_rate, 4, 1, file); - fwrite(&block_align, 2, 1, file); - fwrite(&bits_per_sample, 2, 1, file); - - // data subchunk header - fwrite("data", 1, 4, file); - fwrite(&data_size, 4, 1, file); -} - -void WavDumpBackend::update_wav_header() { - if (wav_file_ == nullptr) - return; - - // Seek to beginning and rewrite header with actual sample count - fseek(wav_file_, 0, SEEK_SET); - write_wav_header(wav_file_, samples_written_); -} - -#endif /* !defined(STRIP_ALL) */ diff --git a/src/audio/wav_dump_backend.h b/src/audio/wav_dump_backend.h deleted file mode 100644 index c3c5302..0000000 --- a/src/audio/wav_dump_backend.h +++ /dev/null @@ -1,64 +0,0 @@ -// This file is part of the 64k demo project. -// WAV dump backend for debugging audio output to file instead of device. -// Stripped in final build (STRIP_ALL). - -#if !defined(DEMO_AUDIO_WAV_DUMP_BACKEND_H) -#define DEMO_AUDIO_WAV_DUMP_BACKEND_H - -#include "audio_backend.h" -#include -#include - -#if !defined(STRIP_ALL) - -// WAV file dump backend for debugging -// Captures audio from synth_render() and writes to .wav file -class WavDumpBackend : public AudioBackend { - public: - WavDumpBackend(); - ~WavDumpBackend(); - - // AudioBackend interface - void init() override; - void start() override; - void shutdown() override; - - // Set output filename (call before init()) - void set_output_file(const char* filename); - - // Write audio data to WAV file (stereo interleaved float samples) - // num_samples: Total number of samples (2x num_frames for stereo) - void write_audio(const float* samples, int num_samples); - - // Get total samples written - size_t get_samples_written() const { - return samples_written_; - } - - // Get number of samples that were clipped (diagnostic metric) - size_t get_clipped_samples() const { - return clipped_samples_; - } - - private: - // Write WAV header with known sample count - void write_wav_header(FILE* file, uint32_t num_samples); - - // Update WAV header with final sample count - void update_wav_header(); - - FILE* wav_file_; - std::vector sample_buffer_; - size_t samples_written_; - size_t clipped_samples_; - const char* output_filename_; - bool is_active_; - float duration_sec_; - - static const int kSampleRate = 32000; - static const int kBufferSize = 1024; -}; - -#endif /* !defined(STRIP_ALL) */ - -#endif /* DEMO_AUDIO_WAV_DUMP_BACKEND_H */ -- cgit v1.2.3