src/audio/player.rs (12127B)
1 use anyhow::Result;
2 use crossbeam_channel::{Receiver, Sender, select, tick};
3 use rodio::buffer::SamplesBuffer;
4 use rodio::mixer::Mixer;
5 use rodio::{Decoder, OutputStreamBuilder, Sink, Source};
6 use std::fs::File;
7 use std::io::BufReader;
8 use std::path::{Path, PathBuf};
9 use std::time::{Duration, Instant};
10 use symphonia::core::audio::SampleBuffer;
11 use symphonia::core::codecs::DecoderOptions;
12 use symphonia::core::formats::FormatOptions;
13 use symphonia::core::io::MediaSourceStream;
14 use symphonia::core::meta::MetadataOptions;
15 use symphonia::core::probe::Hint;
16
17 use crate::event::{AudioEvent, Event};
18
19 #[derive(Debug, Clone)]
20 pub enum PlayerCommand {
21 Play(PathBuf),
22 Pause,
23 Resume,
24 Stop,
25 SetVolume(f32),
26 Seek(f64),
27 }
28
29 pub struct AudioEngine {
30 cmd_tx: Sender<PlayerCommand>,
31 _handle: std::thread::JoinHandle<()>,
32 }
33
34 impl AudioEngine {
35 pub fn new(event_tx: Sender<Event>) -> Result<Self> {
36 let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded();
37
38 let handle = std::thread::spawn(move || {
39 player_thread(cmd_rx, event_tx);
40 });
41
42 Ok(Self {
43 cmd_tx,
44 _handle: handle,
45 })
46 }
47
48 pub fn send(&self, cmd: PlayerCommand) {
49 let _ = self.cmd_tx.send(cmd);
50 }
51 }
52
53 /// One flat loop over every command, with the sink and the playback clock held
54 /// as plain locals.
55 ///
56 /// This used to be two loops: an idle one that matched only Play and Stop and
57 /// threw the rest away (`Ok(_) => {}`), and a playing one that recursed into
58 /// itself on every track change. That cost a stack frame and a still-connected
59 /// `Sink` per track, and — because volume lived only inside the playing loop —
60 /// every track that ended on its own started the next one at rodio's default
61 /// full volume.
62 fn player_thread(cmd_rx: Receiver<PlayerCommand>, event_tx: Sender<Event>) {
63 let stream = match OutputStreamBuilder::open_default_stream() {
64 Ok(s) => s,
65 Err(e) => {
66 let _ = event_tx.send(Event::Audio(AudioEvent::DeviceError(e.to_string())));
67 return;
68 }
69 };
70
71 let mixer = stream.mixer().clone();
72 let position_ticker = tick(Duration::from_millis(250));
73
74 let mut sink: Option<Sink> = None;
75 // Survives across tracks and idle periods; applied to every sink we build.
76 let mut volume: f32 = 1.0;
77 let mut duration: f64 = 0.0;
78 let mut play_start: Option<Instant> = None;
79 let mut accumulated_secs: f64 = 0.0;
80 let mut is_paused = false;
81
82 loop {
83 select! {
84 recv(cmd_rx) -> msg => {
85 match msg {
86 Ok(PlayerCommand::Play(path)) => {
87 if let Some(old) = sink.take() {
88 old.stop();
89 }
90 accumulated_secs = 0.0;
91 is_paused = false;
92 match open_and_play(&mixer, &path, volume) {
93 Ok((new_sink, new_duration)) => {
94 sink = Some(new_sink);
95 duration = new_duration;
96 play_start = Some(Instant::now());
97 let _ = event_tx.send(Event::Audio(AudioEvent::Playing));
98 }
99 Err(_) => {
100 duration = 0.0;
101 play_start = None;
102 let _ = event_tx.send(Event::Audio(AudioEvent::TrackError));
103 }
104 }
105 }
106 Ok(PlayerCommand::Pause) => {
107 if let Some(ref s) = sink {
108 if !is_paused {
109 s.pause();
110 if let Some(start) = play_start.take() {
111 accumulated_secs += start.elapsed().as_secs_f64();
112 }
113 is_paused = true;
114 let _ = event_tx.send(Event::Audio(AudioEvent::Paused));
115 }
116 }
117 }
118 Ok(PlayerCommand::Resume) => {
119 if let Some(ref s) = sink {
120 if is_paused {
121 s.play();
122 play_start = Some(Instant::now());
123 is_paused = false;
124 let _ = event_tx.send(Event::Audio(AudioEvent::Playing));
125 }
126 }
127 }
128 Ok(PlayerCommand::Stop) => {
129 if let Some(old) = sink.take() {
130 old.stop();
131 }
132 play_start = None;
133 accumulated_secs = 0.0;
134 is_paused = false;
135 let _ = event_tx.send(Event::Audio(AudioEvent::Stopped));
136 }
137 Ok(PlayerCommand::SetVolume(vol)) => {
138 // Remembered even with nothing playing, so the volume
139 // restored at startup applies to the first track.
140 volume = vol.clamp(0.0, 1.0);
141 if let Some(ref s) = sink {
142 s.set_volume(volume);
143 }
144 }
145 Ok(PlayerCommand::Seek(secs)) => {
146 if let Some(ref s) = sink {
147 if s.try_seek(Duration::from_secs_f64(secs)).is_ok() {
148 accumulated_secs = secs;
149 if !is_paused {
150 play_start = Some(Instant::now());
151 }
152 }
153 }
154 }
155 Err(_) => break,
156 }
157 }
158 recv(position_ticker) -> _ => {
159 let finished = sink.as_ref().is_some_and(|s| s.empty()) && !is_paused;
160 if finished {
161 sink = None;
162 play_start = None;
163 accumulated_secs = 0.0;
164 let _ = event_tx.send(Event::Audio(AudioEvent::TrackFinished));
165 } else if sink.is_some() {
166 let pos = if is_paused {
167 accumulated_secs
168 } else if let Some(start) = play_start {
169 accumulated_secs + start.elapsed().as_secs_f64()
170 } else {
171 accumulated_secs
172 };
173 // Only clamp when the decoder actually reported a length —
174 // VBR MP3s without a Xing header give 0.0, and clamping to
175 // that pins the elapsed time at 0:00 for the whole track.
176 let position_secs = if duration > 0.0 { pos.min(duration) } else { pos };
177 let _ = event_tx.send(Event::Audio(AudioEvent::PositionUpdate {
178 position_secs,
179 duration_secs: duration,
180 }));
181 }
182 }
183 }
184 }
185 }
186
187 fn open_and_play(mixer: &Mixer, path: &PathBuf, volume: f32) -> Result<(Sink, f64), String> {
188 let ext = path
189 .extension()
190 .and_then(|e| e.to_str())
191 .map(|e| e.to_lowercase())
192 .unwrap_or_default();
193
194 // First try rodio's Decoder
195 if let Ok(file) = File::open(path) {
196 let reader = BufReader::new(file);
197 if let Ok(source) = Decoder::new(reader) {
198 let duration = Source::total_duration(&source)
199 .map(|d| d.as_secs_f64())
200 .unwrap_or(0.0);
201 return Ok((start_sink(mixer, source, volume), duration));
202 }
203 }
204
205 // Rodio failed — try extension-specific rodio decoders
206 if let Ok(file) = File::open(path) {
207 let reader = BufReader::new(file);
208 let result = match ext.as_str() {
209 "mp3" => Decoder::new_mp3(reader).ok(),
210 "flac" => Decoder::new_flac(reader).ok(),
211 "wav" => Decoder::new_wav(reader).ok(),
212 "ogg" => Decoder::new_vorbis(reader).ok(),
213 _ => None,
214 };
215 if let Some(source) = result {
216 let duration = Source::total_duration(&source)
217 .map(|d| d.as_secs_f64())
218 .unwrap_or(0.0);
219 return Ok((start_sink(mixer, source, volume), duration));
220 }
221 }
222
223 // Fall back to symphonia direct decoding for m4a/mp4/etc
224 decode_with_symphonia(mixer, path, volume)
225 }
226
227 /// Build a playing sink at the player's current volume. A fresh `Sink` starts at
228 /// rodio's default 1.0, so skipping the `set_volume` here is what made every
229 /// naturally-ended track hand off to the next one at full blast.
230 fn start_sink<S>(mixer: &Mixer, source: S, volume: f32) -> Sink
231 where
232 S: Source + Send + 'static,
233 {
234 let sink = Sink::connect_new(mixer);
235 sink.set_volume(volume);
236 sink.append(source);
237 sink.play();
238 sink
239 }
240
241 /// Decode using symphonia directly, buffer the entire track, and play via rodio Sink.
242 fn decode_with_symphonia(mixer: &Mixer, path: &Path, volume: f32) -> Result<(Sink, f64), String> {
243 let file = File::open(path).map_err(|e| format!("Open: {}", e))?;
244 let mss = MediaSourceStream::new(Box::new(file), Default::default());
245
246 let mut hint = Hint::new();
247 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
248 hint.with_extension(ext);
249 }
250
251 let probed = symphonia::default::get_probe()
252 .format(
253 &hint,
254 mss,
255 &FormatOptions::default(),
256 &MetadataOptions::default(),
257 )
258 .map_err(|e| format!("Probe: {}", e))?;
259
260 let mut format = probed.format;
261 let track = format
262 .tracks()
263 .iter()
264 .find(|t| t.codec_params.codec != symphonia::core::codecs::CODEC_TYPE_NULL)
265 .ok_or_else(|| "No audio track found".to_string())?;
266
267 let track_id = track.id;
268 let codec_params = track.codec_params.clone();
269 let sample_rate = codec_params.sample_rate.unwrap_or(44100);
270 let channels = codec_params.channels.map(|c| c.count()).unwrap_or(2) as u16;
271
272 // Calculate duration from track params
273 let duration_secs = codec_params
274 .n_frames
275 .map(|n| n as f64 / sample_rate as f64)
276 .or_else(|| {
277 codec_params
278 .time_base
279 .and_then(|tb| codec_params.n_frames.map(|n| tb.calc_time(n).seconds as f64))
280 })
281 .unwrap_or(0.0);
282
283 let mut decoder = symphonia::default::get_codecs()
284 .make(&codec_params, &DecoderOptions::default())
285 .map_err(|e| format!("Codec: {}", e))?;
286
287 let mut all_samples: Vec<f32> = Vec::new();
288
289 loop {
290 let packet = match format.next_packet() {
291 Ok(p) => p,
292 Err(symphonia::core::errors::Error::IoError(ref e))
293 if e.kind() == std::io::ErrorKind::UnexpectedEof =>
294 {
295 break;
296 }
297 Err(_) => break,
298 };
299
300 if packet.track_id() != track_id {
301 continue;
302 }
303
304 let decoded = match decoder.decode(&packet) {
305 Ok(d) => d,
306 Err(_) => continue,
307 };
308
309 let spec = *decoded.spec();
310 let num_frames = decoded.frames();
311 let mut sample_buf = SampleBuffer::<f32>::new(num_frames as u64, spec);
312 sample_buf.copy_interleaved_ref(decoded);
313 all_samples.extend_from_slice(sample_buf.samples());
314 }
315
316 if all_samples.is_empty() {
317 return Err("No audio data decoded".to_string());
318 }
319
320 let buffer = SamplesBuffer::new(channels, sample_rate, all_samples);
321 let actual_duration = Source::total_duration(&buffer)
322 .map(|d| d.as_secs_f64())
323 .unwrap_or(duration_secs);
324
325 Ok((start_sink(mixer, buffer, volume), actual_duration))
326 }