aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-02 13:28:23 +0100
committersalmonik <salmonik@tuta.io>2026-11-02 13:28:23 +0100
commit6e62ee4057b8ea3144f2f4df85e5d56c37c8fd72 (patch)
tree5cc559e2eaad47f632294914a9d57133e863b1b8
parentconfig: parse the config once and reuse it until the file changes (diff)
downloadrsplayer-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.md1
-rw-r--r--src/audio.rs159
-rw-r--r--src/config.rs35
-rw-r--r--src/daemon.rs2
-rw-r--r--tests/config.rs42
5 files changed, 211 insertions, 28 deletions
diff --git a/README.md b/README.md
index c2cf39a..678d085 100644
--- a/README.md
+++ b/README.md
@@ -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
46all other values can be changed in runtime using commands 47all 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)]
140impl AudioEngine { 140impl 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.
591fn 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]
155fn 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]
174fn 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]
185fn 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}