Repository navigation
Measure a hard sync from now after the sink starves - #198
Merged
Merged
Conversation
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.
Contributor
There was a problem hiding this comment.
🟢 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
Lost sync (19964581us off), inserted silence as long as the gap, and then dropped that much of the resumed audio as late.roles/player/v1.md "Large errors and startup" asks recovery from an underrun to snap to the chunk's timestamp in one shot.
Change
SyncTask::starved_playtime_floor(playtime, now): returnsnowwhen the estimate trails it by more thanHARD_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 syncpairs instead of one.Lost syncnow 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.