aboutsummaryrefslogtreecommitdiffstats
path: root/src/audio.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/audio.rs')
-rw-r--r--src/audio.rs159
1 files changed, 132 insertions, 27 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 };