ommp.git

ommp

~ Oh My Music Player — a standalone terminal music player built with Rust and ratatui
git clone https://github.com/debuman/ommp.git

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 }