From ad366b2355e35dbfb0b99faccdb978f21162d66f Mon Sep 17 00:00:00 2001 From: Tsiry Sandratraina Date: Sun, 5 Jul 2026 23:24:41 +0300 Subject: [PATCH] Support multiple audio formats via symphonia Replace the minimp3-based Mp3Decoder with a symphonia StreamDecoder that probes the stream container/codec using the Content-Type header as a hint. Icecast stations in AAC/AAC+ (ADTS), Ogg Vorbis, FLAC (raw and in Ogg), WAV, and ALAC now play, in addition to MP3. - Skip malformed packets at stream start instead of failing - Define our own Frame type for the visualizer, replacing minimp3::Frame - Remove an unconsumed frame channel and unused decoder field in player.rs - Drop minimp3; disable rodio default features (only Sink/OutputStream are used), removing minimp3 from the dependency tree entirely - Add ignored network tests decoding live MP3/AAC/Ogg-FLAC streams (cargo test -- --ignored) --- Cargo.lock | 150 +++-------------------------- Cargo.toml | 5 +- src/app.rs | 5 +- src/audio.rs | 8 +- src/decoder.rs | 256 ++++++++++++++++++++++++++++++++++++++----------- src/play.rs | 13 ++- src/player.rs | 13 +-- 7 files changed, 238 insertions(+), 212 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 24fb365..ef51355 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -724,12 +724,6 @@ dependencies = [ "os_str_bytes", ] -[[package]] -name = "claxon" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bfbf56724aa9eca8afa4fcfadeb479e722935bb2a0900c2d37e0cc477af0688" - [[package]] name = "cocoa" version = "0.24.1" @@ -910,7 +904,7 @@ dependencies = [ "once_cell", "parking_lot", "stdweb", - "thiserror 1.0.69", + "thiserror", "web-sys", "windows 0.37.0", ] @@ -1529,7 +1523,7 @@ dependencies = [ "ipnet", "once_cell", "rand 0.8.5", - "thiserror 1.0.69", + "thiserror", "tinyvec", "tracing", "url 2.5.4", @@ -1551,7 +1545,7 @@ dependencies = [ "rand 0.8.5", "resolv-conf", "smallvec", - "thiserror 1.0.69", + "thiserror", "tracing", ] @@ -1586,12 +1580,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "hound" -version = "3.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" - [[package]] name = "http" version = "0.2.12" @@ -2063,7 +2051,7 @@ dependencies = [ "combine", "jni-sys", "log", - "thiserror 1.0.69", + "thiserror", "walkdir", ] @@ -2107,17 +2095,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "lewton" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "777b48df9aaab155475a83a7df3070395ea1ac6902f5cd062b8f2b028075c030" -dependencies = [ - "byteorder", - "ogg", - "tinyvec", -] - [[package]] name = "lexical-core" version = "0.7.6" @@ -2256,15 +2233,6 @@ dependencies = [ "libc", ] -[[package]] -name = "mach2" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" -dependencies = [ - "libc", -] - [[package]] name = "malloc_buf" version = "0.0.6" @@ -2329,46 +2297,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" -[[package]] -name = "minimp3" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985438f75febf74c392071a975a29641b420dd84431135a6e6db721de4b74372" -dependencies = [ - "minimp3-sys 0.3.2", - "slice-deque", - "thiserror 1.0.69", -] - -[[package]] -name = "minimp3" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c684a06335e480df3f2aa78970d0d470835716ae5867d5dadb71ef9f4413572d" -dependencies = [ - "minimp3-sys 0.6.0", - "slice-ring-buffer", - "thiserror 2.0.14", -] - -[[package]] -name = "minimp3-sys" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e21c73734c69dc95696c9ed8926a2b393171d98b3f5f5935686a26a487ab9b90" -dependencies = [ - "cc", -] - -[[package]] -name = "minimp3-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa16a1fbac806d94e230e0a6b9b5a137d051897a188944cf07645da4b73a9fae" -dependencies = [ - "cc", -] - [[package]] name = "miniz_oxide" version = "0.8.3" @@ -2417,7 +2345,7 @@ dependencies = [ "jni-sys", "ndk-sys 0.3.0", "num_enum", - "thiserror 1.0.69", + "thiserror", ] [[package]] @@ -2431,7 +2359,7 @@ dependencies = [ "ndk-sys 0.4.1+23.1.7779620", "num_enum", "raw-window-handle", - "thiserror 1.0.69", + "thiserror", ] [[package]] @@ -2608,15 +2536,6 @@ dependencies = [ "cc", ] -[[package]] -name = "ogg" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6951b4e8bf21c8193da321bcce9c9dd2e13c858fe078bf9054a288b419ae5d6e" -dependencies = [ - "byteorder", -] - [[package]] name = "once_cell" version = "1.20.2" @@ -3068,7 +2987,7 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom 0.2.15", "libredox 0.1.10", - "thiserror 1.0.69", + "thiserror", ] [[package]] @@ -3187,11 +3106,7 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb10b653d5ec0e9411a2e7d46e2c7f4046fd87d35b9955bd73ba4108d69072b5" dependencies = [ - "claxon", "cpal", - "hound", - "lewton", - "minimp3 0.5.1", ] [[package]] @@ -3424,7 +3339,7 @@ checksum = "c7715380eec75f029a4ef7de39a9200e0a63823176b759d055b613f5a87df6a6" dependencies = [ "percent-encoding 2.3.1", "serde", - "thiserror 1.0.69", + "thiserror", ] [[package]] @@ -3512,28 +3427,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "slice-deque" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31ef6ee280cdefba6d2d0b4b78a84a1c1a3f3a4cec98c2d4231c8bc225de0f25" -dependencies = [ - "libc", - "mach", - "winapi", -] - -[[package]] -name = "slice-ring-buffer" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84ae312bda09b2368f79f985fdb4df4a0b5cbc75546b511303972d195f8c27d6" -dependencies = [ - "libc", - "mach2", - "winapi", -] - [[package]] name = "smallvec" version = "1.13.2" @@ -3574,7 +3467,7 @@ dependencies = [ "dbus-crossroads", "dispatch", "objc", - "thiserror 1.0.69", + "thiserror", "windows 0.44.0", ] @@ -4028,16 +3921,7 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b0949c3a6c842cbde3f1686d6eea5a010516deb7085f79db747562d4102f41e" -dependencies = [ - "thiserror-impl 2.0.14", + "thiserror-impl", ] [[package]] @@ -4051,17 +3935,6 @@ dependencies = [ "syn 2.0.96", ] -[[package]] -name = "thiserror-impl" -version = "2.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc5b44b4ab9c2fdd0e0512e6bece8388e214c0749f5862b114cc5b7a25daf227" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.96", -] - [[package]] name = "time" version = "0.2.27" @@ -4419,7 +4292,6 @@ dependencies = [ "futures-util", "hyper 0.14.32", "m3u", - "minimp3 0.6.0", "owo-colors", "pls", "prost", @@ -4435,7 +4307,7 @@ dependencies = [ "surf", "symphonia", "termion", - "thiserror 1.0.69", + "thiserror", "tokio", "tonic", "tonic-build", diff --git a/Cargo.toml b/Cargo.toml index 8c1732b..e053330 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,7 +43,6 @@ hyper = { version = "0.14.23", features = [ "http2", ] } m3u = "1.0.0" -minimp3 = "0.6" owo-colors = "3.5.0" directories = "5.0.1" pls = "0.2.2" @@ -57,7 +56,7 @@ reqwest = { version = "0.11.14", features = [ "blocking", "rustls-tls", ], default-features = false } -rodio = { version = "0.16" } +rodio = { version = "0.16", default-features = false } rustfft = "6.2.0" serde = { version = "1.0.197", features = ["derive"] } serde_json = "1.0.117" @@ -70,6 +69,8 @@ symphonia = { version = "0.5.1", features = [ "mp3", "isomp4", "flac", + "ogg", + "vorbis", ] } termion = "2.0.1" thiserror = "1.0.58" diff --git a/src/app.rs b/src/app.rs index a4b2d74..fe390c4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,6 +15,7 @@ use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; use tunein_cli::os_media_controls::{self, OsMediaControls}; use crate::{ + decoder::Frame as AudioFrame, extract::get_currently_playing, input::stream_to_matrix, play::SinkCommand, @@ -144,7 +145,7 @@ pub struct App { vectorscope: Vectorscope, spectroscope: Spectroscope, mode: CurrentDisplayMode, - frame_rx: Receiver, + frame_rx: Receiver, /// [`OsMediaControls`]. os_media_controls: Option, /// Poll for events every specified [`Duration`]. @@ -165,7 +166,7 @@ impl App { pub fn new( ui: &crate::cfg::UiOptions, source: &crate::cfg::SourceOptions, - frame_rx: Receiver, + frame_rx: Receiver, mode: CurrentDisplayMode, os_media_controls: Option, poll_events_every: Duration, diff --git a/src/audio.rs b/src/audio.rs index 7bc3888..b2fcd25 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -7,7 +7,7 @@ use hyper::header::HeaderValue; use rodio::{OutputStream, OutputStreamHandle, Sink}; use tokio::sync::mpsc; -use crate::decoder::Mp3Decoder; +use crate::decoder::StreamDecoder; use crate::types::Station; /// Commands sent to the audio worker thread. @@ -170,13 +170,13 @@ impl AudioWorker { let bitrate = header_to_string(headers.get("icy-br")).unwrap_or_default(); let response = follow_redirects(client, response)?; + let content_type = header_to_string(response.headers().get("content-type")); let sink = Arc::new(Sink::try_new(&self.handle)?); sink.set_volume(volume_percent.max(0.0) / 100.0); - let decoder = Mp3Decoder::new(response, None).map_err(|_| { - Error::msg("stream is not in MP3 format or failed to initialize decoder") - })?; + let decoder = StreamDecoder::new(response, content_type.as_deref(), None) + .with_context(|| format!("failed to decode stream {}", stream_url))?; sink.append(decoder); sink.play(); diff --git a/src/decoder.rs b/src/decoder.rs index f4418f0..31fc6fc 100644 --- a/src/decoder.rs +++ b/src/decoder.rs @@ -1,56 +1,167 @@ +use std::io::Read; +use std::sync::mpsc::Sender; use std::time::Duration; -use std::{io::Read, sync::mpsc::Sender}; -use minimp3::{Decoder, Frame}; +use anyhow::{anyhow, Error}; use rodio::Source; +use symphonia::core::audio::SampleBuffer; +use symphonia::core::codecs::{Decoder, DecoderOptions, CODEC_TYPE_NULL}; +use symphonia::core::errors::Error as SymphoniaError; +use symphonia::core::formats::{FormatOptions, FormatReader}; +use symphonia::core::io::{MediaSourceStream, ReadOnlySource}; +use symphonia::core::meta::MetadataOptions; +use symphonia::core::probe::Hint; -pub struct Mp3Decoder -where - R: Read, -{ - decoder: Decoder, - current_frame: Frame, - current_frame_offset: usize, +/// A chunk of decoded interleaved samples, forwarded to the visualizer. +#[derive(Debug, Clone)] +pub struct Frame { + pub data: Vec, + pub channels: usize, + pub sample_rate: i32, +} + +/// Decodes an Icecast/HTTP audio stream (MP3, AAC, Ogg Vorbis, FLAC, WAV, ...) +/// using symphonia and exposes it as a rodio `Source`. +pub struct StreamDecoder { + format: Box, + decoder: Box, + track_id: u32, + buffer: Vec, + offset: usize, + channels: u16, + sample_rate: u32, tx: Option>, } -impl Mp3Decoder -where - R: Read, -{ - pub fn new(mut data: R, tx: Option>) -> Result { - if !is_mp3(data.by_ref()) { - return Err(data); +impl StreamDecoder { + pub fn new( + data: R, + content_type: Option<&str>, + tx: Option>, + ) -> Result + where + R: Read + Send + Sync + 'static, + { + let mss = MediaSourceStream::new(Box::new(ReadOnlySource::new(data)), Default::default()); + + let mut hint = Hint::new(); + if let Some(mime) = content_type { + let mime = mime.split(';').next().unwrap_or(mime).trim(); + hint.mime_type(mime); + if let Some(ext) = extension_for_mime(mime) { + hint.with_extension(ext); + } } - let mut decoder = Decoder::new(data); - let current_frame = decoder.next_frame().unwrap(); - Ok(Mp3Decoder { + let probed = symphonia::default::get_probe() + .format( + &hint, + mss, + &FormatOptions::default(), + &MetadataOptions::default(), + ) + .map_err(|e| anyhow!("unsupported or unrecognized stream format: {}", e))?; + + let format = probed.format; + let track = format + .tracks() + .iter() + .find(|t| t.codec_params.codec != CODEC_TYPE_NULL) + .ok_or_else(|| anyhow!("no supported audio track found in stream"))?; + let track_id = track.id; + + let decoder = symphonia::default::get_codecs() + .make(&track.codec_params, &DecoderOptions::default()) + .map_err(|e| anyhow!("unsupported codec: {}", e))?; + + let channels = track + .codec_params + .channels + .map(|c| c.count() as u16) + .unwrap_or(2); + let sample_rate = track.codec_params.sample_rate.unwrap_or(44100); + + let mut this = StreamDecoder { + format, decoder, - current_frame, - current_frame_offset: 0, + track_id, + buffer: Vec::new(), + offset: 0, + channels, + sample_rate, tx, - }) + }; + + // Decode the first packet so channel count and sample rate are accurate + // before rodio queries them. + if !this.decode_next() { + return Err(anyhow!("failed to decode audio stream")); + } + + Ok(this) + } + + /// Decode packets until one yields samples. Returns false at end of stream. + fn decode_next(&mut self) -> bool { + loop { + let packet = loop { + match self.format.next_packet() { + Ok(packet) if packet.track_id() == self.track_id => break packet, + Ok(_) => continue, + Err(_) => return false, + } + }; + + match self.decoder.decode(&packet) { + Ok(decoded) => { + if decoded.frames() == 0 { + continue; + } + let spec = *decoded.spec(); + self.channels = spec.channels.count() as u16; + self.sample_rate = spec.rate; + + let mut samples = SampleBuffer::::new(decoded.capacity() as u64, spec); + samples.copy_interleaved_ref(decoded); + self.buffer.clear(); + self.buffer.extend_from_slice(samples.samples()); + self.offset = 0; + + if let Some(tx) = &self.tx { + let frame = Frame { + data: self.buffer.clone(), + channels: self.channels as usize, + sample_rate: self.sample_rate as i32, + }; + if tx.send(frame).is_err() { + return false; + } + } + + return true; + } + // Skip malformed packets, common at the start of live streams. + Err(SymphoniaError::DecodeError(_)) => continue, + Err(_) => return false, + } + } } } -impl Source for Mp3Decoder -where - R: Read, -{ +impl Source for StreamDecoder { #[inline] fn current_frame_len(&self) -> Option { - Some(self.current_frame.data.len()) + Some(self.buffer.len().saturating_sub(self.offset)) } #[inline] fn channels(&self) -> u16 { - self.current_frame.channels as _ + self.channels } #[inline] fn sample_rate(&self) -> u32 { - self.current_frame.sample_rate as _ + self.sample_rate } #[inline] @@ -59,42 +170,75 @@ where } } -impl Iterator for Mp3Decoder -where - R: Read, -{ +impl Iterator for StreamDecoder { type Item = i16; #[inline] fn next(&mut self) -> Option { - if self.current_frame_offset == self.current_frame.data.len() { - match self.decoder.next_frame() { - Ok(frame) => { - if let Some(tx) = &self.tx { - if tx.send(frame.clone()).is_err() { - return None; - } - } - self.current_frame = frame - } - _ => return None, - } - self.current_frame_offset = 0; + if self.offset >= self.buffer.len() && !self.decode_next() { + return None; } - let v = self.current_frame.data[self.current_frame_offset]; - self.current_frame_offset += 1; + let v = self.buffer[self.offset]; + self.offset += 1; Some(v) } } -/// Returns true if the stream contains mp3 data, then resets it to where it was. -fn is_mp3(mut data: R) -> bool -where - R: Read, -{ - let mut decoder = Decoder::new(data.by_ref()); - let ok = decoder.next_frame().is_ok(); - ok +#[cfg(test)] +mod tests { + use super::*; + + /// Decodes a few seconds of a live stream. Requires network access, + /// so these tests are ignored by default; run with `cargo test -- --ignored`. + fn decode_live_stream(url: &str) { + let client = reqwest::blocking::Client::new(); + let response = client.get(url).send().unwrap(); + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .map(String::from); + let mut decoder = StreamDecoder::new(response, content_type.as_deref(), None) + .unwrap_or_else(|e| panic!("failed to open {url} ({content_type:?}): {e}")); + println!( + "{url}: content-type={:?} channels={} sample_rate={}", + content_type, + Source::channels(&decoder), + Source::sample_rate(&decoder) + ); + let decoded = decoder.by_ref().take(200_000).count(); + assert_eq!(decoded, 200_000, "stream {url} ended prematurely"); + } + + #[test] + #[ignore] + fn decodes_mp3_stream() { + decode_live_stream("http://stream.radioparadise.com/mp3-128"); + } + + #[test] + #[ignore] + fn decodes_aac_stream() { + decode_live_stream("http://stream.radioparadise.com/aac-128"); + } + + #[test] + #[ignore] + fn decodes_ogg_flac_stream() { + decode_live_stream("http://stream.radioparadise.com/flacm"); + } +} + +fn extension_for_mime(mime: &str) -> Option<&'static str> { + match mime.to_ascii_lowercase().as_str() { + "audio/mpeg" | "audio/mp3" => Some("mp3"), + "audio/aac" | "audio/aacp" | "audio/x-aac" => Some("aac"), + "audio/mp4" | "audio/m4a" => Some("m4a"), + "application/ogg" | "audio/ogg" | "audio/x-ogg" => Some("ogg"), + "audio/flac" | "audio/x-flac" => Some("flac"), + "audio/wav" | "audio/x-wav" | "audio/wave" => Some("wav"), + _ => None, + } } diff --git a/src/play.rs b/src/play.rs index aedc00e..ced8d45 100644 --- a/src/play.rs +++ b/src/play.rs @@ -7,7 +7,7 @@ use tunein_cli::os_media_controls::OsMediaControls; use crate::{ app::{App, CurrentDisplayMode, State, Volume}, cfg::{SourceOptions, UiOptions}, - decoder::Mp3Decoder, + decoder::StreamDecoder, provider::{radiobrowser::Radiobrowser, tunein::Tunein, Provider}, tui, }; @@ -44,7 +44,7 @@ pub async fn exec( let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::(); let (sink_cmd_tx, mut sink_cmd_rx) = tokio::sync::mpsc::unbounded_channel::(); - let (frame_tx, frame_rx) = std::sync::mpsc::channel::(); + let (frame_tx, frame_rx) = std::sync::mpsc::channel::(); let ui = UiOptions { scale: 1.0, @@ -140,10 +140,17 @@ pub async fn exec( None => response, }; + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .map(String::from); + let (_stream, handle) = rodio::OutputStream::try_default().unwrap(); let sink = rodio::Sink::try_new(&handle).unwrap(); sink.set_volume(volume.volume_ratio()); - let decoder = Mp3Decoder::new(response, Some(frame_tx)).unwrap(); + let decoder = StreamDecoder::new(response, content_type.as_deref(), Some(frame_tx)) + .expect("failed to decode audio stream"); sink.append(decoder); loop { diff --git a/src/player.rs b/src/player.rs index 9a2ea5e..ee6fbe9 100644 --- a/src/player.rs +++ b/src/player.rs @@ -8,11 +8,10 @@ use std::{ use anyhow::Error; use futures_util::Future; -use reqwest::blocking::Response; use rodio::{OutputStream, OutputStreamHandle, Sink}; use tokio::sync::mpsc; -use crate::decoder::Mp3Decoder; +use crate::decoder::StreamDecoder; pub struct Player; @@ -38,7 +37,6 @@ struct PlayerInternal { stream: OutputStream, handle: OutputStreamHandle, commands: Arc>>, - decoder: Option>, } impl PlayerInternal { @@ -49,7 +47,6 @@ impl PlayerInternal { stream, handle, commands: cmd_rx, - decoder: None, } } @@ -61,7 +58,6 @@ impl PlayerInternal { let sink = self.sink.clone(); thread::spawn(move || { - let (frame_tx, _frame_rx) = std::sync::mpsc::channel::(); let client = reqwest::blocking::Client::new(); let response = client.get(url.clone()).send().unwrap(); @@ -80,7 +76,12 @@ impl PlayerInternal { } None => response, }; - let decoder = Mp3Decoder::new(response, Some(frame_tx)).unwrap(); + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .map(String::from); + let decoder = StreamDecoder::new(response, content_type.as_deref(), None).unwrap(); { let sink = sink.lock().unwrap(); -- 2.51.2