feat: show actual stream quality in player UI

Issue #34 - Display actual stream quality, not track metadata

Backend changes:
- Add sample_rate and bit_depth to PlaybackEvent
- Add stream quality tracking to SharedState
- Extract bit_depth from FLAC/M4A headers in streaming path
- Player sets stream quality when decoding audio

Frontend changes:
- Update PlaybackEvent interface with new fields
- Update currentTrack with actual stream quality from backend

This allows the UI to show the actual quality being played (e.g., 44.1kHz
when streaming a 192kHz track at CD Quality) instead of the track's
maximum advertised quality.
This commit is contained in:
vicrodh committed 2026-01-31 23:32:29 -06:00
1 parent 38b7ea7aef
commit 8b7e4dab27
4 files changed
+110 -31

No files matched your search

+22 -13
View File
@@ -144,10 +144,11 @@ pub async fn play_track(
let stream_info = get_stream_info(&stream_url.url).await?;
log::info!(
"Stream info: {:.2} MB, {}Hz, {} channels, {:.1} MB/s",
"Stream info: {:.2} MB, {}Hz, {} channels, {}-bit, {:.1} MB/s",
stream_info.content_length as f64 / (1024.0 * 1024.0),
stream_info.sample_rate,
stream_info.channels,
stream_info.bit_depth,
stream_info.speed_mbps
);
@@ -157,6 +158,7 @@ pub async fn play_track(
track_id,
stream_info.sample_rate,
stream_info.channels,
stream_info.bit_depth,
stream_info.content_length,
stream_info.speed_mbps,
duration_secs.unwrap_or(0), // Use 0 if not provided
@@ -314,6 +316,7 @@ pub struct StreamInfo {
pub content_length: u64,
pub sample_rate: u32,
pub channels: u16,
pub bit_depth: u32,
pub speed_mbps: f64,
}
@@ -383,18 +386,19 @@ async fn get_stream_info(url: &str) -> Result<StreamInfo, String> {
);
// Try to extract audio format from initial bytes
let (sample_rate, channels) = extract_audio_format_from_header(&initial_bytes)?;
let (sample_rate, channels, bit_depth) = extract_audio_format_from_header(&initial_bytes)?;
Ok(StreamInfo {
content_length,
sample_rate,
channels,
bit_depth,
speed_mbps,
})
}
/// Extract sample rate and channels from audio file header
fn extract_audio_format_from_header(data: &[u8]) -> Result<(u32, u16), String> {
/// Extract sample rate, channels, and bit depth from audio file header
fn extract_audio_format_from_header(data: &[u8]) -> Result<(u32, u16, u32), String> {
use std::io::{BufReader, Cursor};
use rodio::Decoder;
@@ -404,11 +408,14 @@ fn extract_audio_format_from_header(data: &[u8]) -> Result<(u32, u16), String> {
// FLAC format: "fLaC" + METADATA_BLOCK_HEADER (4 bytes) + STREAMINFO
if data.len() >= 26 {
// STREAMINFO starts at byte 8
// Bytes 10-12: sample rate (20 bits) + channels (3 bits) + bits per sample (5 bits)
// Bytes 18-20: sample rate (20 bits) + channels (3 bits) + bits per sample (5 bits)
let sr_high = ((data[18] as u32) << 12) | ((data[19] as u32) << 4) | ((data[20] as u32) >> 4);
let sample_rate = sr_high;
let channels = ((data[20] >> 1) & 0x07) + 1;
return Ok((sample_rate, channels as u16));
// Bits per sample: 5 bits starting at bit 4 of byte 20
let bits_per_sample = ((data[20] & 0x01) << 4) | ((data[21] >> 4) & 0x0F);
let bit_depth = (bits_per_sample + 1) as u32; // FLAC stores (bits - 1)
return Ok((sample_rate, channels as u16, bit_depth));
}
}
@@ -459,24 +466,26 @@ fn extract_audio_format_from_header(data: &[u8]) -> Result<(u32, u16), String> {
if let Some(track) = probed.format.default_track() {
let sample_rate = track.codec_params.sample_rate.unwrap_or(44100);
let channels = track.codec_params.channels.map(|c| c.count() as u16).unwrap_or(2);
return Ok((sample_rate, channels));
let bit_depth = track.codec_params.bits_per_sample.unwrap_or(16);
return Ok((sample_rate, channels, bit_depth));
}
}
// Default to common values for M4A
return Ok((44100, 2));
// Default to common values for M4A (typically 16-bit for AAC)
return Ok((44100, 2, 16));
}
// Try rodio decoder for other formats
// Try rodio decoder for other formats (rodio doesn't expose bit depth)
match Decoder::new(BufReader::new(Cursor::new(data.to_vec()))) {
Ok(decoder) => {
use rodio::Source;
Ok((decoder.sample_rate(), decoder.channels()))
// Assume 16-bit for rodio-decoded formats (MP3, etc.)
Ok((decoder.sample_rate(), decoder.channels(), 16))
}
Err(_) => {
// Default fallback
log::warn!("Could not determine audio format, using defaults (44100Hz, stereo)");
Ok((44100, 2))
log::warn!("Could not determine audio format, using defaults (44100Hz, stereo, 16-bit)");
Ok((44100, 2, 16))
}
}
}
+4
View File
@@ -382,12 +382,16 @@ pub fn run() {
let should_update_mpris = should_emit || (track_id == 0 && last_track_id != 0);
if should_emit {
let sample_rate = player_state.get_sample_rate();
let bit_depth = player_state.get_bit_depth();
let event = player::PlaybackEvent {
is_playing,
position,
duration,
track_id,
volume,
sample_rate: if sample_rate > 0 { Some(sample_rate) } else { None },
bit_depth: if bit_depth > 0 { Some(bit_depth) } else { None },
};
let _ = app_handle.emit("playback:state", &event);
last_position = position;
+71 -18
View File
@@ -17,7 +17,7 @@ pub use streaming_source::{BufferedMediaSource, BufferWriter, StreamingConfig, I
use std::io::{BufReader, Cursor, Read, Seek, SeekFrom};
use std::panic::{self, AssertUnwindSafe};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::mpsc::{self, Sender, RecvTimeoutError};
use std::sync::{Arc, Mutex};
use std::thread;
@@ -199,24 +199,30 @@ fn is_isomp4(data: &[u8]) -> bool {
/// Extract audio metadata (sample rate, channels) without full decode.
/// This is much faster than decode_with_symphonia as it only reads headers.
/// Audio metadata extracted from file headers
struct AudioMetadata {
sample_rate: u32,
channels: u16,
bit_depth: Option<u32>,
}
fn extract_audio_metadata(data: &[u8]) -> Result<(u32, u16), String> {
// For non-isomp4 files (FLAC, etc.), try rodio's decoder first - it reads headers quickly
if !is_isomp4(data) {
let result = panic::catch_unwind(AssertUnwindSafe(|| {
Decoder::new(BufReader::new(Cursor::new(data.to_vec())))
}));
let meta = extract_audio_metadata_full(data)?;
Ok((meta.sample_rate, meta.channels))
}
if let Ok(Ok(decoder)) = result {
return Ok((decoder.sample_rate(), decoder.channels()));
}
}
fn extract_audio_metadata_full(data: &[u8]) -> Result<AudioMetadata, String> {
// For non-isomp4 files (FLAC, etc.), try symphonia directly to get all metadata
// Symphonia gives us bits_per_sample which rodio doesn't expose
// Fallback to symphonia probe for codec params (no decode needed)
// Use symphonia probe for codec params (no decode needed)
let source = Box::new(CursorMediaSource::new(data.to_vec())) as Box<dyn MediaSource>;
let mss = MediaSourceStream::new(source, Default::default());
let mut hint = Hint::new();
hint.with_extension("m4a");
if is_isomp4(data) {
hint.with_extension("m4a");
}
let format_opts = FormatOptions {
enable_gapless: true,
@@ -241,7 +247,14 @@ fn extract_audio_metadata(data: &[u8]) -> Result<(u32, u16), String> {
.map(|c| c.count() as u16)
.unwrap_or(2);
Ok((sample_rate, channels))
// Get bits per sample for bit depth
let bit_depth = track.codec_params.bits_per_sample;
Ok(AudioMetadata {
sample_rate,
channels,
bit_depth,
})
}
fn decode_with_fallback(
@@ -465,6 +478,10 @@ pub struct PlaybackEvent {
pub duration: u64,
pub track_id: u64,
pub volume: f32,
/// Actual sample rate of the current stream (Hz)
pub sample_rate: Option<u32>,
/// Actual bit depth of the current stream
pub bit_depth: Option<u32>,
}
/// Shared state between main thread and audio thread
@@ -488,6 +505,10 @@ pub struct SharedState {
current_device: Arc<std::sync::RwLock<Option<String>>>,
/// Stream error flag (set when ALSA/audio errors are detected)
stream_error: Arc<AtomicBool>,
/// Actual sample rate of the current stream (Hz)
sample_rate: Arc<AtomicU32>,
/// Actual bit depth of the current stream
bit_depth: Arc<AtomicU32>,
}
impl Default for SharedState {
@@ -508,6 +529,8 @@ impl SharedState {
position_at_start: Arc::new(AtomicU64::new(0)),
current_device: Arc::new(std::sync::RwLock::new(None)),
stream_error: Arc::new(AtomicBool::new(false)),
sample_rate: Arc::new(AtomicU32::new(0)),
bit_depth: Arc::new(AtomicU32::new(0)),
}
}
@@ -519,6 +542,19 @@ impl SharedState {
self.stream_error.load(Ordering::SeqCst)
}
pub fn set_stream_quality(&self, sample_rate: u32, bit_depth: u32) {
self.sample_rate.store(sample_rate, Ordering::SeqCst);
self.bit_depth.store(bit_depth, Ordering::SeqCst);
}
pub fn get_sample_rate(&self) -> u32 {
self.sample_rate.load(Ordering::SeqCst)
}
pub fn get_bit_depth(&self) -> u32 {
self.bit_depth.load(Ordering::SeqCst)
}
pub fn set_current_device(&self, device: Option<String>) {
if let Ok(mut d) = self.current_device.write() {
*d = device;
@@ -1669,16 +1705,24 @@ impl Player {
pub fn play_data(&self, data: Vec<u8>, track_id: u64) -> Result<(), String> {
log::info!("Player: Playing {} bytes of audio data for track {}", data.len(), track_id);
// Extract audio metadata (sample rate and channels) - fast header-only read
let (sample_rate, channels) = extract_audio_metadata(&data)
// Extract audio metadata (sample rate, channels, bit depth) - fast header-only read
let meta = extract_audio_metadata_full(&data)
.map_err(|e| format!("Failed to extract audio metadata: {}", e))?;
let sample_rate = meta.sample_rate;
let channels = meta.channels;
let bit_depth = meta.bit_depth.unwrap_or(16);
log::info!(
"Player: Detected audio format - {}Hz, {} channels",
"Player: Detected audio format - {}Hz, {} channels, {}-bit",
sample_rate,
channels
channels,
bit_depth
);
// Update shared state with actual stream quality
self.state.set_stream_quality(sample_rate, bit_depth);
self.tx
.send(AudioCommand::Play {
data,
@@ -1746,20 +1790,25 @@ impl Player {
track_id: u64,
sample_rate: u32,
channels: u16,
bit_depth: u32,
content_length: u64,
speed_mbps: f64,
duration_secs: u64,
) -> Result<BufferWriter, String> {
log::info!(
"Player: Starting dynamic streaming for track {} ({}Hz, {}ch, {:.2} MB, {:.1} MB/s, {}s)",
"Player: Starting dynamic streaming for track {} ({}Hz, {}ch, {}-bit, {:.2} MB, {:.1} MB/s, {}s)",
track_id,
sample_rate,
channels,
bit_depth,
content_length as f64 / (1024.0 * 1024.0),
speed_mbps,
duration_secs
);
// Update shared state with actual stream quality
self.state.set_stream_quality(sample_rate, bit_depth);
// Use StreamingConfig::from_speed_mbps for dynamic buffer sizing
let config = StreamingConfig::from_speed_mbps(speed_mbps);
@@ -1893,12 +1942,16 @@ impl Player {
/// Get playback event for emitting to frontend
pub fn get_playback_event(&self) -> PlaybackEvent {
let sample_rate = self.state.get_sample_rate();
let bit_depth = self.state.get_bit_depth();
PlaybackEvent {
is_playing: self.state.is_playing(),
position: self.state.current_position(),
duration: self.state.duration(),
track_id: self.state.current_track_id(),
volume: self.state.volume(),
sample_rate: if sample_rate > 0 { Some(sample_rate) } else { None },
bit_depth: if bit_depth > 0 { Some(bit_depth) } else { None },
}
}
}
+13
View File
@@ -68,6 +68,8 @@ interface PlaybackEvent {
duration: number;
track_id: number;
volume: number;
sample_rate: number | null; // Actual stream sample rate in Hz
bit_depth: number | null; // Actual stream bit depth
}
// ============ State ============
@@ -413,6 +415,17 @@ async function handlePlaybackEvent(event: PlaybackEvent): Promise<void> {
if (event.track_id === currentTrack.id) {
currentTime = event.position;
isPlaying = event.is_playing;
// Update track with actual stream quality (issue #34)
// The backend now sends the real sample rate and bit depth from the decoded stream
if (event.sample_rate && event.sample_rate > 0) {
// Convert Hz to kHz for display (44100 -> 44.1)
currentTrack.samplingRate = event.sample_rate / 1000;
}
if (event.bit_depth && event.bit_depth > 0) {
currentTrack.bitDepth = event.bit_depth;
}
notifyListeners();
// Check if track ended - auto-advance to next