Skip to content

Audio player lifecycle & threading fixes#78

Merged
kahrendt merged 6 commits into
mainfrom
pr/audio
Jul 9, 2026
Merged

Audio player lifecycle & threading fixes#78
kahrendt merged 6 commits into
mainfrom
pr/audio

Conversation

@kahrendt

@kahrendt kahrendt commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Fixes a set of correctness and threading issues in the audio player path, plus a host ring-buffer alignment bug.

  • SPSC ring buffer: round storage down to alignment. Unaligned storage_size_ desynced the writer/reader dummy-filler accounting at wrap points, causing data corruption or a permanent stall. create() now masks the usable size to an 8-byte multiple and rejects storage below one header + one aligned byte. Adds test_spsc_ring_buffer.cpp (fill/drain cycling across aligned and off-by-N sizes).
  • release_high_performance() underflow. Replaced the load-then-fetch_sub with a compare-exchange loop so two concurrent releases at count 1 can't both pass the zero-check and underflow the counter.
  • Player stream lifecycle races. Moved the high-performance acquire off the network thread to the main-loop STREAM_START drain, keeping the pairing flag and listener callbacks main-thread-only. Added a stream_active guard so on_stream_end() stays paired 1:1 with on_stream_start() (no spurious end from cleanup() or a failed start), and invalidated the stale sync_idle snapshot after a just-started stream so a same-batch STREAM_END waits for the task to drain.
  • SyncTask init guards. signal_stream_* and is_running() now early-return when init() never ran, preventing a null-handle crash from touching uncreated event flags on ESP.
  • Queue-full logging on the time, state, and stream-event queues.
  • Removed dead CHUNK_TYPE_DECODED_AUDIO, simplifying the decode-path chunk-type checks.
  • Docs: corrected volume range to 0–100, and documented that on_audio_write partial writes must be a whole number of PCM frames.

kahrendt added 4 commits July 9, 2026 09:45
With a storage size that was not a multiple of the 8-byte item
alignment, the wrap-around dummy filler stored its raw tail size while
the reader skipped the aligned size, desynchronizing the free-byte
accounting (data corruption); a tail smaller than ItemHeader made
try_acquire fail forever even on an empty buffer (permanent stall).
Round the usable size down to the alignment in create() so unaligned
tails can never exist, and reject storage too small for a single item.

The default audio_buffer_capacity happened to be a multiple of 8, so
default configs were unaffected; the ESP FreeRTOS-backed build was
never affected.

(cherry picked from commit 35a3fe8b5e5ce249c473deafd12c67f26d5c33c8)
(cherry picked from commit 45c484b767382925e3d4f064b8c7c6d9b7218ee7)
A player role with empty audio_formats or no listener constructs a
SyncTask but never calls init(), so its event flags are never created.
handle_stream_end/clear, cleanup(), and drain_events() still signal and
query the task unconditionally; on ESP those paths call
xEventGroupSetBits/GetBits with a null handle and crash on the first
disconnect or stream/end. Guard all signal/query entry points on
is_initialized().

Also remove CHUNK_TYPE_DECODED_AUDIO: nothing ever produced it, and
decode_chunk() fell through both the header and decode branches for it,
silently discarding the chunk while reporting SUCCESS.

(cherry picked from commit eb1307a37d7034f778450d5057948bed5e834c3c)
(cherry picked from commit cc61d8c4ec6757dd5cfb9cb1826b3a0629aeb089)
- Defer the playback high-performance acquire from the network thread's
  handle_stream_start to the main loop's STREAM_START drain, making
  high_performance_requested_for_playback main-thread-only and making
  the documented listener threading contract true for
  on_request_high_performance.
- Replace the load-then-fetch_sub in release_high_performance with a
  compare-exchange loop so two concurrent releases at count 1 cannot
  underflow the counter.
- Track stream_active across drained STREAM_START/STREAM_END events so
  on_stream_end() fires exactly once per on_stream_start(): cleanup()
  after a stream already ended (and a failed stream start) no longer
  produce spurious/duplicate on_stream_end callbacks.
- Invalidate the pre-loop sync_idle snapshot after processing a
  STREAM_START so a STREAM_END later in the same drain batch waits for
  the just-started sync task instead of firing immediately.
- Log when the stream/state/time event queues overflow instead of
  dropping events silently (a lost STREAM_START parks the sync task
  forever).

(cherry picked from commit de24d77ac801b9913b273b9853ff7d3b6a8e1e35)
(cherry picked from commit c735c5adeb73946714671d12b1fa11a13ea56299)
- get_volume() documented 0-255; the wire range is 0-100.
- The controller usage example sent VOLUME 128, outside the protocol's
  0-100 range (the reference server rejects it).
- Document that on_audio_write() partial writes must return whole-frame
  byte counts: the sync task derives the playtime estimate from the
  returned count, so a mid-frame value drifts sync and misaligns the
  next write.

(cherry picked from commit dd086d4d2ce40f6f386ec8761038279fed71e1e1)
(cherry picked from commit 925c82fa188508736fea724aee98b6b3a635ad30)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses correctness and threading/lifecycle issues across the audio playback pipeline, including SPSC ring-buffer wrap accounting, high-performance networking refcounting, and stream start/end callback pairing.

Changes:

  • Fix SPSC ring buffer usable-size alignment (round down to 8-byte multiples) and add regression tests covering wrap-around across off-by-N storage sizes.
  • Eliminate a potential release_high_performance() refcount underflow via a compare-exchange loop, and move high-performance acquisition into the main-loop stream-start drain.
  • Add SyncTask initialization guards to prevent null-handle crashes on ESP; improve queue-full logging; remove dead CHUNK_TYPE_DECODED_AUDIO; and update public docs for volume and PCM frame write requirements.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/test_spsc_ring_buffer.cpp Adds fill/drain regression tests targeting wrap-around accounting and minimum storage handling.
tests/CMakeLists.txt Registers the new SPSC ring buffer test in the test binary.
src/sync_task.h Guards is_running() for pre-init calls to avoid ESP event-flag crashes.
src/sync_task.cpp Adds init guards for stream signals and removes dead chunk-type checks in decode/drain logic.
src/player_role.cpp Defers high-performance acquire to main loop, enforces 1:1 stream start/end callback pairing, and adds queue-full logging.
src/player_role_impl.h Adds stream_active flag and declares enqueue_stream_event().
src/platform/spsc_ring_buffer.h Rounds down storage size to alignment and rejects too-small post-rounded buffers.
src/client.cpp Fixes potential high-performance refcount underflow; adds time-queue-full logging.
src/audio_types.h Removes CHUNK_TYPE_DECODED_AUDIO from internal chunk tags.
include/sendspin/player_role.h Documents PCM-frame-aligned partial writes and corrects volume range to 0–100.
include/sendspin/controller_role.h Updates volume example to the documented 0–100 range.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/player_role.cpp
Comment thread tests/test_spsc_ring_buffer.cpp Outdated
@kahrendt kahrendt enabled auto-merge (squash) July 9, 2026 13:59
@kahrendt kahrendt merged commit 09c7bfb into main Jul 9, 2026
5 checks passed
@kahrendt kahrendt deleted the pr/audio branch July 9, 2026 14:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants