diff options
| author | skal <pascal.massimino@gmail.com> | 2026-02-07 14:00:23 +0100 |
|---|---|---|
| committer | skal <pascal.massimino@gmail.com> | 2026-02-07 14:00:23 +0100 |
| commit | a6a7bf0440dbabdc6c994c0fb21a8ac31c27be07 (patch) | |
| tree | 26663d3d65b110fca618d6fa33c83f7a8d1e362a /src/audio/backend | |
| parent | da1d4e10731789191d8a23e60c3dd35217e6bdb0 (diff) | |
feat(audio): Add SilentBackend, fix peak measurement, reorganize backends
## 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 <noreply@anthropic.com>
Diffstat (limited to 'src/audio/backend')
| -rw-r--r-- | src/audio/backend/jittered_audio_backend.cc | 118 | ||||
| -rw-r--r-- | src/audio/backend/jittered_audio_backend.h | 60 | ||||
| -rw-r--r-- | src/audio/backend/miniaudio_backend.cc | 286 | ||||
| -rw-r--r-- | src/audio/backend/miniaudio_backend.h | 38 | ||||
| -rw-r--r-- | src/audio/backend/mock_audio_backend.cc | 51 | ||||
| -rw-r--r-- | src/audio/backend/mock_audio_backend.h | 65 | ||||
| -rw-r--r-- | src/audio/backend/silent_backend.cc | 50 | ||||
| -rw-r--r-- | src/audio/backend/silent_backend.h | 52 | ||||
| -rw-r--r-- | src/audio/backend/wav_dump_backend.cc | 159 | ||||
| -rw-r--r-- | src/audio/backend/wav_dump_backend.h | 65 |
10 files changed, 944 insertions, 0 deletions
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 <chrono> +#include <cstdlib> +#include <cstring> +#include <random> +#include <thread> + +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<float> jitter_dist(-jitter_ms_, jitter_ms_); + std::uniform_int_distribution<int> 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 <atomic> +#include <thread> + +// 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<bool> running_; + std::atomic<bool> should_stop_; + + // Configuration + float jitter_ms_; + float base_interval_ms_; + int min_chunk_frames_; + int max_chunk_frames_; + + // Statistics + std::atomic<int> total_frames_consumed_; + std::atomic<int> 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 <cmath> + +// 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 <vector> + +// 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<VoiceTriggerEvent>& 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<VoiceTriggerEvent> 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 <atomic> + +// 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<int> frames_rendered_; + std::atomic<int> 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 <assert.h> +#include <stdio.h> +#include <string.h> + +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 <stdio.h> +#include <vector> + +#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<float> 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 */ |
