diff options
Diffstat (limited to '')
| -rw-r--r-- | src/audio.rs | 159 | ||||
| -rw-r--r-- | src/config.rs | 35 | ||||
| -rw-r--r-- | src/daemon.rs | 2 |
3 files changed, 168 insertions, 28 deletions
diff --git a/src/audio.rs b/src/audio.rs index 5ce00ae..dc7bc56 100644 --- a/src/audio.rs +++ b/src/audio.rs | |||
| @@ -138,7 +138,8 @@ pub struct AudioEngine { | |||
| 138 | 138 | ||
| 139 | #[allow(clippy::new_without_default)] | 139 | #[allow(clippy::new_without_default)] |
| 140 | impl AudioEngine { | 140 | impl AudioEngine { |
| 141 | pub fn new() -> Self { | 141 | /// `sample_rate` forces the output rate; `None` follows each track. |
| 142 | pub fn new(sample_rate: Option<u32>) -> Self { | ||
| 142 | let (tx, rx) = mpsc::channel(); | 143 | let (tx, rx) = mpsc::channel(); |
| 143 | let is_playing = Arc::new(AtomicBool::new(false)); | 144 | let is_playing = Arc::new(AtomicBool::new(false)); |
| 144 | let is_playing_clone = Arc::clone(&is_playing); | 145 | let is_playing_clone = Arc::clone(&is_playing); |
| @@ -161,6 +162,7 @@ impl AudioEngine { | |||
| 161 | volume_clone, | 162 | volume_clone, |
| 162 | track_ended_clone, | 163 | track_ended_clone, |
| 163 | position_clone, | 164 | position_clone, |
| 165 | sample_rate, | ||
| 164 | ); | 166 | ); |
| 165 | }); | 167 | }); |
| 166 | 168 | ||
| @@ -575,6 +577,63 @@ impl RecoveryState { | |||
| 575 | } | 577 | } |
| 576 | } | 578 | } |
| 577 | 579 | ||
| 580 | /// Picks the output config for a requested sample rate. | ||
| 581 | /// | ||
| 582 | /// `requested` is the rate the audio is already in — the track's own, or the | ||
| 583 | /// value forced by the `sample_rate` setting — and `None` keeps whatever the | ||
| 584 | /// device reports as its default. | ||
| 585 | /// | ||
| 586 | /// cpal asks the ALSA "default" PCM for its default config, and pipewire-alsa | ||
| 587 | /// answers 44.1 kHz regardless of what the graph runs at. Opening the stream | ||
| 588 | /// at the track's rate instead means our (linear, cheap) resampler stays out of | ||
| 589 | /// the path and PipeWire's polyphase one converts to the graph rate only when | ||
| 590 | /// the track does not already match it. | ||
| 591 | fn choose_output_config( | ||
| 592 | device: &cpal::Device, | ||
| 593 | requested: Option<u32>, | ||
| 594 | ) -> Result<cpal::SupportedStreamConfig, String> { | ||
| 595 | use cpal::traits::DeviceTrait; | ||
| 596 | |||
| 597 | let default = device | ||
| 598 | .default_output_config() | ||
| 599 | .map_err(|e| format!("Failed to get output config: {}", e))?; | ||
| 600 | let Some(target) = requested else { | ||
| 601 | return Ok(default); | ||
| 602 | }; | ||
| 603 | if default.sample_rate() == target { | ||
| 604 | return Ok(default); | ||
| 605 | } | ||
| 606 | |||
| 607 | let ranges = device | ||
| 608 | .supported_output_configs() | ||
| 609 | .map_err(|e| format!("Failed to query output configs: {}", e))?; | ||
| 610 | let mut fallback = None; | ||
| 611 | for range in ranges { | ||
| 612 | if range.min_sample_rate() > target || range.max_sample_rate() < target { | ||
| 613 | continue; | ||
| 614 | } | ||
| 615 | // Prefer a range that keeps the default's channel count and format. | ||
| 616 | if range.channels() == default.channels() | ||
| 617 | && range.sample_format() == default.sample_format() | ||
| 618 | { | ||
| 619 | return Ok(range.with_sample_rate(target)); | ||
| 620 | } | ||
| 621 | fallback.get_or_insert(range); | ||
| 622 | } | ||
| 623 | |||
| 624 | match fallback { | ||
| 625 | Some(range) => Ok(range.with_sample_rate(target)), | ||
| 626 | None => { | ||
| 627 | crate::debug_eprintln(format_args!( | ||
| 628 | "Output does not support {} Hz, using {} Hz", | ||
| 629 | target, | ||
| 630 | default.sample_rate() | ||
| 631 | )); | ||
| 632 | Ok(default) | ||
| 633 | } | ||
| 634 | } | ||
| 635 | } | ||
| 636 | |||
| 578 | /// Rebuilds the output stream after the backend broke it. | 637 | /// Rebuilds the output stream after the backend broke it. |
| 579 | /// | 638 | /// |
| 580 | /// The default output can change while the daemon runs (a re-plugged DAC, a | 639 | /// The default output can change while the daemon runs (a re-plugged DAC, a |
| @@ -584,16 +643,21 @@ fn rebuild_stream( | |||
| 584 | host: &cpal::Host, | 643 | host: &cpal::Host, |
| 585 | device: &mut cpal::Device, | 644 | device: &mut cpal::Device, |
| 586 | ctx: &StreamContext, | 645 | ctx: &StreamContext, |
| 646 | sample_rate: Option<u32>, | ||
| 587 | ) -> Result<cpal::Stream, String> { | 647 | ) -> Result<cpal::Stream, String> { |
| 588 | use cpal::traits::{DeviceTrait, HostTrait}; | 648 | use cpal::traits::HostTrait; |
| 589 | 649 | ||
| 590 | if let Some(fresh) = host.default_output_device() { | 650 | if let Some(fresh) = host.default_output_device() { |
| 591 | *device = fresh; | 651 | *device = fresh; |
| 592 | } | 652 | } |
| 593 | 653 | ||
| 594 | let config = device | 654 | // Keep the rate the track was opened at, so the resampler ratio stays valid. |
| 595 | .default_output_config() | 655 | let track_rate = ctx |
| 596 | .map_err(|e| format!("Failed to get output config while rebuilding: {}", e))?; | 656 | .audio_data |
| 657 | .lock() | ||
| 658 | .ok() | ||
| 659 | .and_then(|data| data.as_ref().map(|state| state.sample_rate)); | ||
| 660 | let config = choose_output_config(device, sample_rate.or(track_rate))?; | ||
| 597 | let device_sample_rate = config.sample_rate(); | 661 | let device_sample_rate = config.sample_rate(); |
| 598 | 662 | ||
| 599 | // The device may report a different rate now than when the track was | 663 | // The device may report a different rate now than when the track was |
| @@ -619,8 +683,9 @@ fn audio_thread( | |||
| 619 | volume: Arc<Mutex<f32>>, | 683 | volume: Arc<Mutex<f32>>, |
| 620 | track_ended: Arc<AtomicBool>, | 684 | track_ended: Arc<AtomicBool>, |
| 621 | position: Arc<Mutex<f64>>, | 685 | position: Arc<Mutex<f64>>, |
| 686 | sample_rate: Option<u32>, | ||
| 622 | ) { | 687 | ) { |
| 623 | use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; | 688 | use cpal::traits::{HostTrait, StreamTrait}; |
| 624 | 689 | ||
| 625 | let host = cpal::default_host(); | 690 | let host = cpal::default_host(); |
| 626 | let mut device = match host.default_output_device() { | 691 | let mut device = match host.default_output_device() { |
| @@ -682,25 +747,6 @@ fn audio_thread( | |||
| 682 | recovery.clear(); | 747 | recovery.clear(); |
| 683 | ctx.failed.store(false, Ordering::SeqCst); | 748 | ctx.failed.store(false, Ordering::SeqCst); |
| 684 | 749 | ||
| 685 | // Query the config every time instead of caching it at | ||
| 686 | // startup: the default device or its rate can change while | ||
| 687 | // the daemon runs. | ||
| 688 | let config = match device.default_output_config() { | ||
| 689 | Ok(c) => c, | ||
| 690 | Err(e) => { | ||
| 691 | eprintln!("Failed to get output config: {}", e); | ||
| 692 | notification::notify_error("Failed to query audio output"); | ||
| 693 | is_playing.store(false, Ordering::SeqCst); | ||
| 694 | continue; | ||
| 695 | } | ||
| 696 | }; | ||
| 697 | let device_sample_rate = config.sample_rate(); | ||
| 698 | crate::debug_eprintln(format_args!( | ||
| 699 | "Default output: {} Hz, {} channels", | ||
| 700 | device_sample_rate, | ||
| 701 | config.channels() | ||
| 702 | )); | ||
| 703 | |||
| 704 | match open_audio_file(&path) { | 750 | match open_audio_file(&path) { |
| 705 | Ok(mut data) => { | 751 | Ok(mut data) => { |
| 706 | crate::debug_eprintln(format_args!( | 752 | crate::debug_eprintln(format_args!( |
| @@ -708,6 +754,29 @@ fn audio_thread( | |||
| 708 | data.sample_rate, data.channels | 754 | data.sample_rate, data.channels |
| 709 | )); | 755 | )); |
| 710 | 756 | ||
| 757 | // Ask for the track's own rate unless one is forced: | ||
| 758 | // the output then runs at the rate the samples are | ||
| 759 | // already in, and PipeWire converts to the graph rate | ||
| 760 | // with its polyphase resampler. | ||
| 761 | let config = match choose_output_config( | ||
| 762 | &device, | ||
| 763 | sample_rate.or(Some(data.sample_rate)), | ||
| 764 | ) { | ||
| 765 | Ok(c) => c, | ||
| 766 | Err(e) => { | ||
| 767 | eprintln!("Failed to get output config: {}", e); | ||
| 768 | notification::notify_error("Failed to query audio output"); | ||
| 769 | is_playing.store(false, Ordering::SeqCst); | ||
| 770 | continue; | ||
| 771 | } | ||
| 772 | }; | ||
| 773 | let device_sample_rate = config.sample_rate(); | ||
| 774 | crate::debug_eprintln(format_args!( | ||
| 775 | "Output: {} Hz, {} channels", | ||
| 776 | device_sample_rate, | ||
| 777 | config.channels() | ||
| 778 | )); | ||
| 779 | |||
| 711 | if data.sample_rate != device_sample_rate { | 780 | if data.sample_rate != device_sample_rate { |
| 712 | crate::debug_eprintln(format_args!( | 781 | crate::debug_eprintln(format_args!( |
| 713 | "Resampling from {} Hz to {} Hz", | 782 | "Resampling from {} Hz to {} Hz", |
| @@ -870,7 +939,7 @@ fn audio_thread( | |||
| 870 | && recovery.retry_at.is_some_and(|at| at <= Instant::now()); | 939 | && recovery.retry_at.is_some_and(|at| at <= Instant::now()); |
| 871 | if retry_due { | 940 | if retry_due { |
| 872 | recovery.retry_at = None; | 941 | recovery.retry_at = None; |
| 873 | match rebuild_stream(&host, &mut device, &ctx) { | 942 | match rebuild_stream(&host, &mut device, &ctx, sample_rate) { |
| 874 | Ok(stream) => { | 943 | Ok(stream) => { |
| 875 | current_stream = Some(stream); | 944 | current_stream = Some(stream); |
| 876 | recovery.stream_built(Instant::now()); | 945 | recovery.stream_built(Instant::now()); |
| @@ -1117,6 +1186,42 @@ mod tests { | |||
| 1117 | } | 1186 | } |
| 1118 | 1187 | ||
| 1119 | #[test] | 1188 | #[test] |
| 1189 | fn keeps_the_device_rate_when_nothing_is_requested() { | ||
| 1190 | use cpal::traits::{DeviceTrait, HostTrait}; | ||
| 1191 | |||
| 1192 | let host = cpal::default_host(); | ||
| 1193 | let Some(device) = host.default_output_device() else { | ||
| 1194 | eprintln!("skipped: no output device available"); | ||
| 1195 | return; | ||
| 1196 | }; | ||
| 1197 | let Ok(default) = device.default_output_config() else { | ||
| 1198 | eprintln!("skipped: no default output config available"); | ||
| 1199 | return; | ||
| 1200 | }; | ||
| 1201 | |||
| 1202 | let config = choose_output_config(&device, None).expect("config"); | ||
| 1203 | |||
| 1204 | assert_eq!(config.sample_rate(), default.sample_rate()); | ||
| 1205 | assert_eq!(config.channels(), default.channels()); | ||
| 1206 | } | ||
| 1207 | |||
| 1208 | #[test] | ||
| 1209 | fn opens_at_the_requested_rate() { | ||
| 1210 | use cpal::traits::{DeviceTrait, HostTrait}; | ||
| 1211 | |||
| 1212 | let host = cpal::default_host(); | ||
| 1213 | let Some(device) = host.default_output_device() else { | ||
| 1214 | eprintln!("skipped: no output device available"); | ||
| 1215 | return; | ||
| 1216 | }; | ||
| 1217 | |||
| 1218 | // 48 kHz is supported by any output that pipes through a graph. | ||
| 1219 | let config = choose_output_config(&device, Some(48_000)).expect("config"); | ||
| 1220 | |||
| 1221 | assert_eq!(config.sample_rate(), 48_000); | ||
| 1222 | } | ||
| 1223 | |||
| 1224 | #[test] | ||
| 1120 | fn rebuilds_stream_after_error() { | 1225 | fn rebuilds_stream_after_error() { |
| 1121 | use cpal::traits::{HostTrait, StreamTrait}; | 1226 | use cpal::traits::{HostTrait, StreamTrait}; |
| 1122 | 1227 | ||
| @@ -1127,7 +1232,7 @@ mod tests { | |||
| 1127 | }; | 1232 | }; |
| 1128 | 1233 | ||
| 1129 | let ctx = test_context(); | 1234 | let ctx = test_context(); |
| 1130 | let Ok(stream) = rebuild_stream(&host, &mut device, &ctx) else { | 1235 | let Ok(stream) = rebuild_stream(&host, &mut device, &ctx, None) else { |
| 1131 | eprintln!("skipped: cannot build output stream"); | 1236 | eprintln!("skipped: cannot build output stream"); |
| 1132 | return; | 1237 | return; |
| 1133 | }; | 1238 | }; |
diff --git a/src/config.rs b/src/config.rs index a08f01f..52afd92 100644 --- a/src/config.rs +++ b/src/config.rs | |||
| @@ -25,6 +25,8 @@ pub struct Config { | |||
| 25 | pub volume: Option<u8>, | 25 | pub volume: Option<u8>, |
| 26 | pub shuffle: Option<String>, | 26 | pub shuffle: Option<String>, |
| 27 | pub display_format: Option<String>, | 27 | pub display_format: Option<String>, |
| 28 | /// Output sample rate: `None` follows the track, `Some` forces a rate. | ||
| 29 | pub sample_rate: Option<String>, | ||
| 28 | } | 30 | } |
| 29 | 31 | ||
| 30 | /// Identity of the config file on disk, used to tell whether a cached copy is | 32 | /// Identity of the config file on disk, used to tell whether a cached copy is |
| @@ -64,6 +66,7 @@ impl Config { | |||
| 64 | volume: Some(50), | 66 | volume: Some(50), |
| 65 | shuffle: None, | 67 | shuffle: None, |
| 66 | display_format: Some(String::from("$ARTIST - $TRACK")), | 68 | display_format: Some(String::from("$ARTIST - $TRACK")), |
| 69 | sample_rate: None, | ||
| 67 | }; | 70 | }; |
| 68 | default.save()?; | 71 | default.save()?; |
| 69 | return Ok(default); | 72 | return Ok(default); |
| @@ -192,6 +195,8 @@ impl Config { | |||
| 192 | output.push_str(&format!("shuffle = \"{}\"\n", shuffle)); | 195 | output.push_str(&format!("shuffle = \"{}\"\n", shuffle)); |
| 193 | let fmt = self.display_format.clone().unwrap_or_default(); | 196 | let fmt = self.display_format.clone().unwrap_or_default(); |
| 194 | output.push_str(&format!("display_format = \"{}\"\n", fmt)); | 197 | output.push_str(&format!("display_format = \"{}\"\n", fmt)); |
| 198 | let sample_rate = self.sample_rate.as_deref().unwrap_or("auto"); | ||
| 199 | output.push_str(&format!("sample_rate = \"{}\"\n", sample_rate)); | ||
| 195 | if let Some(parent) = self.config_path.parent() { | 200 | if let Some(parent) = self.config_path.parent() { |
| 196 | fs::create_dir_all(parent)?; | 201 | fs::create_dir_all(parent)?; |
| 197 | } | 202 | } |
| @@ -207,6 +212,11 @@ impl Config { | |||
| 207 | Ok(()) | 212 | Ok(()) |
| 208 | } | 213 | } |
| 209 | 214 | ||
| 215 | /// Forced output rate, or `None` to follow each track. | ||
| 216 | pub fn sample_rate_hz(&self) -> Option<u32> { | ||
| 217 | self.sample_rate.as_deref().and_then(|s| s.parse().ok()) | ||
| 218 | } | ||
| 219 | |||
| 210 | pub fn get(&self, key: &str) -> Result<String, Box<dyn std::error::Error>> { | 220 | pub fn get(&self, key: &str) -> Result<String, Box<dyn std::error::Error>> { |
| 211 | match key { | 221 | match key { |
| 212 | "music_paths" => Ok(self | 222 | "music_paths" => Ok(self |
| @@ -252,6 +262,10 @@ impl Config { | |||
| 252 | .display_format | 262 | .display_format |
| 253 | .clone() | 263 | .clone() |
| 254 | .unwrap_or_else(|| "not set".to_string())), | 264 | .unwrap_or_else(|| "not set".to_string())), |
| 265 | "sample_rate" => Ok(self | ||
| 266 | .sample_rate | ||
| 267 | .clone() | ||
| 268 | .unwrap_or_else(|| "auto".to_string())), | ||
| 255 | _ => Err(format!("Unknown setting: {}", key).into()), | 269 | _ => Err(format!("Unknown setting: {}", key).into()), |
| 256 | } | 270 | } |
| 257 | } | 271 | } |
| @@ -291,6 +305,20 @@ impl Config { | |||
| 291 | "display_format" => { | 305 | "display_format" => { |
| 292 | self.display_format = Some(value.to_string()); | 306 | self.display_format = Some(value.to_string()); |
| 293 | } | 307 | } |
| 308 | "sample_rate" => { | ||
| 309 | let value = value.trim(); | ||
| 310 | if value.eq_ignore_ascii_case("auto") || value == "off" { | ||
| 311 | self.sample_rate = None; | ||
| 312 | } else { | ||
| 313 | let rate: u32 = value | ||
| 314 | .parse() | ||
| 315 | .map_err(|_| format!("Invalid sample rate: {}", value))?; | ||
| 316 | if !(8_000..=384_000).contains(&rate) { | ||
| 317 | return Err(format!("Sample rate must be 8000-384000 Hz: {}", value).into()); | ||
| 318 | } | ||
| 319 | self.sample_rate = Some(rate.to_string()); | ||
| 320 | } | ||
| 321 | } | ||
| 294 | _ => return Err(format!("Unknown setting: {}", key).into()), | 322 | _ => return Err(format!("Unknown setting: {}", key).into()), |
| 295 | } | 323 | } |
| 296 | Ok(()) | 324 | Ok(()) |
| @@ -357,6 +385,12 @@ impl Config { | |||
| 357 | .clone() | 385 | .clone() |
| 358 | .unwrap_or_else(|| "not set".to_string()) | 386 | .unwrap_or_else(|| "not set".to_string()) |
| 359 | ); | 387 | ); |
| 388 | println!( | ||
| 389 | "sample_rate = {}", | ||
| 390 | self.sample_rate | ||
| 391 | .clone() | ||
| 392 | .unwrap_or_else(|| "auto".to_string()) | ||
| 393 | ); | ||
| 360 | } | 394 | } |
| 361 | 395 | ||
| 362 | pub fn unset(&mut self, key: &str) -> Result<(), Box<dyn std::error::Error>> { | 396 | pub fn unset(&mut self, key: &str) -> Result<(), Box<dyn std::error::Error>> { |
| @@ -370,6 +404,7 @@ impl Config { | |||
| 370 | "volume" => self.volume = None, | 404 | "volume" => self.volume = None, |
| 371 | "shuffle" => self.shuffle = None, | 405 | "shuffle" => self.shuffle = None, |
| 372 | "display_format" => self.display_format = None, | 406 | "display_format" => self.display_format = None, |
| 407 | "sample_rate" => self.sample_rate = None, | ||
| 373 | _ => return Err(format!("Unknown setting: {}", key).into()), | 408 | _ => return Err(format!("Unknown setting: {}", key).into()), |
| 374 | } | 409 | } |
| 375 | Ok(()) | 410 | Ok(()) |
diff --git a/src/daemon.rs b/src/daemon.rs index eaae64f..3dcb802 100644 --- a/src/daemon.rs +++ b/src/daemon.rs | |||
| @@ -225,7 +225,7 @@ pub fn start_daemon() -> Result<(), Box<dyn std::error::Error>> { | |||
| 225 | let listener = UnixListener::bind(&socket_path)?; | 225 | let listener = UnixListener::bind(&socket_path)?; |
| 226 | println!("Listening on {:?}", socket_path); | 226 | println!("Listening on {:?}", socket_path); |
| 227 | 227 | ||
| 228 | let audio_engine = Arc::new(AudioEngine::new()); | 228 | let audio_engine = Arc::new(AudioEngine::new(config.sample_rate_hz())); |
| 229 | let queue = Arc::new(Mutex::new(Queue::new())); | 229 | let queue = Arc::new(Mutex::new(Queue::new())); |
| 230 | 230 | ||
| 231 | // Load and apply saved settings | 231 | // Load and apply saved settings |
