Repository navigation
Skip a late chunk only when it is later than its own length - #243
theseal666 wants to merge 4 commits into
Conversation
`decode_chunk()` discards a whole encoded chunk once its playback time is more than `HARD_SYNC_THRESHOLD_US` (5 ms) behind the playtime estimate. The branch below it in `handle_synchronize_audio()` is written for exactly that chunk, and says why it is the better answer: > Chunk should have started playing already - drop only the late prefix from the front of the > buffer and play the remainder. [...] a partial drop keeps the follow-up disturbance smaller than > discarding the whole chunk would. A chunk 6 ms late never reaches it. It is discarded whole, and because nothing was written for its slot, the next chunk is a full chunk length ahead of the estimate and takes the silence branch instead. Six milliseconds of lateness costs 25 ms of audio plus 19 ms of inserted silence. ## Summary - **A bar of its own.** `LATE_CHUNK_SKIP_US` separates the two decisions `HARD_SYNC_THRESHOLD_US` was making at once. A chunk is discarded undecoded only when it is later than `MAX_AUDIO_CHUNK_MS`, the longest audio a chunk can decode to, since only then is every decoded frame certain to be dropped anyway. Everything less late goes to the existing prefix drop. - **A testable boundary.** `chunk_is_unplayably_late()` is a static helper in the shape of `starved_playtime_floor()` from Sendspin#198, so the bar is pinned by a table-driven test instead of being reached through a decode. - **Docs.** The LOAD_CHUNK bullet in `docs/playback-sync.md` described the old bar and is updated with it. ## Measured on hardware An ESP32 player against a source delivering in exact real time, which is what any internet radio is: the server has no margin to build a lead with, so chunks land a few milliseconds late. Same firmware, same source, same 180 s arm, 25 ms chunks. | | before | after | |---|---|---| | chunks discarded whole | 91 | **0** | | sync losses | 92, all positive | 69, 68 of them negative | | sync error | median +18.5 ms | median −5.8 ms | | late prefixes dropped | — | 93, median 5.5 ms | | silence inserted into the stream | 92 gaps | **1 gap** | | audio discarded | 2275 ms, **1.26 %** | 482 ms, **0.27 %** | | sink starvation / URB rate | 0 / 993 | 0 / 998 | Before the change every sync loss was one discarded chunk: 90 of the 92 had a discard immediately before them, and the reported error matched `n × 25 ms + lateness` with a median deviation of 0 µs, within ±1 ms in 88 of 90. A microphone in front of the DAC measured 1.1 % of the audio missing, against the 1.26 % the counters predicted. ## Notes - The event rate is unchanged, 31 per minute in both arms. What triggers each event is the source's missing delivery margin, and that is not addressed here; this only stops one late chunk from costing a whole chunk plus a gap. - Host suite: 715/715 under ASan/UBSan. I was not able to run the TSan and no-Opus configurations locally, so those are left to CI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks, overall this is a good thing to add (I honestly thought it was already in the codebase; turns out I did a hacky fix in one of my own tests branches and forgot it never made it to main)! 1. Use the previous chunk's decoded length as the bar instead of Suggested shape:
bool SyncTask::chunk_is_unplayably_late(int64_t client_timestamp, int64_t expected_duration_us,
int64_t playtime_us) {
const int64_t duration_us = expected_duration_us > 0
? expected_duration_us
: static_cast<int64_t>(MAX_AUDIO_CHUNK_MS) * US_PER_MS;
return client_timestamp + duration_us < playtime_us;
}Rows to add to the table test:
2. The prefix-drop comment becomes stale. 3. Trim the comments.
|
Review of Sendspin#243. Skipping without decoding should skip only when every frame would be dropped anyway, and a fixed MAX_AUDIO_CHUNK_MS bar is far wider than that: a 25 ms chunk that is 30 to 150 ms late was still decoded and then dropped whole by the prefix drop, so catching up after a stall cost about six wasted decodes. A chunk's length is not known before decoding it, but chunks within a stream are nearly the same length, so the previous one predicts it, and a wrong prediction costs only the one decode it was spent on. - SyncContext::last_chunk_duration_us holds what the previous chunk of the stream decoded to, taken from the decode buffer after decode_whole_chunk() succeeds. The buffer is empty on entry, so that is exactly this chunk's output, already capped at MAX_AUDIO_CHUNK_MS. - reset_context() clears it: a new stream may use a different chunk length. Until one chunk has decoded, chunk_is_unplayably_late() falls back to MAX_AUDIO_CHUNK_MS, which costs at most one extra decode per stream and never wrongly skips the first chunk. - LATE_CHUNK_SKIP_US is gone. The bar is now per-chunk, so the constant held nothing but that fallback, which lives in the helper. - The table test takes the estimate: a 25 ms chunk is decoded at 24 ms late and skipped at 26 ms, and with no estimate yet 100 ms late is decoded while just over 150 ms is skipped. The control and hard-sync-threshold rows stay. Also from the review: the prefix drop's comment said silence had already been inserted by the time it ran, which was true when only a chunk later than 150 ms reached it. The usual way in is now a chunk a few milliseconds late with nothing inserted ahead of it, so it says both. The constant's doc, the duplicated header doc and the inline comment the helper's name already carries are gone. docs/playback-sync.md follows the bar. Host suite: 714/714 in one process under ASan/UBSan. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
All three taken, in 65668ba. 1. The previous chunk's decoded length as the bar. You're right, and the waste is worse than the count suggests: the decode that gets thrown away is the expensive half of the work, and it happens exactly when the device is already behind. Implemented as you laid it out —
The table test takes the estimate now — 25 ms estimate decodes at 24 ms late and skips at 26 ms, no estimate decodes at 100 ms and skips just past 150 ms, controls and the 5 ms row kept. One thing worth stating explicitly about the hardware numbers in the description, since I can't re-measure right now: that arm was run with the fixed 150 ms bar, and I believe it is audio-identical under the new one rather than merely close. A chunk between one chunk length and 150 ms late took the prefix drop, which drained the buffer and returned to 2. The prefix-drop comment. Reworded. It now says both cases, because both are real: the usual entry is a chunk a few milliseconds late with nothing inserted ahead of it, and the post-underrun entry still has silence filling the gap first. The line about the partial drop being the smaller disturbance applies to both and stays. 3. Comments trimmed. The constant's doc went with the constant. The helper's header doc no longer restates it — it says what the predicate means and where the length comes from, nothing more. The inline
On tests: the host suite is 714/714 under ASan/UBSan, run as one process. Worth knowing if you ever see it yourself — in a container, |
Sendspin#255 made SyncContext::decode_buffer a TransferBuffer rather than a unique_ptr to one. The merge above kept this branch's -> through, since the two changes do not touch the same lines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Correction to my previous comment: CI failed on that push, and the cause was mine. The branch was 14 commits behind Fixed in 12517d2. The suite now passes against current |
decode_chunk()discards a whole encoded chunk once its playback time is more thanHARD_SYNC_THRESHOLD_US(5 ms) behind the playtime estimate. The branch below it inhandle_synchronize_audio()is written for exactly that chunk, and says why it is the betteranswer:
A chunk 6 ms late never reaches it. It is discarded whole, and because nothing was written for its
slot, the next chunk is a full chunk length ahead of the estimate and takes the silence branch
instead. Six milliseconds of lateness costs 25 ms of audio plus 19 ms of inserted silence.
Summary
HARD_SYNC_THRESHOLD_USwas making at once areseparated. A chunk is discarded undecoded only when it is later than its own length, since only
then is every frame it would decode to certain to be dropped anyway. Everything less late goes to
the existing prefix drop, which keeps the playable remainder.
knowable before decoding it, but chunks within a stream are nearly the same length, so
last_chunk_duration_uscarries what the previous one decoded to. A wrong prediction is harmless:a chunk that turns out to be entirely late is dropped whole by
handle_synchronize_audio()at thecost of one decode. Until one chunk of the stream has decoded,
MAX_AUDIO_CHUNK_MSstands in,which costs at most one extra decode per stream and never wrongly skips the first chunk. The
alternative — a fixed 150 ms bar — still decoded a 25 ms chunk that was 30 to 150 ms late and then
threw the result away, so catching up after a stall cost about six wasted decodes.
chunk_is_unplayably_late()is a static helper in the shape ofstarved_playtime_floor()from Measure a hard sync from now after the sink starves #198, so the bar is pinned by a table-driven test instead of beingreached through a decode.
docs/playback-sync.mddescribed the old bar and is updated,including the prediction and what a wrong one costs. The prefix drop's own comment claimed silence
had always been inserted by the time it ran, which stops being the usual case here, and now
describes both routes in.
Measured on hardware
An ESP32 player against a source delivering in exact real time, which is what any internet radio
is: the server has no margin to build a lead with, so chunks land a few milliseconds late. Same
firmware, same source, same 180 s arm, 25 ms chunks.
Before the change every sync loss was one discarded chunk: 90 of the 92 had a discard immediately
before them, and the reported error matched
n × 25 ms + latenesswith a median deviation of 0 µs,within ±1 ms in 88 of 90. A microphone in front of the DAC measured 1.1 % of the audio missing,
against the 1.26 % the counters predicted.
This arm was run with the fixed 150 ms bar, before review moved the bar to the chunk's own
length, and it has not been re-measured. It should be audio-identical under the per-chunk bar
rather than merely close: a chunk between one chunk length and 150 ms late took the prefix drop,
which drained the buffer and returned to
LOAD_CHUNK, so the whole chunk was dropped either way —the difference is only whether a decode was paid for first. The per-chunk bar should therefore move
decode counts, not any of the numbers above.
Notes
missing delivery margin, and that is not addressed here; this only stops one late chunk from
costing a whole chunk plus a gap.
no-Opus configurations locally, so those are left to CI. Note that driving the suite as 715
separate ASan processes under
ctestproduced spurious SEGFAULTs in my container; each one passesstandalone and under
ctest -R.🤖 Generated with Claude Code