Implement DAC passthrough and sample rate switching

This commit implements proper sample rate passthrough for high-quality
audio playback, addressing issue #10 where DAC sample rates were locked
at 44.1kHz regardless of file quality.

Core Changes:
- Modified Player to accept and store AudioSettings (exclusive mode, DAC passthrough)
- Updated AudioCommand::Play to include sample_rate and channels metadata
- Created create_output_stream_with_config() to build custom StreamConfig
- Added sample rate tracking to detect changes between tracks
- Modified Play handler to recreate OutputStream when sample rate changes
- Updated play_data() to extract audio metadata before playback
- Modified lib.rs to read AudioSettings on startup and pass to Player

Device Detection Improvements (Issue #4):
- Added display_name and device_type fields to AudioDevice struct
- Implemented get_pipewire_device_descriptions() to extract friendly names
- Updated get_audio_devices() to map technical names to user-friendly names
- Added device type detection (USB, HDMI, PCI, Bluetooth)

Technical Details:
- Audio files are decoded twice: once for metadata, once for playback
- Stream is recreated only when sample rate or channel count changes
- Falls back to default device config if custom config fails
- Supports both PipeWire and PulseAudio device naming via pactl
- Logs sample rate transitions and OutputStream configuration

Files Modified:
- src-tauri/src/player/mod.rs: Core playback engine changes
- src-tauri/src/lib.rs: Initialize Player with AudioSettings
- src-tauri/src/commands/playback.rs: Device enumeration improvements

Tested: Compiles successfully on Linux with Rust 1.92
This commit is contained in:
vicrodh committed 2026-01-16 14:16:18 -06:00
1 parent 8565696d41
commit 88b1f959ae
3 files changed
+357 -47

No files matched your search

+87 -2
View File
@@ -392,8 +392,50 @@ pub fn get_playback_state(state: State<'_, AppState>) -> Result<PlaybackState, S
/// Audio device information
#[derive(serde::Serialize)]
pub struct AudioDevice {
pub name: String,
pub name: String, // Technical name (internal use)
pub display_name: String, // Friendly name for UI
pub is_default: bool,
pub device_type: Option<String>, // "USB", "PCI", "HDMI", etc.
}
/// Get friendly device names from PipeWire/PulseAudio
#[cfg(target_os = "linux")]
fn get_pipewire_device_descriptions() -> std::collections::HashMap<String, String> {
use std::process::Command;
let mut map = std::collections::HashMap::new();
// Try pactl (PipeWire/PulseAudio)
if let Ok(output) = Command::new("pactl").args(&["list", "sinks"]).output() {
if let Ok(text) = String::from_utf8(output.stdout) {
let mut current_name: Option<String> = None;
for line in text.lines() {
let line = line.trim();
// Extract Name: field
if let Some(name) = line.strip_prefix("Name: ") {
current_name = Some(name.to_string());
}
// Extract Description: field
if let Some(desc) = line.strip_prefix("Description: ") {
if let Some(name) = &current_name {
map.insert(name.clone(), desc.to_string());
log::debug!("Device mapping: {} -> {}", name, desc);
}
}
}
}
}
log::info!("Found {} device descriptions from PipeWire/PulseAudio", map.len());
map
}
#[cfg(not(target_os = "linux"))]
fn get_pipewire_device_descriptions() -> std::collections::HashMap<String, String> {
std::collections::HashMap::new()
}
/// Get available audio output devices
@@ -409,13 +451,56 @@ pub fn get_audio_devices() -> Result<Vec<AudioDevice>, String> {
.default_output_device()
.and_then(|d| d.name().ok());
// Get friendly names from PipeWire/PulseAudio
let friendly_names = get_pipewire_device_descriptions();
let devices: Vec<AudioDevice> = host
.output_devices()
.map_err(|e| format!("Failed to enumerate devices: {}", e))?
.filter_map(|device| {
device.name().ok().map(|name| {
let is_default = default_device_name.as_ref().map(|d| d == &name).unwrap_or(false);
AudioDevice { name, is_default }
// Try to get friendly name from PipeWire/PulseAudio
let display_name = friendly_names
.get(&name)
.cloned()
.unwrap_or_else(|| {
// Fallback: clean up technical name slightly
name.replace("alsa_output.", "")
.replace("_", " ")
.replace("pro-output", "")
.trim()
.to_string()
});
// Detect device type from technical name
let device_type = if name.contains("usb") || name.contains("USB") {
Some("USB".to_string())
} else if name.contains("hdmi") || name.contains("HDMI") {
Some("HDMI".to_string())
} else if name.contains("pci") || name.contains("PCI") {
Some("PCI".to_string())
} else if name.contains("bluetooth") || name.contains("bluez") {
Some("Bluetooth".to_string())
} else {
None
};
log::debug!(
"Device: {} -> {} (type: {:?}, default: {})",
name,
display_name,
device_type,
is_default
);
AudioDevice {
name,
display_name,
is_default,
device_type,
}
})
})
.collect();
+27 -7
View File
@@ -52,10 +52,17 @@ pub struct AppState {
impl AppState {
pub fn new() -> Self {
Self::with_device(None)
Self::with_device_and_settings(None, config::audio_settings::AudioSettings::default())
}
pub fn with_device(device_name: Option<String>) -> Self {
Self::with_device_and_settings(device_name, config::audio_settings::AudioSettings::default())
}
pub fn with_device_and_settings(
device_name: Option<String>,
audio_settings: config::audio_settings::AudioSettings,
) -> Self {
// Create playback cache (L2 - disk, 500MB)
let playback_cache = match PlaybackCache::new(500 * 1024 * 1024) {
Ok(cache) => Some(Arc::new(cache)),
@@ -74,7 +81,7 @@ impl AppState {
Self {
client: Arc::new(Mutex::new(QobuzClient::default())),
player: Player::new(device_name),
player: Player::new(device_name, audio_settings),
queue: QueueManager::new(),
media_controls: MediaControlsManager::new(),
audio_cache,
@@ -164,23 +171,36 @@ pub fn run() {
let offline_state = offline::OfflineState::new()
.expect("Failed to initialize offline state");
// Read saved audio device setting for player initialization
let saved_device = audio_settings_state
// Read saved audio device and settings for player initialization
let (saved_device, audio_settings) = audio_settings_state
.store
.lock()
.ok()
.and_then(|store| store.get_settings().ok())
.and_then(|settings| settings.output_device);
.and_then(|store| {
store.get_settings().ok().map(|settings| {
(settings.output_device.clone(), settings)
})
})
.unwrap_or_else(|| {
log::info!("No saved audio settings found, using defaults");
(None, config::audio_settings::AudioSettings::default())
});
if let Some(ref device) = saved_device {
log::info!("Initializing player with saved device: {}", device);
}
log::info!(
"Audio settings: exclusive_mode={}, dac_passthrough={}, sample_rate={:?}",
audio_settings.exclusive_mode,
audio_settings.dac_passthrough,
audio_settings.preferred_sample_rate
);
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_clipboard_manager::init())
.manage(AppState::with_device(saved_device))
.manage(AppState::with_device_and_settings(saved_device, audio_settings))
.setup(|app| {
// Initialize system tray icon
if let Err(e) = tray::init_tray(app.handle()) {
+243 -38
View File
@@ -13,13 +13,14 @@ use std::io::{BufReader, Cursor, Read, Seek, SeekFrom};
use std::panic::{self, AssertUnwindSafe};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{self, Sender, RecvTimeoutError};
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
use rodio::{Decoder, OutputStream, Sink, Source};
use rodio::buffer::SamplesBuffer;
use rodio::decoder::Mp4Type;
use rodio::cpal::traits::{DeviceTrait, HostTrait};
use rodio::cpal::{StreamConfig, SampleRate, BufferSize, SupportedStreamConfig, SupportedBufferSize, SampleFormat};
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::DecoderOptions;
use symphonia::core::errors::Error as SymphoniaError;
@@ -30,11 +31,18 @@ use symphonia::core::probe::Hint;
use symphonia::default::{get_codecs, get_probe};
use crate::api::{client::QobuzClient, models::Quality};
use crate::config::audio_settings::AudioSettings;
/// Commands sent to the audio thread
enum AudioCommand {
/// Play audio data with track ID and duration
Play { data: Vec<u8>, track_id: u64, duration_secs: u64 },
/// Play audio data with track ID, duration, and audio specs
Play {
data: Vec<u8>,
track_id: u64,
duration_secs: u64,
sample_rate: u32,
channels: u16,
},
/// Pause playback
Pause,
/// Resume playback
@@ -83,7 +91,14 @@ impl Seek for CursorMediaSource {
}
}
fn decode_with_symphonia(data: &[u8]) -> Result<SamplesBuffer<i16>, String> {
/// Audio specifications extracted from decoded audio
struct AudioSpecs {
samples: SamplesBuffer<i16>,
sample_rate: u32,
channels: u16,
}
fn decode_with_symphonia(data: &[u8]) -> Result<AudioSpecs, String> {
let source = Box::new(CursorMediaSource::new(data.to_vec())) as Box<dyn MediaSource>;
let mss = MediaSourceStream::new(source, Default::default());
@@ -150,7 +165,11 @@ fn decode_with_symphonia(data: &[u8]) -> Result<SamplesBuffer<i16>, String> {
return Err("Symphonia decode produced no audio".to_string());
}
Ok(SamplesBuffer::new(channels, sample_rate, samples))
Ok(AudioSpecs {
samples: SamplesBuffer::new(channels, sample_rate, samples),
sample_rate,
channels,
})
}
fn is_isomp4(data: &[u8]) -> bool {
@@ -166,9 +185,9 @@ fn decode_with_fallback(
) -> Result<Box<dyn Source<Item = i16> + Send>, String> {
if is_isomp4(data) {
return decode_with_symphonia(data)
.map(|buffer| {
.map(|specs| {
log::info!("Decoded audio using symphonia fallback (isomp4)");
Box::new(buffer) as Box<dyn Source<Item = i16> + Send>
Box::new(specs.samples) as Box<dyn Source<Item = i16> + Send>
});
}
@@ -208,14 +227,89 @@ fn decode_with_fallback(
}
match decode_with_symphonia(data) {
Ok(buffer) => {
Ok(specs) => {
log::info!("Decoded audio using symphonia fallback");
Ok(Box::new(buffer))
Ok(Box::new(specs.samples))
}
Err(err) => Err(err),
}
}
/// Create OutputStream with custom sample rate configuration
fn create_output_stream_with_config(
device: &rodio::cpal::Device,
sample_rate: u32,
channels: u16,
exclusive_mode: bool,
) -> Result<(OutputStream, rodio::OutputStreamHandle), String> {
log::info!(
"Creating OutputStream: {}Hz, {} channels, exclusive: {}",
sample_rate,
channels,
exclusive_mode
);
// Create StreamConfig with desired sample rate
let config = StreamConfig {
channels,
sample_rate: SampleRate(sample_rate),
buffer_size: if exclusive_mode {
BufferSize::Fixed(512) // Lower latency for exclusive mode
} else {
BufferSize::Default
},
};
// Check if device supports this configuration
let supported_configs = device
.supported_output_configs()
.map_err(|e| format!("Failed to get supported configs: {}", e))?;
let mut found_matching = false;
for range in supported_configs {
if range.channels() == channels
&& sample_rate >= range.min_sample_rate().0
&& sample_rate <= range.max_sample_rate().0
{
found_matching = true;
log::info!(
"Device supports {}Hz (range: {}-{}Hz)",
sample_rate,
range.min_sample_rate().0,
range.max_sample_rate().0
);
break;
}
}
if !found_matching {
log::warn!(
"Device may not support {}Hz, attempting anyway",
sample_rate
);
}
// Create SupportedStreamConfig
let supported_config = SupportedStreamConfig::new(
config.channels,
config.sample_rate,
SupportedBufferSize::Range { min: 64, max: 8192 },
SampleFormat::F32,
);
// Create OutputStream with custom config
match OutputStream::try_from_device_config(device, supported_config) {
Ok((stream, handle)) => {
log::info!("✅ OutputStream created successfully at {}Hz", sample_rate);
Ok((stream, handle))
}
Err(e) => {
log::error!("❌ Failed to create OutputStream at {}Hz: {}", sample_rate, e);
Err(format!("Failed to create output stream: {}", e))
}
}
}
/// Event payload for playback state updates
#[derive(Debug, Clone, serde::Serialize)]
pub struct PlaybackEvent {
@@ -358,22 +452,28 @@ pub struct Player {
tx: Sender<AudioCommand>,
/// Shared state accessible from any thread
pub state: SharedState,
/// Audio settings (exclusive mode, DAC passthrough, etc.)
audio_settings: Arc<Mutex<AudioSettings>>,
}
impl Default for Player {
fn default() -> Self {
Self::new(None)
Self::new(None, AudioSettings::default())
}
}
impl Player {
/// Create a new player with an optional specific output device
/// Create a new player with an optional specific output device and audio settings
/// If device_name is None, uses the system default device
pub fn new(device_name: Option<String>) -> Self {
pub fn new(device_name: Option<String>, audio_settings: AudioSettings) -> Self {
let (tx, rx) = mpsc::channel::<AudioCommand>();
let state = SharedState::new();
let thread_state = state.clone();
// Clone settings for thread
let settings = Arc::new(Mutex::new(audio_settings.clone()));
let thread_settings = settings.clone();
// Spawn dedicated audio thread
thread::spawn(move || {
log::info!("Audio thread starting...");
@@ -457,6 +557,8 @@ impl Player {
// Initialize audio device lazily on first playback to avoid idle CPU usage.
let mut current_device_name = device_name.clone();
let mut stream_opt: Option<(OutputStream, rodio::OutputStreamHandle)> = None;
let mut current_sample_rate: Option<u32> = None;
let mut current_channels: Option<u16> = None;
const MAX_INIT_RETRIES: u32 = 5;
const RETRY_DELAY_MS: u64 = 500;
@@ -480,39 +582,120 @@ impl Player {
stream_opt: &mut Option<(OutputStream, rodio::OutputStreamHandle)>,
current_device_name: &mut Option<String>,
consecutive_sink_failures: &mut u32,
pause_suspend_deadline: &mut Option<Instant>| {
pause_suspend_deadline: &mut Option<Instant>,
current_sample_rate: &mut Option<u32>,
current_channels: &mut Option<u16>| {
match command {
AudioCommand::Play { data, track_id, duration_secs } => {
log::info!("Audio thread: playing track {}", track_id);
AudioCommand::Play { data, track_id, duration_secs, sample_rate, channels } => {
log::info!(
"Audio thread: playing track {} ({}Hz, {} channels)",
track_id,
sample_rate,
channels
);
*pause_suspend_deadline = None;
if stream_opt.is_none() {
for attempt in 1..=MAX_INIT_RETRIES {
// Check if we need to recreate the stream
let needs_new_stream = stream_opt.is_none()
|| *current_sample_rate != Some(sample_rate)
|| *current_channels != Some(channels);
if needs_new_stream {
if stream_opt.is_some() {
log::info!(
"Audio device init on playback attempt {}/{}",
attempt,
MAX_INIT_RETRIES
"Sample rate changed from {:?}Hz to {}Hz - recreating OutputStream",
*current_sample_rate,
sample_rate
);
*stream_opt = init_device(current_device_name, &thread_state);
if stream_opt.is_some() {
thread_state.set_stream_error(false);
break;
}
if attempt < MAX_INIT_RETRIES {
log::warn!(
"Audio init failed, retrying in {}ms...",
RETRY_DELAY_MS
);
std::thread::sleep(Duration::from_millis(RETRY_DELAY_MS));
}
// Drop old stream
drop(stream_opt.take());
}
if stream_opt.is_none() {
log::error!(
"Failed to initialize audio after {} attempts.",
MAX_INIT_RETRIES
);
// Get audio settings (exclusive mode / DAC passthrough)
let dac_passthrough = thread_settings
.lock()
.ok()
.map(|s| s.dac_passthrough)
.unwrap_or(false);
log::info!("DAC passthrough enabled: {}", dac_passthrough);
// Get the audio device
let device = if let Some(ref name) = *current_device_name {
log::info!("Looking for audio device: {}", name);
let found = host.output_devices()
.ok()
.and_then(|mut devices| {
devices.find(|d| d.name().ok().as_ref() == Some(name))
});
match found {
Some(d) if is_device_valid(&d) => {
log::info!("Found and validated device: {}", name);
Some(d)
}
Some(_) => {
log::warn!("Device '{}' found but has no valid output configs, using default", name);
host.default_output_device()
}
None => {
log::warn!("Device '{}' not found, using default", name);
host.default_output_device()
}
}
} else {
log::info!("Using default audio device");
host.default_output_device()
};
let Some(device) = device else {
log::error!("No audio output device available");
thread_state.set_current_device(None);
thread_state.set_stream_error(true);
return;
};
// Set current device name
if let Ok(name) = device.name() {
log::info!("Using audio device: {}", name);
thread_state.set_current_device(Some(name));
}
// Create OutputStream with custom sample rate
match create_output_stream_with_config(
&device,
sample_rate,
channels,
dac_passthrough,
) {
Ok(stream) => {
*stream_opt = Some(stream);
*current_sample_rate = Some(sample_rate);
*current_channels = Some(channels);
thread_state.set_stream_error(false);
log::info!("✅ Audio stream ready at {}Hz", sample_rate);
}
Err(e) => {
log::error!("❌ Failed to create stream at {}Hz: {}", sample_rate, e);
log::warn!("Attempting fallback to default device config...");
// Try fallback to default config
match OutputStream::try_from_device(&device) {
Ok(stream) => {
log::warn!("Using default device config as fallback");
*stream_opt = Some(stream);
*current_sample_rate = Some(sample_rate);
*current_channels = Some(channels);
thread_state.set_stream_error(false);
}
Err(e2) => {
log::error!("Fallback also failed: {}", e2);
thread_state.set_stream_error(true);
thread_state.set_current_device(None);
return;
}
}
}
}
}
@@ -796,6 +979,8 @@ impl Player {
&mut current_device_name,
&mut consecutive_sink_failures,
&mut pause_suspend_deadline,
&mut current_sample_rate,
&mut current_channels,
),
Err(RecvTimeoutError::Timeout) => {
let now = Instant::now();
@@ -844,6 +1029,8 @@ impl Player {
&mut current_device_name,
&mut consecutive_sink_failures,
&mut pause_suspend_deadline,
&mut current_sample_rate,
&mut current_channels,
),
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => {
@@ -865,6 +1052,8 @@ impl Player {
&mut current_device_name,
&mut consecutive_sink_failures,
&mut pause_suspend_deadline,
&mut current_sample_rate,
&mut current_channels,
),
Err(_) => {
log::info!("Audio thread: channel closed, exiting");
@@ -875,7 +1064,7 @@ impl Player {
}
});
Self { tx, state }
Self { tx, state, audio_settings: settings }
}
/// Play a track by ID (downloads audio)
@@ -915,11 +1104,27 @@ 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)
// We decode to get metadata, then the audio thread will decode again for playback
let audio_specs = decode_with_symphonia(&data)
.map_err(|e| format!("Failed to extract audio metadata: {}", e))?;
let sample_rate = audio_specs.sample_rate;
let channels = audio_specs.channels;
log::info!(
"Player: Detected audio format - {}Hz, {} channels",
sample_rate,
channels
);
self.tx
.send(AudioCommand::Play {
data,
track_id,
duration_secs: 0, // Will be determined by decoder
sample_rate,
channels,
})
.map_err(|e| {
log::error!("Player: Failed to send to audio thread: {}", e);