aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/audio.rs159
-rw-r--r--src/config.rs35
-rw-r--r--src/daemon.rs2
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)]
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