Skip to content

Commit e0e1097

Browse files
committed
fix(audio): refill sink on loop end instead of repeat_infinite
rodio 0.17's `repeat_infinite()` wraps the source in `Buffered`, which retains every decoded sample in RAM so the loop can replay from cache. For long file-backed tracks (e.g. a multi-hour archived YouTube video) this grows unbounded — observed ~7 GB RSS on a ~6-hour video. For `PlaybackInput::File` repeating tracks, skip `repeat_infinite()` and add `AudioPlayer::try_refill_loop()`: when the sink drains, reopen the file, decode fresh, and append to the sink. `play_loop` polls this on each tick and bumps the real loop counter when a refill happens. Non-file repeating sources (SoundCloud / HypeM stream-first) still go through `repeat_infinite()` since a consumed network stream can't be cheaply re-opened, and keep their existing wall-clock loop estimate as a fallback branch.
1 parent f941341 commit e0e1097

2 files changed

Lines changed: 36 additions & 1 deletion

File tree

src/audio.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ pub struct AudioPlayer {
8585
pub sample_buf: Arc<Mutex<VecDeque<f32>>>,
8686
pub sample_rate: u32,
8787
pub channels: u16,
88+
input: PlaybackInput,
89+
refillable: bool,
8890
}
8991

9092
trait MediaReader: Read + Seek + Send + Sync {}
@@ -103,8 +105,14 @@ impl AudioPlayer {
103105
let sample_rate = source.sample_rate();
104106
let channels = source.channels();
105107

108+
// File-backed sources can be cheaply re-opened from disk, so we play
109+
// them once and refill the sink on each loop boundary instead of using
110+
// rodio's `repeat_infinite()` — which materializes the entire decoded
111+
// PCM in RAM via `Buffered`. For long tracks that grows unbounded.
112+
let refillable = repeat && matches!(input, PlaybackInput::File(_));
113+
106114
let buf = Arc::new(Mutex::new(VecDeque::with_capacity(BUF_CAP)));
107-
if repeat {
115+
if repeat && !refillable {
108116
let tapped = SampleTap {
109117
inner: source.repeat_infinite(),
110118
buf: buf.clone(),
@@ -127,6 +135,8 @@ impl AudioPlayer {
127135
sample_buf: buf,
128136
sample_rate,
129137
channels,
138+
input,
139+
refillable,
130140
})
131141
}
132142

@@ -141,6 +151,23 @@ impl AudioPlayer {
141151
pub fn skip(&self) {
142152
self.sink.stop();
143153
}
154+
155+
/// If this player loops a file-backed source and the sink has drained,
156+
/// reopen the file, decode a fresh source, and queue it. Returns true
157+
/// when a refill happened so the caller can advance its loop counter.
158+
pub fn try_refill_loop(&self) -> Result<bool> {
159+
if !self.refillable || !self.sink.empty() {
160+
return Ok(false);
161+
}
162+
let (reader, _, _) = open_input(&self.input)?;
163+
let source = decode_input(reader, &self.input)?.convert_samples::<f32>();
164+
let tapped = SampleTap {
165+
inner: source,
166+
buf: self.sample_buf.clone(),
167+
};
168+
self.sink.append(tapped);
169+
Ok(true)
170+
}
144171
}
145172

146173
fn decode_input(

src/play_loop.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,15 @@ fn run_loop(
346346
if player.sink.empty() {
347347
return Ok(LoopAction::NextTrack);
348348
}
349+
} else if player.try_refill_loop()? {
350+
state.loop_count += 1;
351+
state.loop_start = Instant::now();
352+
needs_render = true;
349353
} else if let Some(dur) = state.duration {
354+
// Fallback for non-file repeating sources (e.g. SoundCloud
355+
// stream-first) which still go through `repeat_infinite()`:
356+
// the sink never empties so we estimate loop boundaries from
357+
// wall-clock elapsed vs. known duration.
350358
if state.elapsed() >= dur {
351359
state.loop_count += 1;
352360
state.loop_start = Instant::now();

0 commit comments

Comments
 (0)