Skip to content

Read ahead so a stalled source cannot speed up playback - #28

Merged
Isma-L154 merged 1 commit into
mainfrom
fix/read-ahead-buffer
Aug 23, 2026
Merged

Read ahead so a stalled source cannot speed up playback#28
Isma-L154 merged 1 commit into
mainfrom
fix/read-ahead-buffer

Conversation

@Isma-L154

Copy link
Copy Markdown
Owner

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:

next_time = self._start + DELAY * self.loops
delay = max(0, DELAY + (next_time - time.perf_counter()))
time.sleep(delay)

When source.read() blocks — FFmpeg waiting on bytes from yt-dlp — the player falls behind that schedule. next_time is then in the past, delay clamps to zero, and it sends frames as fast as it can until it catches up.

That burst is what you hear.

FFmpegPCMAudio reads 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

BufferedAudioSource keeps 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

FFmpegPCMAudio  ->  BufferedAudioSource  ->  PCMVolumeTransformer

The read-ahead wraps FFmpeg, which is the thing that stalls. PCMVolumeTransformer stays outermost because MusicPlayer.set_volume does:

if vc and vc.source and isinstance(vc.source, discord.PCMVolumeTransformer):

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_outermost guards it.

Verification

Against real YouTube audio on the production host, consuming at realtime pace:

cadena: PCMVolumeTransformer -> BufferedAudioSource -> FFmpegPCMAudio
capacidad del buffer: 250 tramas (5.0s)
tramas listas tras 6s: 250

lectura mas lenta   : 0.2 ms   (limite antes de acelerar: 20 ms)
desfase acumulado   : +0 ms

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 passed overall.

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.

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
Isma-L154 merged commit 09544ae into main Aug 23, 2026
3 checks passed
@Isma-L154
Isma-L154 deleted the fix/read-ahead-buffer branch August 23, 2026 05:53
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.
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.

Tracks briefly play at high speed after a momentary source stall

1 participant