Skip to content

Measure a hard sync from now after the sink starves - #198

Merged
kahrendt merged 1 commit into
mainfrom
sync-starved-playtime
Oct 11, 2026
Merged

kahrendt merged 1 commit into
mainfrom
sync-starved-playtime

Conversation

@kahrendt

@kahrendt kahrendt commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The sync task's playtime estimate (when the next written frame will finish playing) moves only when it writes audio or the sink reports playback. In steady state a drained sink is not bridged with silence, and sinks report only the audio they were given (the host PortAudio example and ESPHome's I2S speaker both do), so after the sink runs dry mid-stream the estimate stays at the end of the last audio played. The next hard sync is measured against that stale endpoint:

  • Server timestamp gap longer than the send-ahead lead (a server keeping a live-input stream open across 20 s of silence): the client logged Lost sync (19964581us off), inserted silence as long as the gap, and then dropped that much of the resumed audio as late.
  • Stall with continuous timestamps (the sink ran out because audio arrived or decoded late): the first chunks after it look on time against the stale endpoint and play late uncorrected, up to a sink buffer's worth, until a playback report arrives and the next chunk drops the late prefix.

roles/player/v1.md "Large errors and startup" asks recovery from an underrun to snap to the chunk's timestamp in one shot.

Change

  • New SyncTask::starved_playtime_floor(playtime, now): returns now when the estimate trails it by more than HARD_SYNC_THRESHOLD_US, else the estimate.
  • handle_synchronize_audio() applies it before computing the error.

Frames not yet written cannot finish before now, so max(estimate, now) is never further from the truth than the estimate. The floor cannot fire while the sink still holds audio, so startup priming, seeks (the aligning keepalive keeps the estimate current) and steady-state sync are unchanged.

The load-time lateness check deliberately keeps the unfloored estimate: it skips whole chunks by their start time, so flooring there would discard partly-late chunks instead of dropping their late prefix.

The floored estimate does not include the sink's hidden latency (about 50 ms of DMA silence on ESPHome after an underrun). The existing re-check of the held chunk after the gap silence drops that remainder once a report arrives, so a stall on such a sink can log two Lost sync/Regained sync pairs instead of one. Lost sync now reports the corrected error (the lead, or the late amount) rather than the gap length.

Tests

  • PlayerHardSync.AStarvedEstimateIsFlooredAtNow: table test of the helper (ahead, exactly at the threshold, just over it, far behind).
  • PlayerHardSync.AfterAnUnderrunTheGapIsMeasuredFromNow: drives SYNCHRONIZE_AUDIO/TRANSFER_AUDIO without the thread; a chunk due 1 s from now against an estimate 20 s stale gets at most 1 s of silence, then plays whole.

The playtime estimate moves only when the sync task writes audio or the
sink reports playback. A drained sink is not bridged with silence in
steady state, and sinks report only the audio they were given, so after
the sink runs dry the estimate stays at the end of the last audio
played. The next hard sync measured against it: after a server
timestamp gap longer than the send-ahead lead it inserted silence as
long as the gap and then dropped that much of the resumed audio as
late; after a stall with continuous timestamps, the first chunks played
late uncorrected until a playback report arrived.

roles/player/v1.md "Large errors and startup" asks recovery from an
underrun to snap to the chunk's timestamp in one shot.
handle_synchronize_audio() now floors the estimate at now once it trails
now by more than HARD_SYNC_THRESHOLD_US (SyncTask::starved_playtime_floor()).
Frames not yet written cannot finish before now, so the floored value is
never further from the truth; the floor cannot fire while the sink holds
audio, so startup, seeks and steady-state sync are unchanged. The
load-time lateness check keeps the unfloored estimate: it skips whole
chunks by start time, and flooring there would discard partly-late
chunks.
@kahrendt
kahrendt marked this pull request as ready for review October 11, 2026 18:44
@kahrendt
kahrendt requested a balanced review from Copilot October 11, 2026 18:44

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.

🟢 Approval recommended

The implementation is focused, documented, and adequately covered by observable behavior tests.

0 open findings

What changed in this PR

Floors stale playback estimates at the current time after sink starvation, preventing oversized hard-sync corrections.

Changes:

  • Adds and applies starved_playtime_floor().
  • Adds focused unit and integration coverage.
  • Documents underrun recovery behavior.
File Description
src/​sync_task.cpp Applies and implements the playback-time floor.
src/​sync_task.h Declares and documents the helper.
tests/​test_player_role.cpp Tests threshold and underrun recovery behavior.
tests/​CMakeLists.txt Documents white-box test access.
docs/​playback-sync.md Describes starvation handling.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@kahrendt
kahrendt merged commit 27ea6a9 into main Oct 11, 2026
17 checks passed
@kahrendt
kahrendt deleted the sync-starved-playtime branch October 11, 2026 18:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants