From aeb4ee8812b2fa317707b44f959b63031a8b861f Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 12 May 2026 08:54:48 -0400 Subject: [PATCH 1/3] Remove the interpolation buffer - Soft sync is done in the output transfer buffer - A small 1 KB null buffer is always allocated (much less than teh prior approach) in internal memory. This avoids flash/psram contention at the start of a stream when stuttering is most common --- docs/integration-guide.md | 5 +- docs/internals.md | 2 +- include/sendspin/config.h | 4 - src/sync_task.cpp | 191 +++++++++++++++++--------------------- src/sync_task.h | 16 +++- 5 files changed, 99 insertions(+), 119 deletions(-) diff --git a/docs/integration-guide.md b/docs/integration-guide.md index be03d60..e90c06c 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -725,8 +725,7 @@ Configuration passed to `client.add_player()`. | `initial_static_delay_ms` | `uint16_t` | `0` | Initial value for the user-adjustable static delay in milliseconds. Overridden by the persisted value if a `SendspinPersistenceProvider` is set. | | `psram_stack` | `bool` | `false` | Allocate sync/decode task stack in PSRAM (ESP-IDF only) | | `priority` | `unsigned` | `18` | FreeRTOS priority for the sync/decode task (ESP-IDF only). The default value, `18`, is one above the default `httpd_priority` (`17`). If you customize priorities, keep this above `httpd_priority` so the HTTP server task cannot starve the decoder during the initial burst of encoded audio that fills the buffer at stream start. | -| `interpolation_buffer_location` | `MemoryLocation` | `PREFER_EXTERNAL` | Memory placement preference for the interpolation transfer buffer. `PREFER_EXTERNAL` tries SPIRAM first and falls back to internal RAM; `PREFER_INTERNAL` does the reverse. ESP-IDF only; ignored on host. | -| `decode_buffer_location` | `MemoryLocation` | `PREFER_EXTERNAL` | Memory placement preference for the decode transfer buffer. Same semantics as `interpolation_buffer_location`. ESP-IDF only; ignored on host. | +| `decode_buffer_location` | `MemoryLocation` | `PREFER_EXTERNAL` | Memory placement preference for the decode transfer buffer. `PREFER_EXTERNAL` tries SPIRAM first and falls back to internal RAM; `PREFER_INTERNAL` does the reverse. ESP-IDF only; ignored on host. | Each entry in `audio_formats` is an `AudioSupportedFormatObject`: @@ -916,4 +915,4 @@ Set with `SendspinClient::set_log_level()`. Only affects host builds; ESP-IDF bu | `PREFER_EXTERNAL` | Prefer SPIRAM, fall back to internal RAM (ESP-IDF only) | | `PREFER_INTERNAL` | Prefer internal RAM, fall back to SPIRAM (ESP-IDF only) | -Used by `SendspinClientConfig::websocket_payload_location` to control where the per-connection WebSocket payload reassembly buffer is allocated, and by `PlayerRoleConfig::interpolation_buffer_location` and `decode_buffer_location` to control where the player's transfer buffers are allocated. Ignored on host platforms (no internal/external distinction). +Used by `SendspinClientConfig::websocket_payload_location` to control where the per-connection WebSocket payload reassembly buffer is allocated, and by `PlayerRoleConfig::decode_buffer_location` to control where the player's decode transfer buffer is allocated. Ignored on host platforms (no internal/external distinction). diff --git a/docs/internals.md b/docs/internals.md index 74d4c18..0df4c5e 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -335,7 +335,7 @@ Where `decoded_timestamp` is the server timestamp converted to client time (via |-------------|--------| | > +5000 us (or +500 us settling) | **Hard sync ahead**: insert silence frames to fill the gap | | < -5000 us (or -500 us settling) | **Hard sync behind**: drop the decoded chunk | -| +100 to +5000 us | **Soft sync**: insert one interpolated frame (average of first two) | +| +100 to +5000 us | **Soft sync**: insert one interpolated frame near the end (average of last two) | | -100 to -5000 us | **Soft sync**: remove last frame (blend into second-to-last) | | -100 to +100 us | **Dead zone**: pass audio through unmodified | diff --git a/include/sendspin/config.h b/include/sendspin/config.h index f619baf..352dd62 100644 --- a/include/sendspin/config.h +++ b/include/sendspin/config.h @@ -119,10 +119,6 @@ struct PlayerRoleConfig { unsigned priority{DEFAULT_SYNC_TASK_PRIORITY}; ///< FreeRTOS priority for the sync/decode ///< task (ESP-IDF only) - /// @brief Memory placement for the interpolation transfer buffer (ESP-IDF only; ignored on - /// host). Defaults to PREFER_EXTERNAL (SPIRAM). - MemoryLocation interpolation_buffer_location{MemoryLocation::PREFER_EXTERNAL}; - /// @brief Memory placement for the decode transfer buffer (ESP-IDF only; ignored on host). /// Defaults to PREFER_EXTERNAL (SPIRAM). MemoryLocation decode_buffer_location{MemoryLocation::PREFER_EXTERNAL}; diff --git a/src/sync_task.cpp b/src/sync_task.cpp index 73e61fc..21d60f9 100644 --- a/src/sync_task.cpp +++ b/src/sync_task.cpp @@ -56,6 +56,18 @@ static constexpr uint32_t AUDIO_WRITE_TIMEOUT_MS = 20U; /// before the next push. static constexpr uint32_t INITIAL_SYNC_SETTLE_MIN_MS = 5U; +/// @brief Size of the shared silence scratch buffer. Bounds the bytes pushed to the sink per call; +/// silence longer than this is sent over multiple iterations. +static constexpr size_t SILENCE_SCRATCH_BYTES = 1024; + +/// @brief Chunk of zeros streamed to the sink for initial-sync priming and hard-sync gap fills. +/// Never written after zero-initialization (the sink only ever reads its input). Deliberately +/// non-const so it lands in .bss (internal SRAM on ESP-IDF) rather than .rodata (flash): the +/// initial-sync push happens exactly when the server is flooding the ring buffer in PSRAM, and on +/// the ESP32 flash shares the SPI bus with PSRAM, so a flash read here would contend with that +/// flood. .bss costs no heap and no flash; it is reserved and zeroed once at startup. +static uint8_t silence_scratch[SILENCE_SCRATCH_BYTES] = {}; + static const char* const TAG = "sendspin.sync_task"; // ============================================================================ @@ -155,29 +167,17 @@ void SyncTask::notify_audio_played(uint32_t frames, int64_t timestamp) { SyncTaskState SyncTask::handle_initial_sync(SyncContext& sync_context) { if (!sync_context.initial_decode) { + // Priming done (the audio stack has started consuming) - drop any leftover priming silence + // so it is not injected before the first real chunk. + sync_context.silence_remaining = 0; return SyncTaskState::LOAD_CHUNK; } - if (sync_context.interpolation_transfer_buffer->available() > 0) { - size_t bytes_written = sync_context.interpolation_transfer_buffer->transfer_data_to_sink( - AUDIO_WRITE_TIMEOUT_MS); - this->track_sent_audio(sync_context, bytes_written); - if ((bytes_written > 0) && sync_context.initial_decode) { - // Sent initial zeros, delay slightly to give it some time to work through the audio - // stack - std::this_thread::sleep_for(std::chrono::milliseconds(std::max( - INITIAL_SYNC_SETTLE_MIN_MS, - sync_context.current_stream_info.bytes_to_ms(bytes_written) / 2))); - } - } else { - const size_t zeroed_bytes = sync_context.interpolation_transfer_buffer->free(); - std::memset( - static_cast(sync_context.interpolation_transfer_buffer->get_buffer_end()), 0, - zeroed_bytes); - sync_context.interpolation_transfer_buffer->increase_buffer_length( - std::min(zeroed_bytes, - sync_context.current_stream_info.ms_to_bytes(INITIAL_SYNC_ZEROS_DURATION_MS))); + if (sync_context.silence_remaining == 0) { + sync_context.silence_remaining = + sync_context.current_stream_info.ms_to_bytes(INITIAL_SYNC_ZEROS_DURATION_MS); } + this->send_pending_silence(sync_context); return SyncTaskState::INITIAL_SYNC; } @@ -223,32 +223,20 @@ SyncTaskState SyncTask::handle_synchronize_audio(SyncContext& sync_context) { // gap sync_context.hard_syncing = true; - // Clear any stale interpolation data - sync_context.interpolation_transfer_buffer->decrease_buffer_length( - sync_context.interpolation_transfer_buffer->available()); - // Compute silence directly in frames from microseconds (avoids ms truncation) uint32_t silence_frames = static_cast( (static_cast(raw_error) * static_cast(sync_context.current_stream_info.get_sample_rate())) / static_cast(US_PER_SECOND)); - size_t silence_bytes = sync_context.current_stream_info.frames_to_bytes(silence_frames); - - // Cap at buffer capacity - const size_t buffer_free = sync_context.interpolation_transfer_buffer->free(); - size_t actual_bytes = std::min(silence_bytes, buffer_free); - - std::memset( - static_cast(sync_context.interpolation_transfer_buffer->get_buffer_end()), 0, - actual_bytes); - sync_context.interpolation_transfer_buffer->increase_buffer_length(actual_bytes); + sync_context.silence_remaining = + sync_context.current_stream_info.frames_to_bytes(silence_frames); // Playtime estimate is advanced by transfer_audio() when the silence is actually sent sync_context.release_chunk = false; // Keep decoded audio for after the silence SS_LOGV(TAG, "Hard sync: adding %" PRIu32 " frames of silence for %" PRId64 "us future error", - sync_context.current_stream_info.bytes_to_frames(actual_bytes), raw_error); + silence_frames, raw_error); } else if (raw_error < -active_threshold) { // Chunk should have started playing already - drop only the late prefix from the front of // the buffer and play the remainder. By the time we get here, silence has already been @@ -283,7 +271,7 @@ SyncTaskState SyncTask::handle_synchronize_audio(SyncContext& sync_context) { sync_context.hard_syncing = false; if (raw_error > SOFT_SYNC_THRESHOLD_US) { - // Slightly behind - add one interpolated frame between the first two decoded frames + // Slightly behind - add one interpolated frame between the last two decoded frames // Playtime estimate is advanced by transfer_audio() when the extra frame is sent this->soft_sync_insert_frame(sync_context); } else if (raw_error < -SOFT_SYNC_THRESHOLD_US) { @@ -319,21 +307,33 @@ void SyncTask::track_sent_audio(SyncContext& sync_context, size_t bytes_sent) { static_cast(sync_context.current_stream_info.frames_to_microseconds(remainder)); } -bool SyncTask::transfer_audio(SyncContext& sync_context) { - size_t bytes_written = - sync_context.interpolation_transfer_buffer->transfer_data_to_sink(AUDIO_WRITE_TIMEOUT_MS); +void SyncTask::send_pending_silence(SyncContext& sync_context) { + if (sync_context.silence_remaining == 0) { + return; + } + + size_t chunk = std::min(sync_context.silence_remaining, sizeof(silence_scratch)); + size_t bytes_written = 0; + if (this->player_impl_->listener != nullptr) { + bytes_written = this->player_impl_->listener->on_audio_write(silence_scratch, chunk, + AUDIO_WRITE_TIMEOUT_MS); + } this->track_sent_audio(sync_context, bytes_written); + sync_context.silence_remaining -= bytes_written; if ((bytes_written > 0) && sync_context.initial_decode) { - // Sent initial zeros, delay slightly to give it some time to work through the audio stack + // Sent priming zeros - delay slightly to give the audio stack time to start consuming std::this_thread::sleep_for(std::chrono::milliseconds( std::max(INITIAL_SYNC_SETTLE_MIN_MS, sync_context.current_stream_info.bytes_to_ms(bytes_written) / 2))); } +} + +bool SyncTask::transfer_audio(SyncContext& sync_context) { + // Pending silence (priming or hard-sync gap fill) goes out before the decoded chunk + this->send_pending_silence(sync_context); - if (sync_context.interpolation_transfer_buffer->available() == 0 && - sync_context.release_chunk) { - // No interpolation bytes available, send main audio data + if (sync_context.silence_remaining == 0 && sync_context.release_chunk) { size_t decode_bytes_written = sync_context.decode_buffer->transfer_data_to_sink(AUDIO_WRITE_TIMEOUT_MS); this->track_sent_audio(sync_context, decode_bytes_written); @@ -345,7 +345,7 @@ bool SyncTask::transfer_audio(SyncContext& sync_context) { } // Keep transferring if there's still data to send - if (sync_context.interpolation_transfer_buffer->available() > 0) { + if (sync_context.silence_remaining > 0) { return false; } if (sync_context.release_chunk && sync_context.decode_buffer->available() > 0) { @@ -403,38 +403,38 @@ int32_t SyncTask::soft_sync_drop_frame(SyncContext& sync_context) { int32_t SyncTask::soft_sync_insert_frame(SyncContext& sync_context) { // Small sync adjustment after getting slightly behind. - // Adds one new frame to get in sync. The new frame is inserted between the first and second - // frames. The new frame is the average of the first two frames in the chunk to minimize audible - // glitches. + // Adds one new frame to get in sync. The new frame is inserted between the last two frames of + // the chunk and set to the average of those two frames to minimize audible glitches. The + // original last frame is moved into the spare frame the decode buffer reserves past the decoded + // data, so no second buffer is needed. - if ((sync_context.interpolation_transfer_buffer->free() >= sync_context.bytes_per_frame) && - (sync_context.decode_buffer->available() >= 2 * sync_context.bytes_per_frame)) { - const uint32_t num_channels = sync_context.current_stream_info.get_channels(); - const uint32_t bytes_per_sample = sync_context.bytes_per_frame / num_channels; - - for (uint32_t chan = 0; chan < num_channels; ++chan) { - const size_t chan_offset = - static_cast(chan) * static_cast(bytes_per_sample); - const int32_t first_sample = unpack_audio_sample_to_q31( - sync_context.decode_buffer->get_buffer_start() + chan_offset, bytes_per_sample); - const int32_t second_sample = - unpack_audio_sample_to_q31(sync_context.decode_buffer->get_buffer_start() + - chan_offset + sync_context.bytes_per_frame, - bytes_per_sample); - int32_t new_sample = first_sample / 2 + second_sample / 2; - pack_q31_as_audio_sample(new_sample, - sync_context.decode_buffer->get_buffer_start() + chan_offset, - bytes_per_sample); - pack_q31_as_audio_sample( - first_sample, - sync_context.interpolation_transfer_buffer->get_buffer_start() + chan_offset, - bytes_per_sample); - } - sync_context.interpolation_transfer_buffer->increase_buffer_length( - sync_context.bytes_per_frame); - return 1; + if ((sync_context.decode_buffer->available() < 2 * sync_context.bytes_per_frame) || + (sync_context.decode_buffer->free() < sync_context.bytes_per_frame)) { + return 0; } - return 0; + + const uint32_t num_channels = sync_context.current_stream_info.get_channels(); + const uint32_t bytes_per_sample = sync_context.bytes_per_frame / num_channels; + + uint8_t* last_frame = + sync_context.decode_buffer->get_buffer_end() - sync_context.bytes_per_frame; + uint8_t* second_last_frame = last_frame - sync_context.bytes_per_frame; + uint8_t* spare_frame = sync_context.decode_buffer->get_buffer_end(); + + // Move the original last frame into the spare slot, then blend the new frame into its old slot. + std::memcpy(spare_frame, last_frame, sync_context.bytes_per_frame); + for (uint32_t chan = 0; chan < num_channels; ++chan) { + const size_t chan_offset = + static_cast(chan) * static_cast(bytes_per_sample); + const int32_t second_last_sample = + unpack_audio_sample_to_q31(second_last_frame + chan_offset, bytes_per_sample); + const int32_t last_sample = + unpack_audio_sample_to_q31(last_frame + chan_offset, bytes_per_sample); + const int32_t blended_sample = second_last_sample / 2 + last_sample / 2; + pack_q31_as_audio_sample(blended_sample, last_frame + chan_offset, bytes_per_sample); + } + sync_context.decode_buffer->increase_buffer_length(sync_context.bytes_per_frame); + return 1; } DecodeResult SyncTask::decode_chunk(SyncContext& sync_context) { @@ -462,22 +462,13 @@ DecodeResult SyncTask::decode_chunk(SyncContext& sync_context) { if (decoded_stream_info != sync_context.current_stream_info) { sync_context.current_stream_info = decoded_stream_info; sync_context.bytes_per_frame = sync_context.current_stream_info.frames_to_bytes(1); - - // Resize interpolation buffer if needed for the actual stream parameters - size_t needed_interp_size = - sync_context.current_stream_info.ms_to_bytes(INITIAL_SYNC_ZEROS_DURATION_MS); - if (sync_context.interpolation_transfer_buffer != nullptr && - needed_interp_size > sync_context.interpolation_transfer_buffer->capacity()) { - if (!sync_context.interpolation_transfer_buffer->reallocate( - needed_interp_size)) { - SS_LOGW(TAG, "Failed to resize interpolation buffer for new stream info"); - } - } } // Create or resize the decode buffer using the decoder's current required size - // estimate; some codecs (for example, Opus) may require this to grow later. - size_t needed = sync_context.decoder->get_decode_buffer_size(); + // estimate; some codecs (for example, Opus) may require this to grow later. One extra + // frame is reserved past the decoded data for soft-sync frame insertion. + size_t needed = + sync_context.decoder->get_decode_buffer_size() + sync_context.bytes_per_frame; if (sync_context.decode_buffer == nullptr) { sync_context.decode_buffer = TransferBuffer::create( needed, this->player_impl_->config.decode_buffer_location); @@ -521,8 +512,9 @@ DecodeResult SyncTask::decode_chunk(SyncContext& sync_context) { if (!decoded) { // The decoder raises its decoded-size estimate when it meets an unusually large chunk // (e.g. a multi-frame Opus packet bigger than the typical 20ms buffer). Grow the - // buffer to the new estimate and retry once. - size_t needed = sync_context.decoder->get_decode_buffer_size(); + // buffer to the new estimate (plus the reserved spare frame) and retry once. + size_t needed = + sync_context.decoder->get_decode_buffer_size() + sync_context.bytes_per_frame; if (needed > sync_context.decode_buffer->capacity() && sync_context.decode_buffer->reallocate(needed)) { decoded = sync_context.decoder->decode_audio_chunk( @@ -601,15 +593,12 @@ void SyncTask::reset_context(SyncContext& sync_context) { sync_context.release_chunk = false; sync_context.initial_decode = true; sync_context.hard_syncing = true; + sync_context.silence_remaining = 0; - // Empty buffers without deallocating + // Empty the decode buffer without deallocating if (sync_context.decode_buffer) { sync_context.decode_buffer->decrease_buffer_length(sync_context.decode_buffer->available()); } - if (sync_context.interpolation_transfer_buffer) { - sync_context.interpolation_transfer_buffer->decrease_buffer_length( - sync_context.interpolation_transfer_buffer->available()); - } if (sync_context.decoder) { sync_context.decoder->reset_decoders(); } @@ -665,22 +654,10 @@ void SyncTask::stop() { void SyncTask::thread_entry(void* params) { SyncTask* this_task = static_cast(params); - // Allocate SyncContext once on the task stack, reused across streams. + // Allocate SyncContext once on the task stack, reused across streams. The decode buffer is + // created lazily once the first codec header arrives. SyncContext sync_context; sync_context.bytes_per_frame = sync_context.current_stream_info.frames_to_bytes(1); - - sync_context.interpolation_transfer_buffer = TransferBuffer::create( - sync_context.current_stream_info.ms_to_bytes(INITIAL_SYNC_ZEROS_DURATION_MS), - this_task->player_impl_->config.interpolation_buffer_location); - if (sync_context.interpolation_transfer_buffer == nullptr) { - SS_LOGE(TAG, "Failed to allocate interpolation transfer buffer"); - this_task->event_flags_.set(EventGroupBits::TASK_ERROR | EventGroupBits::TASK_STOPPED); - return; - } - - if (this_task->player_impl_->listener) { - sync_context.interpolation_transfer_buffer->set_listener(this_task->player_impl_->listener); - } sync_context.decoder = std::make_unique(); // === OUTER LOOP: persists for the lifetime of the client === diff --git a/src/sync_task.h b/src/sync_task.h index 72330c9..112888a 100644 --- a/src/sync_task.h +++ b/src/sync_task.h @@ -62,10 +62,11 @@ struct SyncContext { AudioStreamInfo current_stream_info; // Contains uint32_t and smaller members // Pointer fields - std::unique_ptr decode_buffer; // Reusable decode + output buffer + std::unique_ptr decode_buffer; // Reusable decode + output buffer; reserves one + // spare frame past the decoded data for + // soft-sync frame insertion std::unique_ptr decoder; AudioRingBufferEntry* encoded_entry{nullptr}; - std::unique_ptr interpolation_transfer_buffer; // 64-bit fields int64_t decoded_timestamp{0}; // Timestamp for decoded audio @@ -73,6 +74,8 @@ struct SyncContext { // size_t fields size_t bytes_per_frame{0}; + size_t silence_remaining{0}; // Bytes of silence still to emit before the next/held chunk + // (initial-sync priming or hard-sync gap fill) // 32-bit fields uint32_t buffered_frames{0}; @@ -191,7 +194,11 @@ class SyncTask { /// speaker. These two must always be updated together to keep the playtime estimate consistent. void track_sent_audio(SyncContext& sync_context, size_t bytes_sent); - /// @brief Transfers audio from interpolation and decode buffers to the sink + /// @brief Sends one chunk of pending silence (initial-sync priming or hard-sync gap fill) to + /// the sink and updates the playtime estimate. No-op when no silence is pending. + void send_pending_silence(SyncContext& sync_context); + + /// @brief Transfers pending silence (if any) then the decoded chunk to the sink /// Returns true when all data has been sent, false if more transfers are needed. bool transfer_audio(SyncContext& sync_context); @@ -203,7 +210,8 @@ class SyncTask { /// Returns -1 if a frame was removed, 0 if preconditions not met. int32_t soft_sync_drop_frame(SyncContext& sync_context); - /// @brief Adds one interpolated frame between the first two decoded frames + /// @brief Adds one interpolated frame between the last two decoded frames (average of the two), + /// moving the original last frame into the decode buffer's reserved spare frame /// Returns 1 if a frame was added, 0 if preconditions not met. int32_t soft_sync_insert_frame(SyncContext& sync_context); From a3b65fd12057dfb751f06ea6e573525ad896797a Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 12 May 2026 09:22:19 -0400 Subject: [PATCH 2/3] Frame align the silence bytes written --- src/sync_task.cpp | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/sync_task.cpp b/src/sync_task.cpp index 21d60f9..6934093 100644 --- a/src/sync_task.cpp +++ b/src/sync_task.cpp @@ -174,8 +174,11 @@ SyncTaskState SyncTask::handle_initial_sync(SyncContext& sync_context) { } if (sync_context.silence_remaining == 0) { - sync_context.silence_remaining = - sync_context.current_stream_info.ms_to_bytes(INITIAL_SYNC_ZEROS_DURATION_MS); + // Keep the silence run frame-aligned so per-write chunks (and the playtime accounting in + // track_sent_audio) land on whole frames. + sync_context.silence_remaining = sync_context.current_stream_info.frames_to_bytes( + sync_context.current_stream_info.bytes_to_frames( + sync_context.current_stream_info.ms_to_bytes(INITIAL_SYNC_ZEROS_DURATION_MS))); } this->send_pending_silence(sync_context); @@ -313,6 +316,16 @@ void SyncTask::send_pending_silence(SyncContext& sync_context) { } size_t chunk = std::min(sync_context.silence_remaining, sizeof(silence_scratch)); + // Push whole frames only: the scratch size is not necessarily a multiple of bytes_per_frame + // (e.g. 24-bit stereo), and unaligned writes would mis-account playtime in track_sent_audio() + // and can violate frame-alignment expectations of some sinks. silence_remaining is itself + // frame-aligned, so the final chunk stays whole. + if (sync_context.bytes_per_frame > 0) { + chunk -= chunk % sync_context.bytes_per_frame; + if (chunk == 0) { + chunk = std::min(sync_context.silence_remaining, sync_context.bytes_per_frame); + } + } size_t bytes_written = 0; if (this->player_impl_->listener != nullptr) { bytes_written = this->player_impl_->listener->on_audio_write(silence_scratch, chunk, From fbda76a3fa5b50523d519340d8196df3ae84b656 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 12 May 2026 09:26:30 -0400 Subject: [PATCH 3/3] Reduce minimum settle time to make sure we feed zeros fast enough --- src/sync_task.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sync_task.cpp b/src/sync_task.cpp index 6934093..cb7282f 100644 --- a/src/sync_task.cpp +++ b/src/sync_task.cpp @@ -54,7 +54,7 @@ static constexpr uint32_t AUDIO_WRITE_TIMEOUT_MS = 20U; /// @brief Minimum sleep (ms) after an initial-sync push, to let the audio stack begin draining /// before the next push. -static constexpr uint32_t INITIAL_SYNC_SETTLE_MIN_MS = 5U; +static constexpr uint32_t INITIAL_SYNC_SETTLE_MIN_MS = 2U; /// @brief Size of the shared silence scratch buffer. Bounds the bytes pushed to the sink per call; /// silence longer than this is sent over multiple iterations.