From eb5dee66385760953f3b00706318fe0e4ce90b0e Mon Sep 17 00:00:00 2001 From: skal Date: Wed, 4 Feb 2026 16:36:34 +0100 Subject: fix(audio): Add pending buffer for partial writes to prevent sample loss Implemented pending write buffer on main thread to handle partial ring buffer writes, preventing sample loss during high-load scenarios (acceleration phase). Problem: Even after checking available_write(), partial writes could occur: - Check: available_write() says 1066 samples available - Audio thread consumes 500 samples (between check and write) - synth_render() generates 1066 samples - write() returns 566 (partial write) - Remaining 500 samples LOST! Synth advanced but samples discarded - Result: Audio corruption and glitches during acceleration Solution (as proposed by user): Implement a pending write buffer (ring buffer on main thread): - Static buffer holds partially written samples - On each audio_render_ahead() call: 1. First, try to flush pending samples from previous partial writes 2. Only render new samples if pending buffer is empty 3. If write() returns partial, save remaining samples to pending buffer 4. Retry writing pending samples on next frame Implementation: - g_pending_buffer[MAX_PENDING_SAMPLES]: Static buffer (2048 samples = 533 frames stereo) - g_pending_samples: Tracks how many samples are waiting - Flush logic: Try to write pending samples first, shift remaining to front - Save logic: If partial write, copy remaining samples to pending buffer - No sample loss: Every rendered sample is eventually written Benefits: - Zero sample loss (all rendered samples eventually written) - Synth stays synchronized (we track rendered frames correctly) - Handles partial writes gracefully - No audio corruption during high-load phases - Simple and efficient (no dynamic allocation in hot path) Testing: - All 17 tests pass (100%) - WAV dump produces correct output (61.24s music time) - Live playback should have no glitches during acceleration Co-Authored-By: Claude Sonnet 4.5 --- src/audio/audio.cc | 55 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 5 deletions(-) (limited to 'src/audio') diff --git a/src/audio/audio.cc b/src/audio/audio.cc index de1c702..3b7b7fd 100644 --- a/src/audio/audio.cc +++ b/src/audio/audio.cc @@ -17,6 +17,12 @@ // Global ring buffer for audio streaming static AudioRingBuffer g_ring_buffer; +// Pending write buffer for partially written samples +// Maximum size: one chunk (533 frames @ 60fps = 1066 samples stereo) +#define MAX_PENDING_SAMPLES 2048 +static float g_pending_buffer[MAX_PENDING_SAMPLES]; +static int g_pending_samples = 0; // How many samples are waiting to be written + // Global backend pointer for audio abstraction static AudioBackend* g_audio_backend = nullptr; static MiniaudioBackend g_default_backend; @@ -54,6 +60,9 @@ int register_spec_asset(AssetId id) { void audio_init() { synth_init(); + // Clear pending buffer + g_pending_samples = 0; + // Use default backend if none set if (g_audio_backend == nullptr) { g_audio_backend = &g_default_backend; @@ -85,6 +94,31 @@ void audio_render_ahead(float music_time, float dt) { // Keep rendering small chunks until buffer is full enough while (true) { + // First, try to flush any pending samples from previous partial writes + if (g_pending_samples > 0) { + const int written = g_ring_buffer.write(g_pending_buffer, g_pending_samples); + + if (written > 0) { + // Some or all samples were written + // Move remaining samples to front of buffer + const int remaining = g_pending_samples - written; + if (remaining > 0) { + for (int i = 0; i < remaining; ++i) { + g_pending_buffer[i] = g_pending_buffer[written + i]; + } + } + g_pending_samples = remaining; + + // Notify backend + if (g_audio_backend != nullptr) { + g_audio_backend->on_frames_rendered(written / RING_BUFFER_CHANNELS); + } + } + + // If still have pending samples, buffer is full - wait for consumption + if (g_pending_samples > 0) break; + } + // Check current buffer state const int buffered_samples = g_ring_buffer.available_read(); const float buffered_time = @@ -93,7 +127,7 @@ void audio_render_ahead(float music_time, float dt) { // Stop if buffer is full enough if (buffered_time >= target_lookahead) break; - // Check if buffer has space for this chunk BEFORE rendering + // Check if buffer has space for this chunk const int available_space = g_ring_buffer.available_write(); if (available_space < chunk_samples) { // Buffer is too full, wait for audio callback to consume more @@ -106,17 +140,28 @@ void audio_render_ahead(float music_time, float dt) { // Render audio from synth (advances synth state incrementally) synth_render(temp_buffer, chunk_frames); - // Write to ring buffer (should succeed since we checked space) + // Write to ring buffer const int written = g_ring_buffer.write(temp_buffer, chunk_samples); - // Notify backend of frames rendered (for testing/tracking) + // If partial write, save remaining samples to pending buffer + if (written < chunk_samples) { + const int remaining = chunk_samples - written; + if (remaining <= MAX_PENDING_SAMPLES) { + for (int i = 0; i < remaining; ++i) { + g_pending_buffer[i] = temp_buffer[written + i]; + } + g_pending_samples = remaining; + } + } + + // Notify backend of frames rendered (count frames sent to synth) if (g_audio_backend != nullptr) { - g_audio_backend->on_frames_rendered(written / RING_BUFFER_CHANNELS); + g_audio_backend->on_frames_rendered(chunk_frames); } delete[] temp_buffer; - // Safety: if write failed unexpectedly, stop to avoid infinite loop + // If we couldn't write everything, stop and retry next frame if (written < chunk_samples) break; } } -- cgit v1.2.3