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:
1 parent
38b7ea7aef
commit
8b7e4dab27
4 files changed
+110
-31
No files matched your search
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user