Read ahead so a stalled source cannot speed up playback - #28
Merged
Conversation
Tracks occasionally sped up for a moment and then settled. discord.py's player paces itself against a wall clock: it computes when frame N is due and sleeps the difference. When source.read() blocks - FFmpeg waiting on bytes from yt-dlp - it falls behind that schedule, the sleep clamps to zero, and it sends frames as fast as it can until it catches up. That burst is what is audible. FFmpegPCMAudio reads straight from the subprocess pipe with no buffering beyond the pipe itself, so any gap in yt-dlp's delivery reaches the player directly. Average throughput was never the problem: ~112 KB/s sustained against ~20 KB/s needed. The problem is momentary gaps, which YouTube produces once it starts throttling a long download. BufferedAudioSource keeps 5 seconds of PCM ready in a background thread. The queue is bounded - an unbounded one in front of a fast source would pull a whole track into RAM and undo the backpressure the yt-dlp pipe exists to provide. Order in the chain matters: the read-ahead wraps FFmpeg, which is what stalls, and PCMVolumeTransformer stays outermost because MusicPlayer.set_volume does an isinstance check on voice_client.source. Wrapping the buffer outside would have silently broken volume control; there is a test for it. Verified against real YouTube audio on the host, consuming at realtime pace: slowest read 0.2 ms against the 20 ms budget, and zero accumulated drift. Closes #26
Isma-L154
added a commit
that referenced
this pull request
Aug 23, 2026
Regression from #28. read() returned b"" when the buffer had not filled within a second, which discord.py reads as end-of-track - and yt-dlp needs 3-8s for its first byte, so every track ended immediately. An empty buffer is not the end; only the producer's sentinel or a dead producer is. prime() now fills the buffer before playback starts, because discord.py begins its clock before its first read and would otherwise burst frames to catch up. Verified on the host across search, SoundCloud and YouTube URL.
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.
Closes #26
The symptom
Tracks occasionally speed up for a moment and then settle — reported from real use as "a veces reproduce algunas canciones rápido y después vuelve a la normalidad."
The mechanism
discord.py's player paces itself against a wall clock:When
source.read()blocks — FFmpeg waiting on bytes from yt-dlp — the player falls behind that schedule.next_timeis then in the past,delayclamps to zero, and it sends frames as fast as it can until it catches up.That burst is what you hear.
FFmpegPCMAudioreads straight from the subprocess pipe with no buffering beyond the pipe itself, so any gap in yt-dlp's delivery propagates directly into the player's timing.Why bandwidth was not the answer
Sustained throughput measured at ~112 KB/s against the ~20 KB/s realtime needs — about 6x headroom. Average rate was never the problem. The problem is momentary gaps, which YouTube produces once it starts throttling a long download after the initial burst.
The fix
BufferedAudioSourcekeeps 5 seconds of PCM ready, filled by a background thread. A stall shorter than the buffer never reaches the player.The queue is bounded. An unbounded one in front of a fast source would pull an entire track into RAM and undo the backpressure that the yt-dlp pipe exists to provide — the same property #6 was careful to preserve. 5 s is 250 frames, ~960 KB.
Order in the chain is load-bearing
The read-ahead wraps FFmpeg, which is the thing that stalls.
PCMVolumeTransformerstays outermost becauseMusicPlayer.set_volumedoes:Wrapping the buffer around the outside would have silently broken
!volume— no error, just a command that stops doing anything.test_the_volume_transformer_stays_outermostguards it.Verification
Against real YouTube audio on the production host, consuming at realtime pace:
Slowest read was 0.2 ms against a 20 ms budget, with zero accumulated drift — exactly the condition under which the catch-up burst cannot happen.
Unit tests (15 new) drive sources that stall deliberately: a 1-second stall is absorbed with reads completing in under 0.2 s total; frames arrive in order with none dropped; the buffer stays bounded and the producer does not run past the cap;
cleanup()stops the thread and reaches the wrapped source; a source that raises ends playback instead of hanging; and no reader threads are left behind across repeated construction.258 passedoverall.Note on confidence
The mechanism is derived from
discord.py's player source and matches the reported symptom precisely, but the symptom was not reproduced live — it is intermittent and depends on YouTube's throttling. The change is low risk and independently justified (a stalling audio pipeline is worth insulating regardless). If speed-ups persist after this lands, the cause is elsewhere and worth a fresh issue.