summaryrefslogtreecommitdiff
path: root/src/audio/backend
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-07 14:00:23 +0100
committerskal <pascal.massimino@gmail.com>2026-02-07 14:00:23 +0100
commita6a7bf0440dbabdc6c994c0fb21a8ac31c27be07 (patch)
tree26663d3d65b110fca618d6fa33c83f7a8d1e362a /src/audio/backend
parentda1d4e10731789191d8a23e60c3dd35217e6bdb0 (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.cc118
-rw-r--r--src/audio/backend/jittered_audio_backend.h60
-rw-r--r--src/audio/backend/miniaudio_backend.cc286
-rw-r--r--src/audio/backend/miniaudio_backend.h38
-rw-r--r--src/audio/backend/mock_audio_backend.cc51
-rw-r--r--src/audio/backend/mock_audio_backend.h65
-rw-r--r--src/audio/backend/silent_backend.cc50
-rw-r--r--src/audio/backend/silent_backend.h52
-rw-r--r--src/audio/backend/wav_dump_backend.cc159
-rw-r--r--src/audio/backend/wav_dump_backend.h65
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 */