diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-02 13:28:23 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-02 13:28:23 +0100 |
| commit | 6e62ee4057b8ea3144f2f4df85e5d56c37c8fd72 (patch) | |
| tree | 5cc559e2eaad47f632294914a9d57133e863b1b8 | |
| parent | config: parse the config once and reuse it until the file changes (diff) | |
| download | rsplayer-6e62ee4057b8ea3144f2f4df85e5d56c37c8fd72.tar.gz rsplayer-6e62ee4057b8ea3144f2f4df85e5d56c37c8fd72.zip | |
audio: open the output at the track's rate instead of pw-alsa's 44100
cpal asks the ALSA "default" PCM for its default config and pipewire-alsa
answers 44.1 kHz no matter what the graph runs at, so 48 kHz material was
resampled down to 44.1 in our linear resampler and then back up to 48 by
PipeWire. The stream now opens at the track's own rate (when the device
supports it), which keeps our resampler out of the path and leaves the
conversion to PipeWire's polyphase one.
The new `sample_rate` setting forces a fixed rate instead ("auto" by
default), for anyone who would rather match the graph exactly.
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | src/audio.rs | 159 | ||||
| -rw-r--r-- | src/config.rs | 35 | ||||
| -rw-r--r-- | src/daemon.rs | 2 | ||||
| -rw-r--r-- | tests/config.rs | 42 |
5 files changed, 211 insertions, 28 deletions
| @@ -42,6 +42,7 @@ configuration is generated at startup in $XDG_CONFIG_HOME/rsplayer/config.toml | |||
| 42 | - display_format (supports $ARTIST $ALBUM and $TRACK) | 42 | - display_format (supports $ARTIST $ALBUM and $TRACK) |
| 43 | - music_paths (for DB usage) | 43 | - music_paths (for DB usage) |
| 44 | - scan_interval (same as above) | 44 | - scan_interval (same as above) |
| 45 | - sample_rate ("auto" follows each track's own rate, or e.g. 48000 to force one) | ||
| 45 | 46 | ||
| 46 | all other values can be changed in runtime using commands | 47 | all other values can be changed in runtime using commands |
| 47 | 48 | ||
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 |
diff --git a/tests/config.rs b/tests/config.rs index 7b9d179..6a44a17 100644 --- a/tests/config.rs +++ b/tests/config.rs | |||
| @@ -21,6 +21,7 @@ fn test_config_get_scan_interval() { | |||
| 21 | volume: None, | 21 | volume: None, |
| 22 | shuffle: None, | 22 | shuffle: None, |
| 23 | display_format: Some(String::from("$ARTIST - $TRACK")), | 23 | display_format: Some(String::from("$ARTIST - $TRACK")), |
| 24 | sample_rate: None, | ||
| 24 | }; | 25 | }; |
| 25 | assert_eq!(config.get("scan_interval").unwrap(), "3600"); | 26 | assert_eq!(config.get("scan_interval").unwrap(), "3600"); |
| 26 | } | 27 | } |
| @@ -38,6 +39,7 @@ fn test_config_get_not_set() { | |||
| 38 | volume: None, | 39 | volume: None, |
| 39 | shuffle: None, | 40 | shuffle: None, |
| 40 | display_format: None, | 41 | display_format: None, |
| 42 | sample_rate: None, | ||
| 41 | }; | 43 | }; |
| 42 | assert_eq!(config.get("scan_interval").unwrap(), "not set"); | 44 | assert_eq!(config.get("scan_interval").unwrap(), "not set"); |
| 43 | } | 45 | } |
| @@ -55,6 +57,7 @@ fn test_config_get_volume() { | |||
| 55 | volume: Some(75), | 57 | volume: Some(75), |
| 56 | shuffle: None, | 58 | shuffle: None, |
| 57 | display_format: None, | 59 | display_format: None, |
| 60 | sample_rate: None, | ||
| 58 | }; | 61 | }; |
| 59 | assert_eq!(config.get("volume").unwrap(), "75"); | 62 | assert_eq!(config.get("volume").unwrap(), "75"); |
| 60 | } | 63 | } |
| @@ -135,6 +138,7 @@ fn test_config_unset_volume() { | |||
| 135 | volume: Some(50), | 138 | volume: Some(50), |
| 136 | shuffle: None, | 139 | shuffle: None, |
| 137 | display_format: None, | 140 | display_format: None, |
| 141 | sample_rate: None, | ||
| 138 | }; | 142 | }; |
| 139 | config.unset("volume").unwrap(); | 143 | config.unset("volume").unwrap(); |
| 140 | assert_eq!(config.volume, None); | 144 | assert_eq!(config.volume, None); |
| @@ -146,3 +150,41 @@ fn test_config_unset_unknown_key() { | |||
| 146 | let result = config.unset("unknown_key"); | 150 | let result = config.unset("unknown_key"); |
| 147 | assert!(result.is_err()); | 151 | assert!(result.is_err()); |
| 148 | } | 152 | } |
| 153 | |||
| 154 | #[test] | ||
| 155 | fn test_config_sample_rate_defaults_to_auto() { | ||
| 156 | let config = Config { | ||
| 157 | config_path: PathBuf::from("/test/config.toml"), | ||
| 158 | music_paths: vec![], | ||
| 159 | scan_interval: None, | ||
| 160 | lastfm_api_key: None, | ||
| 161 | lastfm_api_secret: None, | ||
| 162 | lastfm_session_key: None, | ||
| 163 | listenbrainz_token: None, | ||
| 164 | volume: None, | ||
| 165 | shuffle: None, | ||
| 166 | display_format: None, | ||
| 167 | sample_rate: None, | ||
| 168 | }; | ||
| 169 | assert_eq!(config.get("sample_rate").unwrap(), "auto"); | ||
| 170 | assert_eq!(config.sample_rate_hz(), None); | ||
| 171 | } | ||
| 172 | |||
| 173 | #[test] | ||
| 174 | fn test_config_set_sample_rate() { | ||
| 175 | let mut config = Config::default(); | ||
| 176 | config.set("sample_rate", "48000").unwrap(); | ||
| 177 | assert_eq!(config.get("sample_rate").unwrap(), "48000"); | ||
| 178 | assert_eq!(config.sample_rate_hz(), Some(48_000)); | ||
| 179 | |||
| 180 | config.set("sample_rate", "auto").unwrap(); | ||
| 181 | assert_eq!(config.sample_rate_hz(), None); | ||
| 182 | } | ||
| 183 | |||
| 184 | #[test] | ||
| 185 | fn test_config_rejects_bad_sample_rate() { | ||
| 186 | let mut config = Config::default(); | ||
| 187 | assert!(config.set("sample_rate", "fast").is_err()); | ||
| 188 | assert!(config.set("sample_rate", "100").is_err()); | ||
| 189 | assert!(config.set("sample_rate", "999999").is_err()); | ||
| 190 | } | ||
