aboutsummaryrefslogtreecommitdiffstats
path: root/src/audio.rs
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 /src/audio.rs
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.
Diffstat (limited to '')
-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 };